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

  1. Structural type system limitations: TypeScript uses structural typing — type UserId = string and type OrderId = string are fully equivalent. The compiler cannot distinguish isomorphic types across domains. Branded Types are needed to simulate nominal typing.
  2. Compile-time/runtime type inconsistency: Brand markers like __brand are erased at runtime. Precise compile-time branded types cannot guarantee runtime data validity. API boundaries must pair with runtime validation.
  3. 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.
  4. 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.
  5. 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 UserId and OrderId distinct 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.


  • 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 →

#TypeScript品牌类型#Branded Type#类型安全#Nominal Type#类型编程#2026#前端工程