Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~26 min· topic 6 of 7

Topic 4.6

Exhaustiveness Checking with never

In one line

never is the type with no values. If you handle every member of a union and then try to assign the leftover to never, it works; if you forgot a member, the compiler reports exactly which one. Adding a new variant then points you to every place that needs updating.

Think of it like this

A checklist at the end of a flight. Before the plane leaves, the captain ticks off every item. If one is missing, the plane can't go. **Exhaustiveness checking is that final checklist for your switch:** the compiler holds a list of every variant of your union, and refuses to compile until each one has been handled.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

never
The empty type: no value has it. It marks code that cannot be reached.
Exhaustive
Covering every possible case. An exhaustive switch handles every member of the union.
Leftover type
What remains of a union after the checks have removed the handled members.
assertNever
A helper taking a never parameter and throwing. Passing it a non-never value is a compile error.
default branch
The default: part of a switch, run when no case matched.
Unreachable code
Code that can never run, because the checks before it rule out every case.
Safety net
A runtime check (here the throw) that catches what the types could not know.

Step by step

01Handle every case, look at the leftover

Shape has two variants. A switch handles both. In the default, the compiler has nothing left: shape is never.

Hover the variable in the first example below to see it. A never can be assigned to every type, so returning it is also allowed.

02Forget a case, and the leftover is the missing one

Now add a third variant, triangle, and don't write a case for it. The default branch receives the triangle, so const unreachable: never = shape is an error.

The error text is the full type of the missing variant. That's your to-do list: write case "triangle".

area.tswhole filets
default: {
  const unreachable: never = shape;
  // error TS2322: Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'.
  return unreachable;
}

03Wrap it in assertNever

Instead of repeating the assignment, put it in a function. The compile-time part is the parameter type never. The runtime part is the throw.

In the default branch write return assertNever(shape). When a case is missing, the error is on that line: Argument of type '{ kind: "triangle"; ... }' is not assignable to parameter of type 'never'.

assert-never.tswhole filets
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}

04Other ways to get exhaustiveness

A function whose return type is not undefined and whose switch has no match for some case fails with TS2366: Function lacks ending return statement. A Record<Light, number> must list every light. Choose the style your team can read; assertNever is the clearest and also protects at runtime.

Other ways to get exhaustivenessdiagram
Rendering diagram…

05When data lies

If the server sends a kind that isn't in your type, the types are wrong about reality. assertNever throws with the offending value, so the failure is loud and the message tells you what arrived.

Try it yourself

  1. 1

    Fix the triangle

    In the second example add case "triangle": return (shape.base * shape.height) / 2; before default. The error vanishes and the last line prints 6.

    Now remove the square case instead. Which type does the error show?

  2. 2

    Add a variant to the first example

    In the first example add | { kind: "triangle"; base: number; height: number } to Shape. Read the error message completely: it contains everything you need to write the new case.

  3. 3

    Return types matter

    In the third example, add : Light to next2. The missing-return error appears there too. Then add the yellow case to both functions and the errors vanish.

Code & diagrams

The leftover after every case is never

Every kind has a case, so the default branch can never run and shape is never there. Try adding a third member to Shape and read the new error.

Console

(nothing was printed)

Expected console output

9
assertNever: a compile error and a runtime safety net

The triangle has no case, so the error is reported on the assertNever(shape) line and lists the whole missing type. The code still runs: the square works and the triangle reaches the throw. Add case "triangle": return (shape.base * shape.height) / 2; to fix it.

Console

(nothing was printed)

Expected console output

16
Uncaught Error: Unexpected value: {"kind":"triangle","base":3,"height":4}
Missing returns and missing keys point to the same gap

With a return type written, the missing yellow case is reported at the function. Without one, TypeScript quietly adds undefined to the result (see r), which is why writing return types on functions that switch is a good habit. Record<Light, number> demands all three keys.

Console

(nothing was printed)

Expected console output

undefined

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Add a variant without a case

In the first example, add | { kind: "triangle"; base: number; height: number } as a third member of Shape.

terminal
$ Types panel
── what you'll see ──
index.ts(13,13): error TS2322: Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'.

Myth vs fact

Myth

never is the same as void.

Fact

void means "returns nothing useful" (and the function does return). never means the function never returns, or a value can't exist.

Myth

A default branch makes a switch safe.

Fact

A plain default silently swallows new variants. A default that assigns to never or calls assertNever fails the build instead.

Myth

Exhaustiveness checking guards against bad runtime data.

Fact

Only against types. Use the runtime throw in assertNever and validate external data where it enters.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    never is the bottom type: it is assignable to every type, and nothing (except never) is assignable to it. That is why const x: never = shape only compiles when the leftover is empty, and why a function returning never can appear where any type is expected. In unions it disappears (T | never is T), which conditional types use to filter members (later in the course).

  • ▸

    A switch whose cases cover everything lets the compiler treat the end of the function as unreachable. This is why area above needs no final return even with : number. The same analysis is behind noImplicitReturns and noFallthroughCasesInSwitch; both are worth enabling. strict does not include them.

  • ▸

    The linter rule @typescript-eslint/switch-exhaustiveness-check reports missing cases without any code in default. By default it still reports missing members when a default exists; the option considerDefaultExhaustiveForUnions: true makes it accept a default as covering the rest. Choose between the lint rule and assertNever as a team convention; many teams use both.

Remember this

  1. 1

    The problem this solves: When someone adds a new variant to a union, every switch that handles it must be updated, and a forgotten one silently does nothing. The compiler can point at each one. **never** is TypeScript's empty type: a type that has no possible value at all. A variable of type never can never exist, because there is nothing to put in it. A function that always throws, or loops forever, has return type never. It sounds useless, but it's the key to this topic: it represents "this place in the code must be unreachable".

  2. 2

    Remember narrowing by elimination (Topics 4.3 to 4.5). Each time you check a union and handle a member, the compiler removes that member from what's left. If you handle every member, nothing is left, and the leftover type is **never**. In the default branch of a switch over a discriminated union with all cases covered, shape has type never.

  3. 3

    That gives a trick. In the default branch write const unreachable: never = shape;. If every case is handled, shape is never and the assignment is fine. If you forgot one, shape is the missing member, and assigning it to never is an error that prints the missing member's full type: Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'. You read the message and know exactly which case to write.

  4. 4

    Wrapping the trick in a function makes it reusable: function assertNever(value: never): never { throw new Error(...) }, and the default branch becomes return assertNever(shape). The parameter type never does the compile-time check. The throw is a runtime safety net: if data from outside the type system (an API, a stale cache, a newer server) sends a variant your code doesn't know, the program fails loudly with a clear message instead of silently continuing with the wrong behaviour.

  5. 5

    This is how adding a new variant becomes safe. You add "triangle" to Shape. Every switch that ends with assertNever(shape) now fails to compile, each with an error at that default line. You work through the list, and when the compiler is silent, nothing was forgotten. Without the check, the new variant would fall into a default or produce undefined in some forgotten corner, and you'd learn about it in production.

  6. 6

    Other ways to get the same checking. A function with a declared return type and a switch with no default and no matching case gives TS2366: Function lacks ending return statement and return type does not include 'undefined', so you often get exhaustiveness for free. A Record<Light, number> object must have a key for every light, or TS2741 names the missing one. And the linter rule @typescript-eslint/switch-exhaustiveness-check reports missing cases, even in functions without a return type.

  7. 7

    A limit worth saying plainly. Exhaustiveness is checked against the types, not against runtime data. If you wrote the type as "circle" | "square" but the JSON from a server contains "hexagon", TypeScript can't see it. That's why assertNever also throws at runtime, and why data from outside should be validated where it enters (Topic 4.7 and the validation topics later).

Explain it without notes

01

What is the never type and why is it useful for switch statements?

02

How does assertNever give both compile-time and runtime safety?

03

What happens to your code when you add a new member to a union that is switched over with assertNever?

04

What can exhaustiveness checking not catch?

Practice

01

Given type Level = "debug" | "info" | "warn" | "error";, write function icon(l: Level): string as a switch that ends with assertNever. Then remove one case and write down the error.

02

Write assertNever yourself, then add a fifth level "fatal" to Level and list what tsc reports for icon.

03

Replace the switch in icon with an object const ICONS: Record<Level, string> = .... What does adding "fatal" to Level now do?

Trade-offs

  • ↔

    assertNever adds one helper and one default per switch, and gives you refactor safety plus a runtime guard. The cost is that every new variant forces edits in all those switches, which is exactly the purpose.

  • ↔

    Not every switch should be exhaustive. A function that only cares about two of ten variants should say so with a normal default. Use assertNever where silent fall-through would be a bug.

Done when you can

  • I can explain never as the empty type.

  • I can write the const x: never = value trick and read its error.

  • I can write and use assertNever.

  • I can name two other ways to force all cases (return type and Record).

  • I can say what exhaustiveness checking does not protect against.