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 UserIdanywhere 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 symbolfield 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?
2. Why must UserId.from be the only way to create a UserId?
3. Can UserId be passed where string is expected?
Flashcards
Question
Structural vs nominal typing
Click to reveal answer
Answer
TS matches by shape; nominal systems require an explicit tag — brands add that tag at type level.
Question
Brand<T> pattern
Click to reveal answer
Answer
type Id = string & { readonly [__brand]: "Id" } using a unique symbol — invisible at runtime.
Question
Single constructor rule
Click to reveal answer
Answer
All creation flows through from()/parse() that validates once; no exported raw casts.
Question
Zod branding
Click to reveal answer
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
Before You Start
- No prerequisites listed.