TypeScript Branded Type Pattern: 6 Core Patterns for Type-Safe Domain Models
Four Pain Points of Type Safety
You define UserId and OrderId both as string, then the compiler silently accepts swapped function arguments. Email addresses and plain strings are freely assignable — runtime format errors go undetected. API responses get a quick as User assertion, making type safety a facade. DraftOrder and ApprovedOrder share the same interface, with state transitions relying on developer discipline. TypeScript's structural type system makes all string types look identical — domain concepts vanish at the type level.
This article starts from core concepts and guides you through basic Branded Type → Zod validation integration → smart constructors → domain model composition → serialization/deserialization → immutable state machines with 6 core patterns for compile-time and runtime type-safe domain models.
Core Concepts
| Concept | Description |
|---|---|
| Branded Type | Attaches a brand marker to a primitive type via intersection, achieving nominal typing, e.g., string & { __brand: 'UserId' } |
| Nominal Type | A type system where compatibility is based on declared names rather than structure. TypeScript doesn't support this natively — Branded Types simulate it |
| Structural Type | TypeScript's default type system where structural compatibility allows assignment. UserId and OrderId cannot be distinguished |
| Brand Symbol | The unique identifier of a branded type, typically using unreachable property names like __brand or _brand — doesn't exist at runtime |
| Type Narrowing | Narrowing a broad type to a branded type via type guards, precisely distinguishing different brands in control flow |
| Zod Validation | Runtime type validation library that combines with branded types for dual compile-time + runtime safety |
| Type Guard | A function returning a type predicate val is BrandType, performing runtime checks while narrowing compile-time types |
| Immutable Type | Using readonly and Readonly to ensure branded type instances cannot be mutated, preventing accidental domain object modification |
Branded Type Workflow
Compile-time Safety:
Branded Type definition → Smart constructor (validation) → Type guard (narrowing) → Domain model composition
Runtime Safety:
Zod Schema validation → Constructor checks → Serialization/deserialization → State machine constraints
End-to-end Safety:
API boundary Zod validation → Branded type construction → Business logic type constraints → Serialization output validation
Problem Analysis: 5 Major Challenges
- Structural type system limitations: TypeScript uses structural typing —
type UserId = stringandtype OrderId = stringare fully equivalent. The compiler cannot distinguish isomorphic types across domains. Branded Types are needed to simulate nominal typing. - Compile-time/runtime type inconsistency: Brand markers like
__brandare erased at runtime. Precise compile-time branded types cannot guarantee runtime data validity. API boundaries must pair with runtime validation. - Branded type serialization difficulties: Brand markers are lost during JSON serialization. Reconstructing branded types after deserialization requires special handling for network transmission and persistence scenarios.
- Complex generic constraints: Combining branded types with generics makes constraint expression difficult. The inference and narrowing of
<T extends Brand<string>>requires careful type relationship design. - Limited library type extension: Third-party library type definitions cannot directly receive brand markers. Module augmentation and type mapping are needed for indirect extension.
Step-by-Step: 6 Core Patterns
Pattern 1: Basic Branded Type Definition and Usage
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Email = Brand<string, 'Email'>;
type PositiveInt = Brand<number, 'PositiveInt'>;
function createUserId(value: string): UserId {
return value as UserId;
}
function createOrderId(value: string): OrderId {
return value as OrderId;
}
const userId: UserId = createUserId('user-001');
const orderId: OrderId = createOrderId('order-001');
// ❌ Compile error: OrderId is not assignable to UserId
// const wrong: UserId = orderId;
function getUser(id: UserId): string {
return `User: ${id}`;
}
getUser(userId);
// ❌ getUser(orderId); // Type error: OrderId not assignable to UserId
Pattern 2: Branded Type with Zod Validation Integration
import { z } from 'zod';
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type Age = Brand<number, 'Age'>;
const UserIdSchema = z.string().uuid().transform((v) => v as UserId);
const EmailSchema = z.string().email().transform((v) => v as Email);
const AgeSchema = z.number().int().min(0).max(150).transform((v) => v as Age);
function parseUserId(value: unknown): UserId {
return UserIdSchema.parse(value);
}
function parseEmail(value: unknown): Email {
return EmailSchema.parse(value);
}
function parseAge(value: unknown): Age {
return AgeSchema.parse(value);
}
const userId = parseUserId('550e8400-e29b-41d4-a716-446655440000');
const email = parseEmail('user@example.com');
const age = parseAge(25);
// ❌ parseUserId('not-a-uuid'); // ZodError
// ❌ parseEmail('invalid-email'); // ZodError
// ❌ parseAge(-1); // ZodError
const UserSchema = z.object({
id: UserIdSchema,
email: EmailSchema,
age: AgeSchema,
name: z.string().min(1),
});
type User = z.infer<typeof UserSchema>;
function safeParseUser(data: unknown) {
return UserSchema.safeParse(data);
}
Pattern 3: Branded Type Smart Constructors
type Brand<T, B extends string> = T & { readonly __brand: B };
type Result<T, E = string> =
| { success: true; value: T }
| { success: false; error: E };
class BrandConstructor<T, B extends string> {
constructor(
private readonly brand: B,
private readonly validate: (value: T) => boolean,
private readonly errorMessage: string,
) {}
create(value: T): Brand<T, B> {
if (!this.validate(value)) {
throw new Error(`${this.brand}: ${this.errorMessage}`);
}
return value as Brand<T, B>;
}
safeCreate(value: T): Result<Brand<T, B>> {
if (!this.validate(value)) {
return { success: false, error: `${this.brand}: ${this.errorMessage}` };
}
return { success: true, value: value as Brand<T, B> };
}
isBrand(value: T): value is Brand<T, B> {
return this.validate(value);
}
unwrap(value: Brand<T, B>): T {
return value as T;
}
}
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type PositiveInt = Brand<number, 'PositiveInt'>;
const UserIdBrand = new BrandConstructor<string, 'UserId'>(
'UserId',
(v) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v),
'Invalid UUID format',
);
const EmailBrand = new BrandConstructor<string, 'Email'>(
'Email',
(v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
'Invalid email format',
);
const PositiveIntBrand = new BrandConstructor<number, 'PositiveInt'>(
'PositiveInt',
(v) => Number.isInteger(v) && v > 0,
'Must be a positive integer',
);
const userId = UserIdBrand.create('550e8400-e29b-41d4-a716-446655440000');
const emailResult = EmailBrand.safeCreate('user@example.com');
const ageResult = PositiveIntBrand.safeCreate(-5);
if (emailResult.success) {
console.log(emailResult.value);
}
Pattern 4: Domain Model Type-Safe Composition
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
type Money = Brand<number, 'Money'>;
type SKU = Brand<string, 'SKU'>;
interface OrderLine {
sku: SKU;
quantity: PositiveInt;
unitPrice: Money;
}
interface DraftOrder {
readonly status: 'draft';
readonly id: OrderId;
readonly customerId: UserId;
readonly lines: readonly OrderLine[];
readonly createdAt: Date;
}
interface ApprovedOrder {
readonly status: 'approved';
readonly id: OrderId;
readonly customerId: UserId;
readonly lines: readonly OrderLine[];
readonly approvedBy: UserId;
readonly approvedAt: Date;
}
interface ShippedOrder {
readonly status: 'shipped';
readonly id: OrderId;
readonly customerId: UserId;
readonly lines: readonly OrderLine[];
readonly approvedBy: UserId;
readonly trackingNumber: string;
readonly shippedAt: Date;
}
type Order = DraftOrder | ApprovedOrder | ShippedOrder;
function approveOrder(order: DraftOrder, approvedBy: UserId): ApprovedOrder {
return {
...order,
status: 'approved',
approvedBy,
approvedAt: new Date(),
};
}
function shipOrder(order: ApprovedOrder, trackingNumber: string): ShippedOrder {
return {
...order,
status: 'shipped',
trackingNumber,
shippedAt: new Date(),
};
}
// ❌ approveOrder(approvedOrder, userId); // Type error: ApprovedOrder not assignable to DraftOrder
// ❌ shipOrder(draftOrder, 'tracking'); // Type error: DraftOrder not assignable to ApprovedOrder
function getOrderTotal(order: Order): Money {
const total = order.lines.reduce(
(sum, line) => sum + (line.unitPrice as number) * (line.quantity as number),
0,
);
return total as Money;
}
Pattern 5: Branded Type Serialization and Deserialization
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
interface SerializableBrand<T, B extends string> {
serialize(value: Brand<T, B>): T;
deserialize(raw: unknown): Brand<T, B>;
}
function createSerializableBrand<T, B extends string>(
brand: B,
validate: (value: T) => boolean,
): SerializableBrand<T, B> {
return {
serialize(value: Brand<T, B>): T {
return value as T;
},
deserialize(raw: unknown): Brand<T, B> {
if (typeof raw !== 'string' || !validate(raw as T)) {
throw new Error(`Invalid ${brand} value: ${String(raw)}`);
}
return raw as Brand<T, B>;
},
};
}
const UserIdSerDe = createSerializableBrand<string, 'UserId'>(
'UserId',
(v) => /^[0-9a-f]{8}-[0-9a-f]{4}/i.test(v),
);
const EmailSerDe = createSerializableBrand<string, 'Email'>(
'Email',
(v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
);
interface UserDTO {
id: string;
email: string;
name: string;
}
interface User {
readonly id: UserId;
readonly email: Email;
readonly name: string;
}
function toDTO(user: User): UserDTO {
return {
id: UserIdSerDe.serialize(user.id),
email: EmailSerDe.serialize(user.email),
name: user.name,
};
}
function fromDTO(dto: unknown): User {
if (typeof dto !== 'object' || dto === null) {
throw new Error('Invalid user DTO');
}
const raw = dto as Record<string, unknown>;
return {
id: UserIdSerDe.deserialize(raw.id),
email: EmailSerDe.deserialize(raw.email),
name: String(raw.name),
};
}
const user: User = {
id: '550e8400-e29b-41d4-a716-446655440000' as UserId,
email: 'user@example.com' as Email,
name: 'Alice',
};
const dto = toDTO(user);
const restored = fromDTO(dto);
Pattern 6: Immutable Branded Types and State Machines
type Brand<T, B extends string> = T & { readonly __brand: B };
type OrderId = Brand<string, 'OrderId'>;
type UserId = Brand<string, 'UserId'>;
type Draft = 'draft';
type Approved = 'approved';
type Shipped = 'shipped';
type Cancelled = 'cancelled';
interface OrderState<S extends string> {
readonly _tag: S;
}
interface DraftOrder extends OrderState<Draft> {
readonly id: OrderId;
readonly customerId: UserId;
readonly items: readonly string[];
}
interface ApprovedOrder extends OrderState<Approved> {
readonly id: OrderId;
readonly customerId: UserId;
readonly items: readonly string[];
readonly approvedBy: UserId;
readonly approvedAt: Date;
}
interface ShippedOrder extends OrderState<Shipped> {
readonly id: OrderId;
readonly customerId: UserId;
readonly items: readonly string[];
readonly approvedBy: UserId;
readonly trackingNumber: string;
readonly shippedAt: Date;
}
interface CancelledOrder extends OrderState<Cancelled> {
readonly id: OrderId;
readonly customerId: UserId;
readonly reason: string;
readonly cancelledAt: Date;
}
type AnyOrder = DraftOrder | ApprovedOrder | ShippedOrder | CancelledOrder;
type TransitionMap = {
draft: { approve: Approved; cancel: Cancelled };
approved: { ship: Shipped; cancel: Cancelled };
shipped: {};
cancelled: {};
};
type NextState<S extends string, E extends string> =
S extends keyof TransitionMap
? E extends keyof TransitionMap[S]
? TransitionMap[S][E]
: never
: never;
function transition<S extends keyof TransitionMap, E extends keyof TransitionMap[S]>(
order: AnyOrder & OrderState<S>,
event: E,
data: TransitionPayload<S, E>,
): AnyOrder & OrderState<NextState<S, E>> {
switch (event) {
case 'approve':
return {
...order,
_tag: 'approved',
approvedBy: (data as { approvedBy: UserId }).approvedBy,
approvedAt: new Date(),
} as AnyOrder & OrderState<NextState<S, E>>;
case 'ship':
return {
...order,
_tag: 'shipped',
trackingNumber: (data as { trackingNumber: string }).trackingNumber,
shippedAt: new Date(),
} as AnyOrder & OrderState<NextState<S, E>>;
case 'cancel':
return {
id: order.id,
customerId: order.customerId,
_tag: 'cancelled',
reason: (data as { reason: string }).reason,
cancelledAt: new Date(),
} as AnyOrder & OrderState<NextState<S, E>>;
default:
throw new Error(`Invalid event: ${String(event)}`);
}
}
type TransitionPayload<S extends string, E extends string> =
E extends 'approve' ? { approvedBy: UserId } :
E extends 'ship' ? { trackingNumber: string } :
E extends 'cancel' ? { reason: string } :
never;
Common Pitfalls
Pitfall 1: Using Type Aliases Instead of Branded Types
// ❌ Type aliases don't create new types — UserId and string are fully equivalent
type UserId = string;
type OrderId = string;
const userId: UserId = '123';
const orderId: OrderId = userId; // Compiles! Type safety lost
// ✅ Use Brand intersection types for nominal typing
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
const orderId: OrderId = userId as UserId; // ❌ Compile error
Pitfall 2: Using Reachable Brand Properties
// ❌ Reachable properties cause runtime side effects, affecting serialization and comparison
type BadBrand<T, B extends string> = T & { brand: B };
const id: BadBrand<string, 'UserId'> = Object.assign('123', { brand: 'UserId' });
JSON.stringify(id); // "123" — brand marker lost
// ✅ Use unreachable brand symbol properties
type Brand<T, B extends string> = T & { readonly __brand: B };
Pitfall 3: Skipping Runtime Validation with Assertions
// ❌ Direct `as` assertion bypasses validation — runtime data may be invalid
const email = 'not-an-email' as Email;
// ✅ Use constructors or Zod validation to ensure runtime validity
const email = EmailBrand.create('user@example.com');
Pitfall 4: Brand Information Lost in Generics
// ❌ Brand info may be erased during generic inference
function first<T>(arr: T[]): T { return arr[0]; }
const ids: UserId[] = [createUserId('1')];
const id = first(ids); // UserId ✓ but may degrade to string with improper constraints
// ✅ Use branded types as generic constraints
function firstBrand<T extends Brand<string, string>>(arr: T[]): T { return arr[0]; }
Pitfall 5: Forgetting Deserialization Validation
// ❌ Asserting branded type directly from JSON.parse, skipping validation
const user = JSON.parse(json) as User;
// ✅ Deserialize through SerDe, validating data integrity
const user = fromDTO(JSON.parse(json));
Error Troubleshooting
| Error | Possible Cause | Solution |
|---|---|---|
Type 'string' is not assignable to type 'Brand<string, "X">' |
Directly assigning primitive to branded type | Use constructor or type assertion to create branded type |
Type 'Brand<string, "A">' is not assignable to type 'Brand<string, "B">' |
Assigning between different branded types | Check if brand markers match, confirm type correspondence |
Property '__brand' does not exist on type 'string' |
Accessing brand property on primitive type | Brand properties are compile-time only, don't exist at runtime |
| Zod validation passes but types don't match | z.transform return type doesn't match branded type |
Ensure transform callback returns value as BrandType |
| Brand info lost after serialization | JSON.stringify ignores non-enumerable properties | Brand markers are unreachable by design — serialize only primitive values |
Generic branded type inferred as unknown |
Generic constraint missing branded type bound | Use T extends Brand<string, string> constraint |
| Type guard doesn't narrow | is predicate type doesn't match actual branded type |
Ensure type guard returns value is BrandType with exact match |
Module augmentation reports __brand missing |
Third-party type definition doesn't declare brand property | Extend type definition via declare module |
Intersection type produces never |
Brand marker conflicts with existing property | Use unique brand property names like __brand_X to avoid conflicts |
| Missing runtime type checking | Relying solely on compile-time branded types | Use Zod or constructors for runtime validation at API boundaries |
Advanced Optimization
Branded Type Utility Library
type Brand<T, B extends string> = T & { readonly __brand: B };
type Unbrand<T> = T extends Brand<infer U, string> ? U : T;
type BrandOf<T> = T extends Brand<any, infer B> ? B : never;
type IsBranded<T> = T extends Brand<any, string> ? true : false;
type ReplaceBrand<T, B extends string> = T extends Brand<infer U, string> ? Brand<U, B> : never;
type BrandedKeys<T> = { [K in keyof T]: IsBranded<T[K]> extends true ? K : never }[keyof T];
Conditional Branded Types
type MaybeBrand<T, B extends string, ShouldBrand extends boolean> =
ShouldBrand extends true ? Brand<T, B> : T;
type ConfigValue<T, Branded extends boolean = true> = {
id: MaybeBrand<string, 'ConfigId', Branded>;
value: T;
updatedAt: Date;
};
Branded Types with Effect System Integration
type Brand<T, B extends string> = T & { readonly __brand: B };
type Validated<T> = Brand<T, 'Validated'>;
type Sanitized<T> = Brand<T, 'Sanitized'>;
function validate<T>(value: T, predicate: (v: T) => boolean): Validated<T> | Error {
return predicate(value) ? value as Validated<T> : new Error('Validation failed');
}
function sanitize(input: string): Sanitized<string> {
return input.replace(/[<>"'&]/g, '') as Sanitized<string>;
}
Comparison
| Feature | Branded Type | Opaque Type | io-ts | runtypes |
|---|---|---|---|---|
| Implementation | Intersection type + brand property | Conditional type + unique symbol | Runtime codec | Runtime type check |
| Compile-time safety | ★★★★★ | ★★★★★ | ★★★★ | ★★★★ |
| Runtime safety | Needs Zod pairing | Needs Zod pairing | ★★★★★ | ★★★★★ |
| Learning curve | Low | Medium | High | Medium |
| Bundle size | 0 | 0 | ~15KB | ~8KB |
| Serialization support | Manual implementation | Manual implementation | ★★★★★ | ★★★★ |
| Generic support | ★★★★ | ★★★ | ★★★★ | ★★★ |
| Ecosystem integration | Zod/Effect | Zod | fp-ts | — |
| TypeScript native | Yes | Yes | No | No |
| Production recommendation | ★★★★★ | ★★★★ | ★★★ | ★★★ |
Branded types aren't "type gymnastics" window dressing — they're the type-safe foundation of domain-driven design. They make
UserIdandOrderIddistinct at compile time, let Zod validation guard data boundaries at runtime, and make domain model state transitions verifiable at the type level. The highest level of type safety isn't writing the most complex types — it's making illegal states unrepresentable.
Recommended Tools
- JSON Formatter — Format API response JSON, quickly troubleshoot branded type serialization issues
- Code Formatter — Format TypeScript code, unify branded type definition style
- cURL to Code — Convert API requests to type-safe TypeScript branded type fetch code
Try these browser-local tools — no sign-up required →