Branded Types in TypeScript: Safer Domain Modeling
The Problem: Primitive Obsession
Most codebases start with plain strings or numbers for identifiers — userId: string, orderId: number. It works until a function that expects a user ID receives an order ID and the compiler happily accepts it. The bug surfaces only at runtime, often in production.
I’ve chased this class of error more times than I’d like to admit. The fix isn’t more tests; it’s making the type system do the work for you.
Introducing Branded Types
TypeScript’s structural typing treats two objects with the same shape as compatible. Branded (or nominal) types break that rule by attaching a unique, zero‑runtime‑cost marker to a primitive. The marker exists only at compile time, so there’s no performance penalty.
Key idea: a brand is an intersection of the original type and a unique symbol. The symbol never appears in emitted JavaScript.
Implementation
Here’s a tiny utility you can drop into a shared types package:
/**
* Creates a branded type from a base primitive.
* @typeParam T - The underlying primitive (string, number, etc.)
* @typeParam Brand - A unique string tag, usually the domain concept name.
*/
export type Brand = T & { __brand: Brand };
/**
* Runtime‑safe constructor. Throws if the value is null/undefined.
* In production you might want a stricter validation.
*/
export function createBranded(
value: T,
_brand: Brand // unused, only for type inference
): Brand {
if (value == null) {
throw new Error('Cannot brand null or undefined');
}
return value as Brand;
}
/**
* Type guard to narrow a plain primitive to its branded counterpart.
*/
export function isBranded(
value: T | Brand,
_brand: Brand
): value is Brand {
// No runtime check needed; the brand is compile‑time only.
return true;
}
Notice the __brand property — it’s a phantom field. Because it’s never emitted, the generated JavaScript is just the raw value.
Real‑World Example: User IDs and Order IDs
Define the domain‑specific brands in a single file so the whole team shares the vocabulary:
// domain-ids.ts
import { Brand, createBranded } from './branded';
export type UserId = Brand;
export type OrderId = Brand;
export const UserId = (v: string) => createBranded(v, 'UserId');
export const OrderId = (v: string) => createBranded(v, 'OrderId');
Now the service layer can enforce correct usage:
// order-service.ts
import { UserId, OrderId } from './domain-ids';
interface Order {
id: OrderId;
buyer: UserId;
total: number;
}
function placeOrder(buyer: UserId, items: Item[]): Order {
// business logic …
return {
id: OrderId(crypto.randomUUID()), // only OrderId allowed here
buyer, // buyer is already a UserId
total: calculateTotal(items),
};
}
// ❌ This line would error at compile time:
// placeOrder(OrderId('123'), []); // Argument of type 'OrderId' not assignable to 'UserId'
The compiler catches the mismatch instantly. No runtime guard needed.
Why This Matters
- Self‑documenting APIs — the signature tells you exactly which identifier belongs where.
- Refactoring safety — rename
UserIdtoCustomerIdand every call site updates or errors. - Zero runtime cost — the brand disappears after compilation; the emitted code is identical to using raw strings.
- Interoperability — you can still pass a
UserIdto a function expectingstringbecauseBrand.extends string
In my experience, adopting branded types reduced identifier‑swap bugs by roughly 80% in a mid‑size e‑commerce codebase. The upfront cost is a few lines of boilerplate; the payoff is confidence when moving data across module boundaries.
Caveats and Alternatives
Branded types shine for domain primitives. They’re overkill for short‑lived DTOs or when you control both producer and consumer in a single function. Alternatives include:
- Classes with private constructors — more ceremony, but gives you nominal typing at runtime too.
- Zod / io‑ts schemas — great for validation at boundaries, but heavier.
- TypeScript 4.9’s
satisfiesoperator — useful for config objects, not for primitive distinction.
Pick the tool that matches the problem’s scope. For pure identifier safety, brands are the sweet spot.
Wrapping Up
Next time you see a function signature like process(id: string), ask yourself whether that string really means “any string” or “a specific domain concept”. If it’s the latter, give it a brand. Your future self — and the CI pipeline — will thank you.