← Writing

Branded Types in TypeScript: Catching Business Logic Bugs at Compile Time

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.