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.
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:
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:
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:
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.
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:
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:
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
OrderIdand aCustomerIdstop being interchangeable even though both are numbers. - Create branded values only in smart constructors that validate first; that is the one place an
ascast belongs. - Arithmetic on branded numbers returns plain
number, so money helpers must re-brand their results deliberately. Money<NoInfer<C>>on the second argument stopsadd(usd, eur)from inferring a mixed-currency union.- A
defaultbranch that passes the value to aneverparameter, or checks it withsatisfies never, turns a missed union member into a compile error.