skip to content

In TypeScript, a dispatch over a discriminated union ends with `default: throw new Error('unhandled kind')`. What does replacing that throw with a call to a helper declared `function assertNever(value: never): never` buy you?

level: juniorimportance: must knowfreq 55%

answer

  1. one guard runs, the other compiles
  2. who notices the missing case first
  3. only an impossible value fits
  4. never parameter, build-time error
  5. the throw is for erased types

basics

~20 s

A plain throw only fires at runtime. Passing the value to a parameter typed never makes the compiler check that branch too, so an unhandled union member becomes a build error rather than a production surprise.

solid answer

~50 s

Both versions throw, but only one of them is checked before the code ships. A bare `throw new Error('unhandled kind')` is invisible to the type checker — the branch compiles no matter how many union members you forgot. `assertNever` works because its parameter is typed `never`, and the only thing assignable to `never` is a value the checker has already proven impossible. When every member of the union is handled, the value reaching the fallback branch has been narrowed to `never` and the call compiles. The moment someone adds a member and forgets a branch, that value is no longer `never`, and the call fails to compile with "Argument of type ... is not assignable to parameter of type 'never'". You keep the `throw` inside the helper because TypeScript's types are erased: at runtime nothing stops a value with an unknown tag from arriving anyway.

code

typescript · 20 lines
typescript
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

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

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

console.log(area({ kind: 'square', side: 3 }));

go deeper

for a junior

Be able to write the four-line helper from memory and say plainly what each half does: the never parameter is checked by the compiler, the throw fires at runtime. Say that types disappear after compilation.

for a middle

Explain the assignability rule that powers it — nothing is assignable to never except a value already narrowed to never — and why adding a union member breaks exactly the sites that fell behind.

for a senior

Show where the runtime throw actually fires in a real system: unvalidated payloads, older persisted records, casts at the boundary. Argue for validating input rather than relying on the helper as a net.

for a principal

Own the policy: where the helper lives, whether it throws or reports in user-facing paths, and how a team keeps exhaustiveness enforced across packages as unions grow. Weigh a build-breaking error against a shipped silent fallthrough.

## The problem the fallback branch is trying to solve A discriminated union is a set of object types distinguished by a literal-typed tag field, and code that consumes one usually branches on that tag. The failure mode is not the branching — it is *growth*. Someone adds a new member to the union six months later, and every dispatch site in the codebase silently keeps compiling while quietly doing the wrong thing for the new case. The naive defence is a fallback branch that throws: ```ts default: throw new Error('unhandled kind'); ``` This is a runtime tripwire. It tells you about the missed case *after* you deployed, on the first request that hits it. ## What the typed helper changes ```ts function assertNever(value: never): never { throw new Error(`Unexpected value: ${JSON.stringify(value)}`); } ``` The body is the same throw. The parameter annotation is the whole trick. `never` is the type with no values: nothing is assignable to it except an expression the checker has itself concluded is impossible. So `assertNever(x)` is a question posed to the compiler — *have you proven x cannot exist here?* If yes, it compiles. If not, it is a type error, reported at build time, at the exact dispatch site that fell behind the union. Call it in the branch that should be unreachable: ```ts switch (shape.kind) { case 'circle': return Math.PI * shape.radius ** 2; case 'square': return shape.side ** 2; default: return assertNever(shape); } ``` Add `{ kind: 'triangle'; base: number; height: number }` to the union and this file stops compiling until someone writes the triangle case. That is the entire value proposition: a missing case becomes a red build, not a red pager. ## Why the throw stays A common junior instinct is that the helper's body no longer matters, since the compiler proved it unreachable. It matters, because TypeScript types do not exist at runtime. The compiler erases them and emits plain JavaScript; there is no check that a value tagged `kind: 'hexagon'` cannot flow in from a JSON response, a database row written by an older service version, or an `as` assertion somewhere upstream. The type system's proof is a proof about *code you compiled*, not about *data you received*. The throw is what happens when reality disagrees with the annotation, and it should be loud, include the offending value, and never be downgraded to a silent `return undefined`. So the helper does two jobs at two different times: the `never` parameter is a compile-time exhaustiveness check, and the `throw` is a runtime fail-loud for data that violates the declared union. ## Naming and shape The function is conventionally called `assertNever` or `assertUnreachable`; neither is built into TypeScript — you write these few lines yourself, usually once in a shared utility module. It is not the `asserts value is T` assertion-function form, which is a different signature with different semantics; `assertNever` simply takes `never` and returns `never`. The return type matters too. Because `never` is assignable to every type, `return assertNever(shape)` satisfies a function declared to return `number` without any cast, which is why the call is usually written with `return` in front of it. ## What a weak answer looks like The two answers that get marked down are "it throws a better error message" (it does not — the message is whatever you write; the compile-time check is the point) and "it validates the value at runtime" (it validates nothing; a value that reaches it has already defeated the type system). The correct framing is: the plain throw guards runtime only, and the typed helper adds a compile-time guard on top of the same runtime throw. ## When you would not reach for it If the function returns a value and you annotate its return type explicitly, TypeScript can already flag a missing case without any helper, because the function's end point becomes reachable. `assertNever` earns its keep in dispatches that return nothing, in `if/else if` chains, and wherever you want the error to point precisely at the unreachable branch rather than at the function signature.

  • If the compiler has proven that branch unreachable, why keep the throw inside the helper at all?
    Because the proof covers compiled code, not incoming data. Types are erased, so nothing stops a value with an unrecognised tag arriving from JSON, a stale database row, or an upstream `as` assertion. The throw is the only thing that happens then, and it should name the offending value so the log identifies the source.
  • What happens if you type the helper's parameter as `unknown` instead of `never`?
    It compiles everywhere and checks nothing. `unknown` accepts every value, so the call site stays green no matter how many union members are unhandled — you are back to a runtime-only tripwire with extra ceremony. The narrowness of `never` is precisely what makes the call a question the compiler must answer.
  • Where would you put this helper in a codebase?
    In one shared utility module, exported once and imported everywhere. It is a few lines, so teams often duplicate it per file, which is harmless but makes the convention invisible. A single named export — `assertNever` or `assertUnreachable` — also gives you one place to decide how the runtime failure is reported.

saying these in an interview costs you the question

  • Claiming assertNever validates the value at runtime
  • Thinking the benefit is a nicer error message
  • Believing TypeScript keeps the union check after compiling
  • Removing the throw because the branch is unreachable
  • Confusing it with the asserts value is T assertion form

context