Skip to content
advanced Phase 11 · TypeScript Advanced Patterns

Branded & Opaque Types

Create branded types for type-safe IDs and domain values.

1h
0 problems
Topic Progress 0%

The Problem with Plain Strings

The Problem with Plain Strings

TypeScript is structural — every string fits every string parameter:

function transfer(from: AccountId, to: AccountId, cents: number): void

const src: string = "acc_123";
const dst: string = "usr_456";
transfer(src as AccountId, dst as ... );   // even worse: mixing IDs compiles

Swapping a user id for an order id compiles clean and fails at runtime (or silently corrupts data). A brand makes the type nominally distinct:

declare const __brand: unique symbol;
type Brand<B extends string> = { readonly [__brand]: B };

type UserId = string & Brand<"UserId">;
type OrderId = string & Brand<"OrderId">;

UserId is still assignable to string (it is a subtype), but a plain string — or an OrderId — is not assignable to UserId. The intersection adds a property that never exists at runtime: zero cost, full checking.

Single Constructors and Safe Usage

Single Constructors and Safe Usage

Brands are unforgeable only if creation is gated:

const UserId = {
  from(raw: string): UserId {
    if (!/^usr_[a-z0-9]{6,}$/.test(raw)) {
      throw new DomainError("bad user id", { raw });
    }
    return raw as UserId;
  },
  value(id: UserId): string { return id; }
} as const;

function transfer(from: UserId, to: UserId, cents: number): void { ... }

const id = UserId.from("usr_abc123");   // validated once, trusted thereafter
transfer(id, id, 100);
transfer(orderId, id, 100);            // ✗ Type error — OrderId ≠ UserId
const map = new Map<UserId, User>();   // keys can’t be confused

Validation lives in one place per type; deep code receives an already-checked value and cannot pass the wrong ID family. Prefer module helpers over exported as casts — an exported cast is an open door.

Zod Branding, Pitfalls, and Interviews

Zod Branding, Pitfalls, and Interviews

Brand at the trust boundary with schema transforms:

const UserIdSchema = z.string().regex(/^usr_/).transform((s) => s as UserId);
// stronger: z.uuid().transform((v, ctx) => valid ? v as UserId : ctx.addIssue(...))

Pitfalls:

  • as unknown as UserId anywhere defeats the entire scheme — audit for it in review
  • Runtime truth: a branded string is a plain string on the wire — re-validate after deserialization to re-establish meaning
  • Serializing: emit UserId.value(id) so intent is visible at API edges
  • Branded objects: brand object types with a unique symbol field instead of intersecting primitives

Interview angle: define structural vs nominal typing, show why AccountId/OrderId swap is a bug TypeScript could not see, and present the brand as a zero-cost, compile-time-only fix.

Quiz

1. What does string & Brand<"UserId"> do at runtime?

Question 1 options

2. Why must UserId.from be the only way to create a UserId?

Question 2 options

3. Can UserId be passed where string is expected?

Question 3 options

Flashcards

Question

Structural vs nominal typing

Answer

TS matches by shape; nominal systems require an explicit tag — brands add that tag at type level.

Question

Brand<T> pattern

Answer

type Id = string & { readonly [__brand]: "Id" } using a unique symbol — invisible at runtime.

Question

Single constructor rule

Answer

All creation flows through from()/parse() that validates once; no exported raw casts.

Question

Zod branding

Answer

.transform((s) => s as UserId) after regex/uuid validation — brand at the trust boundary.

Revision Notes

Key Takeaways

  • 1. Structural typing conflates ID families; branded intersections restore nominal meaning at zero cost
  • 2. Gate construction behind validators and keep the assertion in one function
  • 3. Brand untrusted input with schema transforms, re-establishing meaning after deserialization

Interview Tips

  • • Present the AccountId/OrderId swap as the motivating bug before showing syntax
  • • State clearly that brands are compile-time only and name where runtime validation still happens

Cheat Sheet

type UserId = string & { [__brand]: "UserId" } (unique symbol); from() validates once; assignable to string, not reverse; z.transform brands at boundary.

Continue Learning