Discriminated unions and exhaustive checks: making illegal states impossible
Model loading, success and error states with a tag field, let TypeScript narrow each case, and use never so the compiler tells you about every case you forgot.
Comes up in: Coding, React, Low-level design
Learn#
Vikram’s order page shows a spinner and an error message at the same time. His state had three separate fields: isLoading, error and data. Nothing stopped all three from being set at once.
A discriminated union describes each state as its own shape, with a shared tag field such as status that says which shape it is. The value can be in only one state at a time.
TypeScript narrows on the tag, and never turns a forgotten case into a compile error. A teammate added "refunded" but not its label:
type Payment =
| { status: "pending" }
| { status: "paid"; receiptId: string }
| { status: "failed"; reason: string }
| { status: "refunded"; amount: number }; // added later by a teammate
function label(p: Payment): string {
switch (p.status) {
case "pending": return "Waiting for the bank…";
case "paid": return `Paid · receipt ${p.receiptId}`;
case "failed": return `Failed: ${p.reason}`;
default: return assertNever(p);
}
}
function assertNever(x: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(x)}`);
}example.ts(12,33): error TS2345: Argument of type '{ status: "refunded"; amount: number; }' is not assignable to parameter of type 'never'.State
- p: pending | paid | failed | refunded
Console
empty
p has type Payment: one of four shapes. Only status is known for sure.
Read the steps as text
- p has type Payment: one of four shapes. Only status is known for sure.
- case "paid": TypeScript narrows p to the paid shape, so p.receiptId is allowed.
- After the three cases, only refunded is left for the default branch.
- assertNever accepts only never. refunded is not never, so the compiler reports an error on that line.
Predict#
Predict first
In case 'failed', you write p.receiptId. What happens?
Inside the "failed" case, p is narrowed to { status: "failed"; reason: string }, which has no receiptId. The union as a whole does have it in one member, which is why people expect it to compile.
Interview angle#
Say it like this — about 30 seconds
“A discriminated union is a union of object types that share a literal tag field, such as status or kind. Checking the tag narrows the value to one member, so only that member’s fields are available. I model request state as idle, loading, success with data, or error with an error, instead of separate booleans. In a switch, a default that passes the value to a function taking never makes the compiler report any case I forget.”
Follow-up Show how you model a request state.
{ status: "loading" } | { status: "success"; data: T } | { status: "error"; error: Error }, plus idle if the request may not have started.Follow-up Why throw inside assertNever if the compiler already checks?
Follow-up What can be a discriminant?
Practice#
Guided
Write reduce for a fetch state machine. The types are written for you; the runtime tests check every transition.
Done when
- FETCH moves to loading, except while already loading.
- RESOLVE and REJECT change the state only while loading.
- RESET always returns to idle.
- Your switch on
event.typeends with an exhaustivenevercheck.
Remember#
Try to answer each card before you open it. Then add them to your reviews.
What is a discriminated union?
A union of object types that share a literal tag field.
What does assertNever(x: never) do for a switch?
Makes the compiler report any union member the switch did not handle.
Why are separate isLoading, error and data fields risky?
They allow impossible combinations, such as loading and error together.
Inside case "paid", what type does p have?
Only the paid member of the union.
Go deeper
Four layers. Stop at the one your interview needs; each ends with a quick check.
The core ideaEvery round
if, switch, === and in checks on it all narrow the type.Check yourself: Which field types can act as a tag?
Literal types, such as string or number literals, in every member.
How it works underneathSenior rounds
Each case removes one member from the type. After all are handled, the remaining type is never, the type with no values. Passing anything else to a never parameter is an error, so the compiler tells you what is left Source: TypeScript Handbook: Exhaustiveness checking.
Check yourself: What type does a value have after every union member is handled?
never.
Edge cases and trapsWhere people slip
Narrowing follows the tag only while TypeScript can track it. A check done outside a callback may not carry into the callback when the variable can be reassigned; use const, or check inside the callback. A satisfies never check on the value (TS.02.04) is a short alternative to assertNever.
Check yourself: Why may a tag check outside a callback not narrow inside it?
If the variable can be reassigned, TypeScript cannot be sure it is unchanged when the callback runs.
In productionStaff-level answers
type. Validate API data at runtime (with a schema library) before trusting its tag Source: TypeScript Handbook: Union types.Check yourself: Where do discriminated unions show up in React code?
Reducer actions, request state, and component props with variants.
Finished the five steps? Mark it, and its cards join your reviews.
Last checked 2026-10-31. Spot a mistake? Use “Report an error” below — fixes are listed on the corrections page.
Interview questions this answers
- What is a discriminated union? Show how you model a request state.