Branded Types for Domain Safety
UserId, OrderId, Email — all of them strings to the compiler, all of them disasters when accidentally swapped. Branded types are five lines of code that prevent the whole class.
Every codebase has the bug where someone passed a UserId where an OrderId was expected. Both are strings. The compiler is happy. Production isn't. Branded types are how you tell the compiler that a UserId is a string but not any string.
1. The pattern
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
At runtime, every branded type is just a string. At compile time, they are distinct. You cannot assign UserId to a parameter that expects OrderId, even though both are strings under the hood.
2. How values are made
You cannot just write const u: UserId = "abc" — the compiler will refuse because the literal lacks the brand. You need a constructor that performs the validation:
function UserId(raw: string): UserId {
if (!raw.startsWith("usr_")) {
throw new Error("Invalid UserId: " + raw);
}
return raw as UserId;
}
The cast inside the constructor is the only place you bypass the type system. Every other reference to UserId in the codebase is type-safe.
3. Where it pays off
3.1 Function signatures stop lying
function refund(orderId: OrderId, userId: UserId): Promise<Refund>;
// caller — argument order mistake:
await refund(user.id, order.id);
// Argument of type 'UserId' is not assignable to parameter of type 'OrderId'.
The exact bug that would have shipped now refuses to compile.
3.2 Domain primitives gain meaning
type Cents = Brand<number, "Cents">;
type Dollars = Brand<number, "Dollars">;
function toCents(d: Dollars): Cents {
return (d * 100) as Cents;
}
The compiler knows that mixing the two is a bug. The number of subtle "we billed in dollars but stored in cents" bugs this prevents is non-trivial.
3.3 Parse, don't validate
This is the deeper pattern. Validation that returns a boolean is forgettable. Parsing that returns a typed value is enforced.
function parseEmail(raw: string): Email {
if (!/^[^@]+@[^@]+\.[^@]+$/.test(raw)) {
throw new Error("Not an email: " + raw);
}
return raw as Email;
}
// Now anywhere that wants Email knows the string passed parseEmail.
function send(to: Email, body: string): void;
The type Email represents "a string that passed the email parser at some point." You cannot fake it. The "is this a valid email" check happens once at the boundary, never again.
4. The runtime cost
Zero. Branded types are erased at compile time. The runtime sees a plain string. No wrapper objects, no proxy, no memory overhead.
5. Trade-offs
- Slightly more ceremony at the boundary. You write the parser; you use it on every input.
- Library types are not branded. You will occasionally cast at the boundary (DB result, HTTP body) — concentrate those casts in adapters so the rest of the code stays clean.
- Doesn't replace runtime validation. A
UserIdcoming from an HTTP body is stillanyat the wire — parse it first, then trust the type after.
6. The brand collision pitfall
Two libraries each export their own type UserId = string & { __brand: "UserId" }. To TypeScript, both definitions match if the brand string is the same. Use a unique brand per project, ideally with a namespace prefix:
type UserId = Brand<string, "myapp.UserId">;
The mental model
Primitive types are shapes; branded types are meanings. A string is a shape. An Email is a meaning. Every codebase has more meanings than shapes — branded types close the gap. The first time the compiler refuses to let you pass a UserId where an OrderId was expected, you'll wonder why you ever shipped without them.
Rules of thumb
- Brand every domain ID. The cost is five lines per type; the upside is preventing the most common bug shape there is.
- Brand money and time (
Cents,Seconds,Milliseconds). - Brands belong on the domain side, not the wire side. Validate at the boundary, propagate brands inward.