TypeScript path · Modelling data · Topic 4 of 11

Unions, discriminated unions and exhaustiveness

Not started

A union type A | B says a value is one of several things. The checker only lets you use what every member has in common until your code proves which one it is. When each member carries a literal tag (kind: "circle", status: "error"), a single comparison narrows the whole object. That pattern, a discriminated union, is the most useful thing in TypeScript for modelling data that comes in different states.

Why it matters: Modelling states as a union with a tag field turns "loading but also has an error" bugs into compile errors, and makes the checker tell you every place to update when a new case is added.

  1. 1A union is one of these

    `A | B` accepts either. Until you narrow it, you can only use what all members have in common.

  2. 2A tag tells them apart

    Give each member a literal field, such as `kind` or `status`. Checking it narrows the whole object, and the tag is real data, so the check works at runtime.

  3. 3Exhaustiveness with never

    After every case has been handled, the value's type is `never`. Assigning it to a `never` variable fails to compile the day someone adds a case you don't handle.

1

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:

⌘/Ctrl + Enter
OutputNot run yet

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.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.`r` is `{ status: "ok"; data: string } | { status: "error"; message: string }`. Where can you read `r.data`?
  2. 2.A new member `{ status: "retrying" }` is added to the union. What makes the compiler point at every `switch` that doesn't handle it?
  3. 3.Why is `{ loading: boolean; data?: User; error?: string }` a weaker model than a union?

0 of 3 right so far. Get all three to finish this topic.