Table of Contents
The Problem
Consider this function signature:
function transferMoney(from: string, to: string, amount: number): voidThree parameters, all primitives. The type system will happily let you call transferMoney(amount, to, from) with the arguments in the wrong order. TypeScript sees string, string, number and shrugs.
This is the primitive obsession problem. When every ID is a string and every quantity is a number, the type system can’t protect you from mixing them up.
Branded Types
The fix is to make AccountId and TransactionAmount distinct types that happen to be backed by primitives:
type Brand<T, B> = T & { readonly __brand: B };
type AccountId = Brand<string, "AccountId">;type USD = Brand<number, "USD">;
function transferMoney(from: AccountId, to: AccountId, amount: USD): void { // implementation}Now transferMoney("abc", "def", 100) is a type error. You need to explicitly construct the branded values:
function toAccountId(id: string): AccountId { if (!id.match(/^acct_[a-z0-9]{16}$/)) { throw new Error(`Invalid account ID: ${id}`); } return id as AccountId;}
function toUSD(cents: number): USD { if (!Number.isInteger(cents) || cents < 0) { throw new Error(`Invalid amount: ${cents}`); } return cents as USD;}The as cast is the only runtime “cost” — and it compiles away entirely. The __brand property never exists at runtime. It’s a phantom type that only lives in the type checker’s head.
Where This Shines
Database IDs
Every table gets its own ID type. UserId and PostId are never interchangeable, even though they’re both UUIDs underneath.
type UserId = Brand<string, "UserId">;type PostId = Brand<string, "PostId">;
// This is now a type error — exactly what we wantfunction getPost(id: PostId): Post { /* ... */ }getPost(currentUser.id); // Error: UserId is not assignable to PostIdUnits of Measurement
Meters and feet, Celsius and Fahrenheit, pixels and rems. Branded types prevent the kind of unit confusion that crashed a Mars orbiter.
type Meters = Brand<number, "Meters">;type Feet = Brand<number, "Feet">;
function metersToFeet(m: Meters): Feet { return (m * 3.28084) as Feet;}Validated Strings
Email addresses, URLs, slugs — strings that have been validated and are known to be well-formed:
type Email = Brand<string, "Email">;type Slug = Brand<string, "Slug">;
function sendEmail(to: Email, subject: string): void { /* ... */ }
// Forces validation at the boundaryfunction parseEmail(input: string): Email | null { return isValidEmail(input) ? (input as Email) : null;}Tradeoffs
Benefits:
- Zero runtime overhead (brands are erased at compile time)
- Catches a whole class of bugs at the type level
- Self-documenting — function signatures tell you what they actually expect
- Validation happens once at the boundary, not scattered through the codebase
Costs:
- Slightly more verbose at construction sites
- The
ascasts can feel like you’re lying to the compiler (you’re not — you’re informing it) - Libraries won’t return your branded types — you need adapter layers at the edges
- Coworkers will ask “what is this
__brandthing” exactly once
The pattern works best when you have clear domain boundaries. If every string in your app needs to be branded, you’ve probably gone too far. Start with the types that get mixed up in practice — IDs, money, and validated formats.