Discriminated unions: stop modelling impossible states
Optional properties describe which fields might exist. A discriminated union describes which combinations are allowed — a much stronger claim.
Train thisThe problem
A payment can succeed, be declined, or still be pending. Each outcome carries different data: a successful payment has a receipt, a declined one has a reason, and a pending one has neither.
The obvious first model is one interface with a status field and optional extras. It reads well and it compiles. It also allows a payment that is paid with no receipt, declined with no reason, and pending with both.
interface PaymentResult {
status: 'paid' | 'declined' | 'pending';
receiptId?: string;
reason?: string;
}
function describe(result: PaymentResult): string {
if (result.status === 'paid') return `Receipt ${result.receiptId}`;
if (result.status === 'declined') return `Declined: ${result.reason}`;
return 'Payment pending';
}
console.log(describe({ status: 'paid' }));PREDICT FIRST
What happens when this file is compiled and run?
Commit to an answer before you read on. Being wrong here is the part that sticks.
Why it behaves that way
The type says three fields may each be present or absent, which describes twelve possible shapes. Only three of them are real. The other nine are states the rest of the codebase will eventually have to defend against, one ?? "unknown" at a time.
A discriminated union inverts the relationship. Instead of one shape with optional extras, it is a set of complete shapes, each keyed by a literal tag. The tag is the discriminant: once TypeScript narrows on it, the fields belonging to that variant become required and the fields belonging to the others disappear.
The payoff is not shorter code. It is that { status: "paid" } becomes a compile error at the place where the value is constructed, rather than an "undefined" string in front of a customer.
type PaymentResult =
| { status: 'paid'; receiptId: string }
| { status: 'declined'; reason: string }
| { status: 'pending' };
function describe(result: PaymentResult): string {
if (result.status === 'paid') return `Receipt ${result.receiptId}`;
if (result.status === 'declined') return `Declined: ${result.reason}`;
return 'Payment pending';
}
// Now a compile error: Property 'receiptId' is missing.
console.log(describe({ status: 'paid' }));What the change buys you
| Behaviour | Before | After |
|---|---|---|
| Building a paid result with no receipt | Compiles, prints "Receipt undefined" | Compile error at the construction site |
| Reading result.reason in the paid branch | Allowed, always undefined | Compile error: property does not exist |
| Building a declined result that also has a receipt | Allowed | Compile error: excess property |
| Adding a fourth outcome later | Silently widens every optional field | Forces every consumer to decide |
Variations worth trying
- Discriminate on something other than
status—kind,type, or a booleanokall work, as long as the values are literal types rather thanstring. - Model an API client as
{ ok: true; data: T } | { ok: false; error: ApiError }so callers cannot readdatawithout checkingokfirst. - Nest a union inside a union: a
declinedvariant can itself split intoinsufficient_fundsandcard_expired, each carrying different data. - Watch what happens when the discriminant is typed as
stringinstead of a literal. Narrowing stops working entirely, and the union quietly degrades to the optional-property model you started with.
TRAIN IT
Make every payment state explicit
A guided 8–12 minute workout: predict the output, repair the model, and verify the compiler now rejects the impossible states.
Open the workout