How it works
The explanation, with examples you can run.
Make the impossible states unwritable
A request is often modelled with independent flags:
type User = { name: string } type Loose = { loading: boolean; data?: User; error?: string }Nothing stops { loading: true, error: "timeout", data: someUser }, and every reader has to guess which field wins. A union lists the states that really exist:
type User = { name: string } type Request = | { status: "idle" } | { status: "loading" } | { status: "success"; data: User } | { status: "failure"; error: string } function label(r: Request): string { switch (r.status) { case "idle": return "Not started" case "loading": return "Loading..." case "success": return `Hello, ${r.data.name}` // r is the success member here case "failure": return `Failed: ${r.error}` }}In each case, r is narrowed to the member with that status, so r.data exists only where it can. There is no way to build a success without data, or a failure with it.
Why the tag works
Types are erased, so the checker can only narrow on things that survive to runtime. The tag is ordinary data in the object, and the switch is ordinary JavaScript. Here is the same function after compilation, running:
Press Run to see what your code prints, in order.
Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.
The last call prints undefined: at runtime nothing stops a value that the type does not allow, and the switch simply falls off the end. The compiler can protect you from that, but only inside the code it checks.
Exhaustiveness checking
Add a default branch that assigns the value to never:
type Request = | { status: "idle" } | { status: "loading" } | { status: "retrying"; attempt: number } function label(r: Request): string { switch (r.status) { case "idle": return "Not started" case "loading": return "Loading..." default: { const unreachable: never = r // Error: Type '{ status: "retrying"; attempt: number; }' is not assignable to type 'never' return unreachable } }}With every member handled, the type left in default is never (no possible value), and the assignment compiles. When someone adds retrying, every switch that forgot it fails to compile at exactly that line. With strict on, the checker also complains when a function with a string return type can fall off the end of an incomplete switch, but the never line names the missing case and works in code that doesn't return.
Prefer literal tags ("success") over booleans for anything with more than two states, and keep the tag name the same across the union so one switch covers it.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.