Branded Types: Safer IDs, Money, and Domain Values

Use branded (nominal-ish) types to prevent mixing IDs and domain values, and learn safer alternatives to ‘just cast it’.

F

Frontend Interview Team

March 01, 2026

2 min read
Branded Types: Safer IDs, Money, and Domain Values

What you’ll learn

  • Why TypeScript is structurally typed (and what that means for domain safety)
  • How branded types prevent mixing UserId with OrderId
  • Safer ways to create brands than as UserId

30‑second interview answer

TypeScript is structurally typed, so two string values are interchangeable even if they represent different concepts. Branded types add a phantom property to create nominal-ish distinctions like UserId vs OrderId. This prevents entire classes of bugs in large codebases, especially around IDs, currency, and validated values.


The problem: structural typing mixes meanings

type UserId = string;
type OrderId = string;
 
function loadUser(id: UserId) {}
 
const orderId: OrderId = "ord_123";
loadUser(orderId); // ✅ compiles (bad!)

Brand pattern

type Brand<T, B extends string> = T & { readonly __brand: B };
 
export type UserId = Brand<string, "UserId">;
export type OrderId = Brand<string, "OrderId">;

Now this fails:

function loadUser(id: UserId) {}
const orderId = "ord_123" as OrderId;
 
loadUser(orderId); // ❌ Type error

The dangerous part: as UserId

You can still lie:

const userId = "not-a-real-id" as UserId;

So the important engineering question is: how do we create branded values safely?


Safer construction: parse/validate at boundaries

function isUserId(s: string): s is UserId {
  return /^usr_[a-z0-9]+$/.test(s);
}
 
export function parseUserId(s: string): UserId {
  if (!isUserId(s)) throw new Error("Invalid UserId");
  return s;
}
 
const id = parseUserId("usr_ab12"); // UserId

This is what you do in production: validate at I/O boundaries.


Example: Money type (avoid mixing currencies)

type Currency = "INR" | "USD";
 
type Money<C extends Currency> = Brand<
  { amount: number; currency: C },
  `Money:${C}`
>;
 
function money<C extends Currency>(amount: number, currency: C): Money<C> {
  return { amount, currency } as Money<C>;
}
 
function add<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
  return money(a.amount + b.amount, a.currency);
}
 
const a = money(100, "USD");
const b = money(200, "USD");
add(a, b);
 
// add(a, money(50, "INR")) // ❌ type error

Production rule of thumb

  • Use brands for values that have domain meaning (IDs, emails, URLs, money).
  • Don’t brand everything; brand the “high-cost mistakes”.
  • Create branded values through parsers/validators at boundaries, not random casts.

Interview questions

  1. Q: Why do branded types work in TS?

    • A: The phantom property makes the type structurally different while having zero runtime cost.
  2. Q: Where should validation happen?

    • A: At I/O boundaries (API responses, forms, DB reads), so internal code stays safe.

Quick recap

  • TS is structural; brands add nominal-ish safety.
  • Brands prevent mixing different string meanings.
  • Validate once at boundaries, then pass branded values internally.