Why Branded Types Matter

When a codebase grows, it's easy to accidentally pass a UserId where an OrderId is expected. Both are just strings at runtime, so TypeScript's structural typing won't catch the mistake. Branded (or nominal) types give you a compile‑time tag that distinguishes otherwise identical primitives without any runtime cost.

The Minimal Boilerplate

// brand.ts
type Brand = K & { __brand: T };

type UserId = Brand;
type OrderId = Brand;

function createUserId(id: string): UserId {
  return id as UserId;
}

function createOrderId(id: string): OrderId {
  return id as OrderId;
}

The Brand helper intersects the original type with a unique brand property that never exists at runtime. The as cast is safe because we only expose the creators, not the raw string.

Real‑World Scenario: Preventing Cross‑Entity Bugs

Imagine a service that fetches a user's recent orders. The function signature looks like:

function getOrdersForUser(userId: UserId): Promise;
function getOrderDetails(orderId: OrderId): Promise;

If a caller mistakenly writes getOrdersForUser(orderId), TypeScript errors immediately. The bug never reaches production, and the fix is a one‑line change.

  • No extra runtime checks — the brand disappears after compilation.
  • Works with existing string‑based APIs; you only wrap at the boundary.
  • Scales to any primitive: numbers, bigints, even objects.

Adding Runtime Validation

Branded types are compile‑time only. When data crosses the wire you still need validation. A tiny parser keeps the guarantee end‑to‑end:

function parseUserId(raw: unknown): UserId {
  if (typeof raw !== 'string' || !/^usr_[a-z0-9]{12}$/.test(raw)) {
    throw new Error('Invalid UserId format');
  }
  return raw as UserId;
}

function parseOrderId(raw: unknown): OrderId {
  if (typeof raw !== 'string' || !/^ord_[A-Z0-9]{10}$/.test(raw)) {
    throw new Error('Invalid OrderId format');
  }
  return raw as OrderId;
}
Keep the parser close to the entry point (HTTP handler, message consumer). Once parsed, the rest of the codebase enjoys pure static safety.

Testing the Guardrails

Unit tests can verify that the type system actually blocks the wrong usage. Using @ts-expect-error comments makes the intent explicit:

// @ts-expect-error: OrderId is not assignable to UserId
const bad: UserId = createOrderId('ord_ABC123');

If the compiler ever stops complaining, the test fails, alerting you to a regression in the branding setup.

Gotchas and When to Skip

Branded types add a layer of indirection. For short‑lived scripts or prototypes the extra files may feel like overkill. Also, libraries that you don't control will still hand you plain strings; you'll need a thin adapter at the boundary. In those cases a simple type UserId = string & { readonly _: unique symbol } inline works just as well.

Overall, the pattern has saved me countless hours debugging mismatched identifiers. It's a small investment that pays off every time a new entity type is introduced.