EduFlow

Section 4 · Patterns for Real Codebases

Branded Types and Exhaustive Checks

19 min3 min readCode playground

Lesson at a glance

Give same-shaped values distinct types with brands and smart constructors, stop mixing currencies at compile time, and make every switch over a union provably exhaustive with never.

  1. 1Brands
  2. 2Smart constructors
  3. 3Money that refuses to mix
  4. 4Exhaustiveness with never

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

The refund bug from the first lesson came down to two numbers that looked the same: dollars and cents. Cartwheel has the same problem everywhere. An order id and a customer id are both number. An amount in cents and a quantity are both number. Structural typing, which you met in Structural Typing and Assignability, considers same-shaped things identical, so it happily lets you write cancelOrder(customer.id).

The fix is to make the shapes differ, without changing anything at runtime.

Brands

A branded type intersects a primitive with a property that only exists in the type system:

TypeScript
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type OrderId = Brand<number, 'OrderId'>;
type CustomerId = Brand<number, 'CustomerId'>;
type Cents = Brand<number, 'Cents'>;

declare const brand: unique symbol declares a symbol that never exists at runtime; using it as the property key means no real object can accidentally have it, and nothing outside this file can forge it by name. An OrderId is still a number for every purpose (you can compare it, print it, send it as JSON), but TypeScript no longer accepts a plain number or a CustomerId in its place:

TypeScript
function cancelOrder(id: OrderId) {}

cancelOrder(customer.id);
// Error: Argument of type 'CustomerId' is not assignable to parameter of type 'OrderId'.
cancelOrder(1042);
// Error: Argument of type 'number' is not assignable to parameter of type 'OrderId'.

Smart constructors

If a plain number is not an OrderId, where do OrderIds come from? From exactly one function per brand, a smart constructor that validates and then casts:

TypeScript
function toOrderId(n: number): OrderId {
  if (!Number.isInteger(n) || n < 1000) throw new Error(`Invalid order id: ${n}`);
  return n as OrderId;
}

function toCents(n: number): Cents {
  if (!Number.isSafeInteger(n)) throw new Error(`Cents must be a whole number, got ${n}`);
  return n as Cents;
}

This is the one legitimate home for as in branded code. Because the cast only happens after validation, every OrderId anywhere in the system is known to be a valid id. A brand is a receipt: it proves the value went through the check.

A plain number from a URL or JSON passes through the toOrderId smart constructor, which validates it and either throws or returns an OrderId. Functions such as cancelOrder accept only OrderId, so the plain number cannot reach them directly; that path is blocked. number from URL, JSON toOrderId(n) validate, then cast cancelOrder(id) takes OrderId direct path: compile error
Smart constructors are the only gate into a branded type

Money that refuses to mix

Branding cents is half the job. The other half is currency. Make the currency a type parameter, so Money<'USD'> and Money<'EUR'> are different types:

TypeScript
type Currency = 'EGP' | 'AED' | 'SAR' | 'JOD' | 'GBP' | 'EUR' | 'USD' | 'CAD';
type Money<C extends Currency = Currency> = { cents: Cents; currency: C };

function add<C extends Currency>(a: Money<C>, b: Money<NoInfer<C>>): Money<C> {
  return { cents: toCents(a.cents + b.cents), currency: a.currency };
}

add(usdPrice, usdShipping); // Money<'USD'>
add(usdPrice, eurShipping);
// Error: Argument of type 'Money<"EUR">' is not assignable to parameter of type 'Money<"USD">'.

Two details matter. Without NoInfer, both arguments would be inference sites and C would quietly become 'USD' | 'EUR', the exact trap from Controlling Inference. And a.cents + b.cents is a plain number: arithmetic strips brands, because TypeScript has no idea whether the result still means “cents”. Re-brand deliberately, through the constructor, so the rule “cents are whole numbers” is checked again.

Where brands pay off

Do not brand everything. Each brand adds a constructor call wherever values enter the system, and a test fixture has to go through it too. Brand where two values share a primitive type, are easy to confuse, and are expensive to confuse: ids of different entities, amounts in different units, and strings that must pass validation before use, such as an Email or a Sku. A quantity that is only ever multiplied by a price does not need one. On a large codebase, a dozen well-chosen brands catch most of the “right type, wrong meaning” bugs; a hundred of them mostly generate casts.

Exhaustiveness with never

Brands make values distinct. The other everyday pattern makes handling complete. You saw in Discriminated Unions for Domain States that a missing return can reveal a missed case. A never check makes that explicit and works even when the function returns nothing:

TypeScript
function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}

function notifyCustomer(order: Order): void {
  switch (order.status) {
    case 'processing': return sendEmail('We are packing your order');
    case 'shipped': return sendEmail(`Track it: ${order.trackingNumber}`);
    case 'delivered': return sendEmail('Enjoy!');
    case 'cancelled': return sendEmail(`Cancelled: ${order.cancelReason}`);
    case 'returned': return sendEmail('Refund on its way');
    default: return assertNever(order);
  }
}

In the default branch, every handled member has been narrowed away, so order is never, and passing it to a never parameter compiles. Add an 'on-hold' status and order becomes the on-hold variant in that branch, which is not assignable to never: compile error, pointing at the exact switch to update. At runtime, if bad data ever sneaks past the types, the function throws with a clear message instead of doing nothing.

If you prefer not to define a helper, default: throw new Error(`Unhandled: ${order satisfies never}`) does the same check inline. And for lookups, Record<OrderStatus, string> with satisfies gives you exhaustiveness for free: a missing key is a compile error.

In the exercise you will brand Cartwheel’s ids and cents, make add reject mixed currencies, and add an exhaustive check. Next lesson tackles the other thing every checkout must handle: failures you expect.

Key takeaways

  • A brand intersects a primitive with a unique, type-only property, so an OrderId and a CustomerId stop being interchangeable even though both are numbers.
  • Create branded values only in smart constructors that validate first; that is the one place an as cast belongs.
  • Arithmetic on branded numbers returns plain number, so money helpers must re-brand their results deliberately.
  • Money<NoInfer<C>> on the second argument stops add(usd, eur) from inferring a mixed-currency union.
  • A default branch that passes the value to a never parameter, or checks it with satisfies never, turns a missed union member into a compile error.

Further reading

Check your understanding

4 questions

Question 1 of 4

type OrderId = number & { readonly [brand]: 'OrderId' }. What does the brand property look like at runtime?

Question 2 of 4

Where should as OrderId appear in a well-designed codebase?

Question 3 of 4

add<C extends Currency>(a: Money<C>, b: Money<C>) is called as add(usd, eur). What happens?

Question 4 of 4

A new 'on-hold' status is added. Which function fails to compile until it handles it?

TypeScript
function label(s: OrderStatus): string {
  switch (s) {
    case 'processing': return 'Packing';
    // …one case for every other existing status…
    default: return assertNever(s);
  }
}

Practice

Code playground

Brand the ids, lock the currency

Harden Cartwheel’s checkout types:

  1. Brand OrderId, CustomerId and Cents so they are distinct from number and from each other, and make the three smart constructors return branded values.
  2. Make add reject Money values in different currencies.
  3. Make describePayment handle cash-on-delivery (return 'Cash on delivery') and add an exhaustiveness check that fails to compile if a payment kind is ever unhandled, and throws at runtime for an unknown kind.
// Brand these three so they stop being interchangeable numbers.
type OrderId = number;
type CustomerId = number;
type Cents = number;

function toOrderId(n: number): OrderId {
  if (!Number.isInteger(n) || n < 1000) throw new Error(`Invalid order id: ${n}`);
  return n;
}

function toCustomerId(n: number): CustomerId {
  if (!Number.isInteger(n) || n < 1) throw new Error(`Invalid customer id: ${n}`);
  return n;
}

function toCents(n: number): Cents {
  if (!Number.isSafeInteger(n)) throw new Error(`Cents must be a whole number, got ${n}`);
  return n;
}

type Currency = 'USD' | 'EUR' | 'EGP';
type Money<C extends Currency = Currency> = { cents: Cents; currency: C };

function money<C extends Currency>(cents: number, currency: C): Money<C> {
  return { cents: toCents(cents), currency };
}

function add<C extends Currency>(a: Money<C>, b: Money<C>): Money<C> {
  return { cents: toCents(a.cents + b.cents), currency: a.currency };
}

type PaymentMethod =
  | { kind: 'card'; last4: string }
  | { kind: 'wallet'; provider: 'apple' | 'google' }
  | { kind: 'cash-on-delivery' };

function describePayment(p: PaymentMethod): string {
  switch (p.kind) {
    case 'card': return `Card ending ${p.last4}`;
    case 'wallet': return `${p.provider === 'apple' ? 'Apple' : 'Google'} wallet`;
  }
}

// ---- Type tests (do not edit) ----
type T1 = Expect<NotEqual<OrderId, number>>;
type T2 = Expect<NotEqual<OrderId, CustomerId>>;
type T3 = Expect<Equal<OrderId extends number ? true : false, true>>;

function typeTests(customer: CustomerId, usd: Money<'USD'>, eur: Money<'EUR'>) {
  // @ts-expect-error: a customer id is not an order id
  const fromCustomer: OrderId = customer;
  // @ts-expect-error: plain numbers must go through toOrderId
  const fromNumber: OrderId = 1042;
  // @ts-expect-error: dollars and euros cannot be added
  add(usd, eur);
  const total = add(usd, usd);
  type T4 = Expect<Equal<typeof total, Money<'USD'>>>;
}

console.log(add(money(18900, 'USD'), money(499, 'USD')), describePayment({ kind: 'cash-on-delivery' }));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • toOrderId rejects 999 and accepts 1042
  • add sums two amounts in the same currency
  • describePayment handles cash on delivery
  • describePayment throws for an unknown payment kind