Make illegal states unrepresentable
If a state can't be written down in the type, your code can't end up in it.
You open a card on a dashboard. It says Loading… and also shows an error message and yesterday's data, all at the same time. You stare at it for a minute, then start hunting through the component to find which combination of flags produced this. You find three booleans, two optional fields, and one comment that says "TODO — fix this later." That's the bug this lesson is about, and the fix is structural — not a patch.
The bag of optionals
Most data-loading code starts the same way. You make a small object that holds whatever the screen needs to know.
type FetchState<T> = {
loading: boolean;
data?: T;
error?: Error;
};That looks fine. loading says whether the request is in flight. data holds the result if it arrived. error holds the failure if there was one. The trouble is what the type also allows. Nothing in FetchState<T> stops you from setting loading: true, data: someValue, and error: someError all at once. The shape is a bag — every field stands on its own, and the compiler doesn't know they're related.
That bag of optionals is where the Loading and error and stale data card came from. Some code path set loading = true for a refresh and forgot to clear error. The component asked all three questions in different if branches and rendered all three answers.
An optional field reads as "this might be missing." It doesn't read as "this only exists when that other field is set." The compiler won't enforce a relationship you only described in a comment.
FetchState<T> above, which combination is the type happy to allow but your UI logic probably can't handle?The discriminated union
The fix is to stop describing the fields and start describing the states. A fetch can be in one of four situations, and only one at a time: nothing started yet, in flight, succeeded with data, failed with an error. A discriminated union is a TypeScript type made by joining several object types with |, where each object has a literal field — the discriminant — that tells you which of the alternatives you're looking at.
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };Read it out loud. A FetchState<T> is either { status: "idle" }, or { status: "loading" }, or { status: "success", data: T }, or { status: "error", error: Error }. Nothing else. The success arm is the only arm that even has a data field — there's no way to write a state that has data and an error at the same time, because no arm of the union has both. The bug we started with is no longer expressible.
The phrase "discriminated union" sounds heavy but it just means the union has a tag that tells you which arm you're in. The tag here is status. It's a string literal — not just string, but the specific values "idle", "loading", "success", "error". That literal is what lets the compiler narrow.
Narrowing as a guide
Once the state is a union with a tag, the compiler will help you read it. When you check the tag in an if or a switch, TypeScript narrows the type to just that arm — and the fields of that arm become available without ? checks.
function render(state: FetchState<User>): string {
if (state.status === "idle") return "";
if (state.status === "loading") return "Loading…";
if (state.status === "error") return "Error: " + state.error.message;
return "Hello, " + state.data.name; /* status must be "success" here */
}Walk the function. The first three checks each peel off one arm. After all three return, the only arm left is "success", so TypeScript knows state.data exists and is a User. There's no state.data?.name ceremony. There's no defensive null check. The type system has carried the information from the check up there to the use down here. That carrying is called narrowing, and it's the practical payoff of using a discriminated union over a bag of optionals.
If you tried to read state.data in the loading branch, you'd get a compile error. The loading arm has no data field at all. The bug you were chasing in the bag-of-optionals version becomes a build-time message before the bug reaches a browser.
if (state.status === "error") { ... }, which expression compiles cleanly?Designing the states first
The phrase that gives this lesson its name comes from a Yaron Minsky talk: make illegal states unrepresentable. The advice is to choose your types so that the situations your code can't handle simply can't be written down. If loading: true and error: someError together is a bug, build a type where you can't put both in an object at the same time. The compiler isn't an extra step after the design — it is the design.
A practical recipe. Before writing the first field, list the situations the value can be in. Write each one as a sentence. Then translate each sentence to an arm of a union, with a literal tag. Add only the fields that make sense in that situation. If a field could be present or absent, ask if "absent" is really a different state — and if it is, give it its own arm.
type FormState =
| { kind: "editing"; values: { email: string; password: string } }
| { kind: "submitting"; values: { email: string; password: string } }
| { kind: "submitted"; userId: string }
| { kind: "rejected"; values: { email: string; password: string }; reason: string };A login form has four situations. While editing, you have whatever the user typed. While submitting, same — but the form should not accept new input. After success, you don't keep the password around; you keep an id. After rejection, you keep the values so the user can fix them, plus a reason to show. Each arm holds only the fields it needs. The shape of the type tells the next reader what the form does.
Pick a discriminant name and stick to it across a codebase. status, kind, and type are all common. Mixing them makes shared helpers (like the assertNever trick from lesson 4) harder to write.
"refreshing", that should keep the previous data while a refresh runs. Which type captures that without making illegal states reachable?