Modelling a Domain So Wrong States Cannot Exist
Bring the course together: use discriminated unions to make impossible states unrepresentable.
What you'll learn
- Design a discriminated union for a state machine
- Narrow on a discriminant property to access variant-specific fields
- Use an exhaustiveness check so a new variant becomes a compile error
Prerequisites
Explanation
Here is a shape you have probably written:
interface RequestState {
loading: boolean;
data?: string;
error?: string;
}
It permits states that make no sense: loading and holding an error; data and an error together; neither loading nor finished. Every consumer must then defend against combinations that should never occur — and eventually one forgets.
The discriminated union
Describe the states that genuinely exist, each with a shared literal discriminant:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; message: string };
data now exists only on success, and message only on error. There is no way to construct a loading-with-an-error value: the illegal states are gone, not guarded against.
Narrowing on the discriminant
Checking status narrows to one variant, unlocking its fields:
function render(state: RequestState): string {
switch (state.status) {
case "idle": return "Nothing yet";
case "loading": return "Loading…";
case "success": return state.data; // only here does data exist
case "error": return state.message; // only here does message exist
}
}
Accessing state.data in the loading branch is a compile error, because that variant genuinely has no such field.
Exhaustiveness with never
The real payoff arrives months later, when someone adds a variant:
default: {
const exhaustive: never = state;
return exhaustive;
}
never is the type with no possible values. If every variant is handled, nothing reaches default and the assignment is fine. Add { status: "cancelled" } and that variant can reach default — it is not assignable to never, so the build fails and points at the switch you forgot.
That is the whole idea: push errors from runtime to compile time, and from "someone notices" to "the build stops."
Designing this way
When modelling, ask which combinations are actually possible, then write only those. Optional fields are frequently a hint that two or more distinct states have been flattened into one shape.
You now have the pieces: annotations and inference, arrays and objects, named shapes, unions and narrowing, optionality, functions, generics, derived types, literal types, and guards. Discriminated unions are where they combine into designs the compiler enforces for you.
Example
A four-state union, narrowing per branch, and an exhaustiveness check that would fail if a variant were added.
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; message: string };
function render(state: RequestState): string {
switch (state.status) {
case "idle":
return "Nothing requested yet";
case "loading":
return "Loading…";
case "success":
return "Got: " + state.data;
case "error":
return "Failed: " + state.message;
default: {
const exhaustive: never = state;
return exhaustive;
}
}
}
console.log(render({ status: "idle" }));
console.log(render({ status: "loading" }));
console.log(render({ status: "success", data: "42 rows" }));
console.log(render({ status: "error", message: "timeout" }));Try it yourself
Add `| { status: "cancelled" }` to the union and Run. The never assignment turns the missing case into a compile error.
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
Define `type Payment = { kind: "cash" } | { kind: "card"; last4: string }`. Write `describePayment(p: Payment): string` returning "Paid in cash" or "Card ending 4242".
Checks: describePayment is defined · Handles the cash variant · Handles the card variant
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
Define `type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }`. Write `area(s: Shape): number` returning the correct area, rounded to 2 decimals with Math.round(x * 100) / 100.
Checks: area is defined · Computes a rectangle's area · Computes a circle's area
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Common mistakes
- Flattening several states into one shape with optional fields, which permits combinations that should be impossible.
- Using a boolean discriminant like `isError`. Two booleans already describe four states, most of which are nonsense.
- Omitting the `never` exhaustiveness check, so adding a variant silently falls through instead of failing the build.
Knowledge check
Takeaway
Model the states that can actually exist, and impossible states stop being a thing you defend against.
Summary
A discriminated union gives each variant a shared literal discriminant, so variant-specific fields exist only where they are valid. Narrowing on the discriminant unlocks those fields, and a `never` exhaustiveness check turns a forgotten variant into a build failure.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.