The Problem with Primitive Obsession
type UserId = string;
type OrderId = string;
function getUserOrders(userId: UserId): Order[] {
return orders.filter(o => o.userId === userId);
}
function getOrder(orderId: OrderId): Order | null {
return orders.find(o => o.id === orderId);
}
// TypeScript allows this, but it's wrong:
const uid: UserId = "user-123";
const order = getOrder(uid); // userId passed as orderId!
UserId and OrderId are both just string. TypeScript can't distinguish them.
Branded Types to the Rescue
// A UserId is a string, but "branded" as distinct
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
// Helper to create branded types
function userId(id: string): UserId {
return id as UserId;
}
function orderId(id: string): OrderId {
return id as OrderId;
}
// Now TypeScript catches the mistake:
const uid = userId("user-123");
const order = getOrder(uid); // ❌ Type error: UserId is not assignable to OrderId
At Runtime
Branded types are zero-cost abstractions:
const uid = userId("user-123");
console.log(typeof uid); // "string"
console.log(uid === "user-123"); // true
The brand is only for TypeScript's type checker. At runtime, it's just a string.
Real-World Example
// Branded types
type CustomerId = string & { readonly __brand: 'CustomerId' };
type AccountId = string & { readonly __brand: 'AccountId' };
type TransactionId = string & { readonly __brand: 'TransactionId' };
// Factory functions (usually autogenerated from DB)
const customerId = (id: string): CustomerId => id as CustomerId;
const accountId = (id: string): AccountId => id as AccountId;
const transactionId = (id: string): TransactionId => id as TransactionId;
// API client
interface CreateTransactionRequest {
customerId: CustomerId;
accountId: AccountId;
amount: number;
}
function createTransaction(req: CreateTransactionRequest): Promise<TransactionId> {
// TypeScript ensures the right IDs are passed
return api.post('/transactions', req).then(res => transactionId(res.id));
}
// Usage
const cid = customerId("cust-456");
const aid = accountId("acct-789");
// ✅ TypeScript allows this
const tid = createTransaction({ customerId: cid, accountId: aid, amount: 100 });
// ❌ TypeScript catches this mistake
const tid2 = createTransaction({ customerId: aid, accountId: cid, amount: 100 });
// ^ Type error
Library Support
Some libraries provide branded types:
- io-ts: Runtime type checking with branded type derivation
- newtype-ts: Explicit NewType pattern for Haskell-like semantics
- Effect: Functional library with built-in branded type utilities
For most projects, DIY brands are sufficient and lightweight.