CodeStaminaOpen preview

Data modelling · TypeScript · 8 min

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 this

The 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.

A payment modelled with optional properties
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.

The same data as a discriminated union
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

BehaviourBeforeAfter
Building a paid result with no receiptCompiles, prints "Receipt undefined"Compile error at the construction site
Reading result.reason in the paid branchAllowed, always undefinedCompile error: property does not exist
Building a declined result that also has a receiptAllowedCompile error: excess property
Adding a fourth outcome laterSilently widens every optional fieldForces every consumer to decide

Variations worth trying

  1. Discriminate on something other than statuskind, type, or a boolean ok all work, as long as the values are literal types rather than string.
  2. Model an API client as { ok: true; data: T } | { ok: false; error: ApiError } so callers cannot read data without checking ok first.
  3. Nest a union inside a union: a declined variant can itself split into insufficient_funds and card_expired, each carrying different data.
  4. Watch what happens when the discriminant is typed as string instead 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

JOIN THE WAITLIST

Help shape what we build next.

Tell us where you want to start and what you want to practise. We’ll use your answers to understand demand and email you about relevant program availability.

What would you like to practise?

We store your email and answers for this waitlist. No campaign is being sent now. Contact mo@codestamina.com to ask us to remove your entry.