Section 1 · Types as a Design Tool
Discriminated Unions for Domain States
19 min3 min readCode playground
Lesson at a glance
Replace bags of optional fields with discriminated unions, so each order state carries exactly the data it has and impossible states stop compiling.
- 1One type per state
- 2Narrowing on the discriminant
- 3Transitions as functions between states
- 4Sharing common fields
- 5When not to use one
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Here is how Cartwheel’s Order type looked before anyone thought about it:
type Order = {
id: number;
status: 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
trackingNumber?: string; // set once shipped
deliveredAt?: string; // set once delivered
cancelReason?: string; // set if cancelled
refundCents?: number; // set if returned
};It looks reasonable, and it is a trap. Four optional fields can each be present or absent, so this type allows 5 × 2⁴ = 80 shapes. Cartwheel’s business has five. A shipped order with no tracking number compiles. A cancelled order with a delivery date compiles. And every time you read order.trackingNumber, you get string | undefined and reach for !, which is you telling the compiler “trust me” eighty times a week.
One type per state
A discriminated union gives each state its own object type and joins them with |. Each member has a shared property with a different literal value, the discriminant:
type ProcessingOrder = { id: number; status: 'processing' };
type ShippedOrder = { id: number; status: 'shipped'; trackingNumber: string };
type DeliveredOrder = { id: number; status: 'delivered'; trackingNumber: string; deliveredAt: string };
type CancelledOrder = { id: number; status: 'cancelled'; cancelReason: string };
type ReturnedOrder = {
id: number;
status: 'returned';
trackingNumber: string;
deliveredAt: string;
refundCents: number;
};
type Order = ProcessingOrder | ShippedOrder | DeliveredOrder | CancelledOrder | ReturnedOrder;Now the type allows exactly five shapes. Notice that nothing is optional any more: a shipped order has a tracking number. The impossible states are not checked at runtime; they are simply not expressible:
const o: Order = { id: 1042, status: 'shipped' };
// Error: Type '{ id: number; status: "shipped"; }' is not assignable to type 'Order'.
// Property 'trackingNumber' is missing in type '{ id: number; status: "shipped"; }'
// but required in type 'ShippedOrder'.Narrowing on the discriminant
Checking the discriminant narrows the whole object. Inside each case, TypeScript knows exactly which member you have:
function describe(order: Order): string {
switch (order.status) {
case 'processing':
return `#${order.id} is being packed`;
case 'shipped':
return `#${order.id} is on its way (${order.trackingNumber})`;
case 'delivered':
return `#${order.id} arrived on ${order.deliveredAt}`;
case 'cancelled':
return `#${order.id} was cancelled: ${order.cancelReason}`;
case 'returned':
return `#${order.id} was refunded ${(order.refundCents / 100).toFixed(2)}`;
}
}No !, no ?., no ?? ''. And notice the explicit : string return type. If someone adds an 'on-hold' status to the union next month, the end of this function becomes reachable, and TypeScript reports Function lacks ending return statement and return type does not include ‘undefined’. The compiler finds every place that needs updating. You will make this check explicit and louder with never in Branded Types and Exhaustive Checks.
Transitions as functions between states
The union pays off most in functions that change state. Type their inputs and outputs as specific variants:
function ship(order: ProcessingOrder, trackingNumber: string): ShippedOrder {
return { id: order.id, status: 'shipped', trackingNumber };
}
function cancel(order: ProcessingOrder, reason: string): CancelledOrder {
return { id: order.id, status: 'cancelled', cancelReason: reason };
}Now “you cannot ship an order twice” and “you cannot cancel a shipped order” are compile-time facts. A caller with an Order in hand must narrow first:
function shipIfReady(order: Order, tracking: string): Order {
if (order.status !== 'processing') return order;
return ship(order, tracking); // order is ProcessingOrder here
}The return types matter as much as the parameters. Because ship returns a ShippedOrder rather than an Order, the caller can hand the result straight to a function that needs a tracking number, such as printLabel(shipped), without checking status again. Each transition carries the knowledge forward. Return the wide Order and you throw that knowledge away, forcing every caller to narrow again.
Sharing common fields
Real orders have more than an id: a customer, a placed-at timestamp, line items. Repeating those in five variants invites drift, so pull them into a base type and intersect it with each state’s own fields:
type OrderBase = { id: number; customerId: number; placedAt: string };
type Shipped = OrderBase & { status: 'shipped'; trackingNumber: string };
type Cancelled = OrderBase & { status: 'cancelled'; cancelReason: string };Each variant is still a separate member with its own literal status, so narrowing works exactly as before. The base type also gives you a natural parameter type for functions that do not care about the state, such as function customerLink(order: OrderBase), which accepts every variant.
You can also destructure the discriminant. Since TypeScript 4.4, const { status } = order; if (status === 'shipped') { … } narrows order too, as long as order is a const or an unassigned parameter. That keeps long switch statements readable without giving up the narrowing.
When not to use one
A discriminated union is right when states carry different data. When the variants differ only in a label (say, a channel of 'web' | 'mobile_app' | 'marketplace' with the same fields), a plain literal union on one property is enough. Reach for the full pattern when you catch yourself writing optional fields with comments like “only set when…”.
In the exercise, you will turn Cartwheel’s bag-of-optionals Order into a union and make ship accept only orders that can be shipped. Next lesson: literal types, as const and satisfies, the tools that keep those literals from widening back into string.
Key takeaways
- A type made of optional fields allows every combination of them, most of which are states your business can never be in.
- A discriminated union gives each state its own object type, tied together by a shared literal property such as
status. - Checking the discriminant with
===orswitchnarrows the whole object, so state-specific fields become available without!. - Functions that move between states should accept and return specific variants, like
ship(order: ProcessingOrder): ShippedOrder. - An explicit return type on a
switchover the discriminant turns a forgotten case into a compile error.