Modelling Domains with Types
Making illegal states unrepresentable: replacing boolean flags with unions, encoding invariants in shapes, and letting the compiler enforce rules you were writing comments about.
Making illegal states unrepresentable: replacing boolean flags with unions, encoding invariants in shapes, and letting the compiler enforce rules you were writing comments about.
A support ticket says an order was shipped to an address that
was deleted three weeks earlier. Reading the code, you find the
combination is possible: Order has an optional address and a
shippedAt, and nothing prevents one without the other. Six
functions check for it. The seventh does not.
Every optional field is a combination somebody has to handle, and every one somebody misses is a bug. By the end of this lesson you will model data so the combinations that must not happen cannot be written down — which turns a rule people follow into one the compiler enforces.
type Order = {
id: string;
address?: Address;
shippedAt?: Date;
cancelledAt?: Date;
refundedAt?: Date;
};Four optional fields means sixteen combinations, and only a handful of them describe an order that could exist.
Every function receiving an Order must decide what to do about
all sixteen, and none of them do. They handle the ones the
author had in mind, and the rest produce whatever the code
happens to do.
The fix is to describe the states rather than the fields.
Bad — one shape with optional everything.
type Order = {
id: string;
address?: Address;
shippedAt?: Date;
cancelledAt?: Date;
};
function trackingLabel(order: Order): string {
return `Shipped ${order.shippedAt.toISOString()} to ${order.address.line1}`;
// ~~~~~~~~~ possibly undefined
}Good — a union of the states that exist.
type Order = { id: string } & (
| { status: "draft" }
| { status: "placed"; address: Address }
| { status: "shipped"; address: Address; shippedAt: Date }
| { status: "cancelled"; cancelledAt: Date }
);
function trackingLabel(order: Order): string {
if (order.status !== "shipped") return "Not yet shipped";
return `Shipped ${order.shippedAt.toISOString()} to ${order.address.line1}`;
}Everything is possibly undefined.
shippedAt is Date | undefined everywhere, including the
branch where it is guaranteed — so you add a check that
cannot fail, or write ! and remove the protection.
And { id: "x" } with nothing else is a valid Order.
Each branch sees exactly what exists.
Narrowing on status gives you the fields that belong to
that state and no others.
A shipped order without a date will not compile, so the eleven impossible combinations cannot be built at all.
The principle: make illegal states unrepresentable. An optional field says "this may be absent, everywhere, always". A union says "in this state these fields exist, and in that state these do".
Every identifier in your program is a string, so the checker
cannot tell one from another. Swapping two arguments compiles
perfectly.
Branded types make them distinct:
At runtime these are ordinary strings — the brand is erased with everything else. The only cost is that you must create them deliberately, which is the point:
The single as lives in one place, behind a check. Everywhere
else, having an AccountId means something verified it.
This is worth the ceremony for identifiers that get passed
around, and for values with units — Seconds and
Milliseconds, Cents and Pounds. Those two pairs cause real
incidents and are invisible to the checker without branding.
The same idea applies to validation. Instead of checking repeatedly, change the type once:
sendWelcome cannot be called with an unvalidated string. Not
"should not" — cannot. The validation happened at the boundary,
and the type carries the proof from there on.
That is the general shape: push the check to the edge and encode the result in the type, so no function downstream repeats it and none can forget.
Some constraints involve more than one field. The type system can express more of them than people expect.
Either one or the other, never both:
phone?: never means the property may be absent but may never
have a value, which rejects the combination.
A non-empty list, which removes a whole class of "what if it is empty" checks:
A value tied to another field:
Each method carries exactly what it needs. There is no
cardLast4 on a cash payment to be undefined, and no branch
handling that possibility.
This can be taken too far.
A type so clever that adding a field means understanding four conditional types has moved the cost rather than removed it. The question to ask: would a new colleague be able to add a state? If the answer needs a paragraph, the model is more expensive than the bugs it prevents.
Three honest limits.
Types cannot check runtime data. A branded Email from
JSON.parse is branded because you said so. The validation
lesson deals with this properly.
Some constraints need code. "The discount cannot exceed the order total" is arithmetic, not a shape.
Some cost more than they save. Modelling a rare, cheap mistake with an elaborate type is a trade that does not pay.
The reliable rule: model the states that exist, brand the values that get confused, and validate at the edge. Beyond those three, ask what a specific bug would have cost before reaching for a clever type.
You can now describe what is actually possible rather than what is merely present, distinguish values the checker would otherwise treat as identical, and push validation to the edge so the type carries the proof. The first of those is the one with the largest return: a union of states removes bugs that optional fields create.
Next is Error Handling Strategies, which applies the same
question to failure. Should a function throw or return? What
does a Result type cost, and when does it pay? And how do you
keep error information across a boundary rather than flattening
it to a string.
Before you move on, find a type in your code with three or more optional fields and count the combinations it permits. Then count the ones your code actually handles. The gap is the number of states nobody has written behaviour for.
STATES, NOT FLAGS
four optional fields = sixteen combinations, ~five of them real
a union of states has no impossible combinations to handle
type Order = { id: string } & (
| { status: "draft" }
| { status: "shipped"; address: Address; shippedAt: Date }
);
narrowing gives each branch exactly the fields that exist
the illegal combinations cannot be CONSTRUCTED
BRANDS - for values that must not be swapped
type Brand<T, B> = T & { readonly [brand]: B };
type AccountId = Brand<string, "AccountId">;
erased at runtime; created only through a checked constructor
worth it for identifiers, and for units:
Seconds vs Milliseconds, Cents vs Pounds
PARSE AT THE EDGE
parseEmail(value: string): Email | null
sendWelcome(to: Email) cannot be called with a raw string
check once, encode the result in the TYPE
RELATIONSHIPS
{ a: string; b?: never } | { b: string; a?: never } one or other
type NonEmpty<T> = [T, ...T[]]; never empty
each union member carries exactly the fields it needs
DERIVE, DO NOT RESTATE
type Status = Order["status"];
type Handlers = Record<Status, () => void>;
adding a state makes the Record incomplete -> an error
WHERE IT STOPS
could a new colleague add a state? if not, it costs too much
types cannot check runtime data - validate at the boundary
some rules are arithmetic, not shapes
model states, brand confusable values, validate at the edgefunction transfer(from: string, to: string, amount: number): void { ... }
transfer(customerId, accountId, 100); // which order was it?declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type CustomerId = Brand<string, "CustomerId">;
type AccountId = Brand<string, "AccountId">;function transfer(from: AccountId, to: AccountId, amount: number): void {}
transfer(customerId, accountId, 100);
// ~~~~~~~~~~ not assignable to 'AccountId'function toAccountId(value: string): AccountId {
if (!/^acct_[a-z0-9]+$/.test(value)) {
throw new Error(`not an account id: ${value}`);
}
return value as AccountId;
}type RawEmail = string;
type Email = Brand<string, "Email">;
function parseEmail(value: string): Email | null {
return value.includes("@") ? (value as Email) : null;
}
function sendWelcome(to: Email): void { ... }type Contact =
| { email: string; phone?: never }
| { phone: string; email?: never };
const bad: Contact = { email: "a@b.c", phone: "123" }; // errortype NonEmpty<T> = [T, ...T[]];
function first<T>(items: NonEmpty<T>): T {
return items[0]; // no undefined - the tuple guarantees it
}type Payment =
| { method: "card"; cardLast4: string }
| { method: "transfer"; reference: string }
| { method: "cash" };type Status = Order["status"]; // the union of statuses
type Handlers = Record<Status, () => void>; // one per status