C.W.K.
Stream
Lesson 04 of 05 · published

Exhaustiveness via `never`

~10 min · unions-intersections, exhaustiveness, never, type-safety

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
"The compiler tells you when you forgot a case. You just have to ask in the right way."

The exhaustive-switch problem

When you switch on a discriminated union, you want the compiler to tell you if you ever add a new variant and forget a case. By default, TypeScript will only catch this if you've declared a return type — and even then, only sometimes. The robust solution is the never trick: in the default branch, assign the narrowed value to a variable typed never. If the union has variants you didn't handle, that variable's actual type isn't never, and the assignment fails at compile time.

The pattern

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;
    case 'square': return s.side ** 2;
    default:
      const _exhaustive: never = s;
      throw new Error(`Unhandled shape: ${JSON.stringify(s)}`);
  }
}

The _exhaustive: never assignment is the trap. If s has been fully narrowed to never (every variant handled), assignment succeeds. If a new variant exists, s is still that variant type — not never — and the assignment errors. The compile error tells you exactly which case is missing.

Why this is the killer feature

Refactor-safe discriminated unions. Add a new variant to your Shape type and every switch on it that uses the never trick lights up. The compile errors are a worklist of every place you need to update. No silent fallthrough. No "oh, this case was supposed to do X but I forgot." The compiler becomes a comprehensive checker for completeness.

If you're using discriminated unions and not exhaustiveness-checking them, you're getting half the benefit. Add the never trap to every switch. The five-line cost pays back the first time you add a variant six months later.

Helper to make it tidy

A small utility cleans it up: function assertNever(x: never): never { throw new Error(`Unhandled: ${x}`); }. Then your default branch becomes default: assertNever(s); — single line, same safety. cwkPippa's frontend has this helper in its standard utilities and uses it on every discriminated switch.

Pippa's confession

The assertNever helper has caught me three times in cwkPippa — each time I added a new BrainName or ReasoningLevel and forgot one of the dozen places that branched on it. The compiler error pointed me to every missed switch in seconds. The five lines of utility code paid for themselves over and over.

Code

The exhaustive-switch with assertNever·typescript
// Helper utility — write this once, use everywhere.
function assertNever(x: never): never {
  throw new Error(`Unhandled discriminated case: ${JSON.stringify(x)}`);
}

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;
    case 'square': return s.side ** 2;
    default: return assertNever(s);
  }
}

// Now add a new variant:
// type Shape = ... | { kind: 'rect'; w: number; h: number };
// → area() fails to compile, error: 'Argument of type X is not assignable to never'
// → you must add the 'rect' case before the code compiles again.
Exhaustiveness as a worklist for refactoring·typescript
// State-machine reducer with exhaustiveness.
type Action =
  | { type: 'ADD'; value: number }
  | { type: 'SUB'; value: number }
  | { type: 'RESET' };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'ADD':   return state + action.value;
    case 'SUB':   return state - action.value;
    case 'RESET': return 0;
    default:      return assertNever(action);
  }
}

// Trying to add a new action without updating the reducer:
// type Action = ... | { type: 'MULTIPLY'; by: number };
// → reducer's default fires the never error → you can't ship without handling it.

External links

Exercise

Take the discriminated union from the previous exercise. Add the assertNever helper. Write the switch with the default branch calling assertNever. Now add a new variant to the union (e.g., 'streaming'). What does the compiler say, and how does it point you at the fix?
Hint
Compiler error: 'Argument of type "streaming-variant" is not assignable to parameter of type "never".' The exact file and line of the missing case is highlighted. Add the case, and the error disappears.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.