skip to content

Discriminated Unions

The tagged-union pattern: give every member of a union a shared literal-typed field so a single check selects the right shape. Interviewers treat it as the signature TypeScript modeling skill — it is how you make illegal states unrepresentable.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

explore

questions

13

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

open as a page

In TypeScript, what must be true of a property for the compiler to treat it as a union's discriminant, so that comparing that property narrows the value to a single member?

level: juniorimportance: must knowfreq 70%

basics

~20 s

The tag must exist on every union member and be typed as a literal — a string, number, boolean or enum-member literal — with a different value per member, so an equality check selects exactly one member.

open as a page

In TypeScript, given `type Shape = { kind: 'circle'; radius: number } | { kind: 'square'; side: number }`, why can you read `shape.radius` inside `case 'circle':` of `switch (shape.kind)`, but not on the same value before the switch?

level: juniorimportance: must knowfreq 66%

basics

~20 s

Comparing the literal tag narrows the value, so inside case 'circle' shape is only the circle member and radius exists. Before the switch it is still the whole union, where one member has no radius.

open as a page

In the TypeScript helper `function assertNever(value: never): never { throw new Error('Unexpected: ' + JSON.stringify(value)); }`, why is the parameter typed `never`, and why is the return type annotated `never` as well?

level: middleimportance: must knowfreq 58%

basics

~20 s

The never parameter accepts only a value the checker proved impossible, which is what turns a forgotten case into a compile error. The never return type is assignable to any declared return type and tells control-flow analysis the call terminates.

open as a page

In TypeScript, when a `switch` over a discriminated union has a `case` for every member's tag, what type does the switched value have in the `default` branch, and how does that turn adding a new union member into a compile error?

level: middleimportance: must knowfreq 72%

basics

~20 s

It has type never: each case removed one member and nothing is left. Since only never is assignable to never, a default branch that feeds the value somewhere requiring never stops compiling as soon as an unhandled member survives.

open as a page

You are handed the TypeScript type `{ isLoading: boolean; data?: User[]; error?: Error }` used to represent the state of an async request, and asked to make illegal states unrepresentable. How would you redesign it, and what does the change buy the call sites?

level: seniorimportance: must knowfreq 62%

basics

~20 s

Replace the flag bag with a union tagged by a status literal — idle, loading, success carrying required data, error carrying required error. Combinations like loading-with-an-error stop being constructible, and call sites lose their optional-field guesswork.

open as a page

A TypeScript codebase dispatches through a lookup object typed `Record<Shape['kind'], (s: Shape) => string>` instead of a switch with an assertNever fallback. How does that enforce exhaustiveness, and where does the compiler report a missing case?

level: middleimportance: should knowfreq 34%

basics

~20 s

Record over the tag union makes one required property per tag, so a missing handler is an error at the object literal that builds the table — reported at declaration, whether or not the table is ever called, and with the missing key named.

open as a page

In TypeScript, how can a function that dispatches over a discriminated union get a compile error for a missing case without calling any assertNever helper, and which compiler settings does that depend on?

level: middleimportance: should knowfreq 40%

basics

~20 s

Annotate the function's return type explicitly and omit the fallback branch. A missing case leaves the end point reachable, and under strictNullChecks the compiler reports that the function lacks an ending return statement. noImplicitReturns gives the same protection.

open as a page

In TypeScript, this code fails to compile — why, and what are the ways to fix it? ```ts type Shape = { kind: 'circle'; r: number } | { kind: 'square'; size: number }; const c = { kind: 'circle', r: 1 }; const s: Shape = c; ```

level: middleimportance: should knowfreq 52%

basics

~10 s

Inference widens the object's kind property to string, and string is not assignable to the literal type 'circle'. Fix it by annotating the variable as Shape, adding as const, or using satisfies Shape.

open as a page

A TypeScript service compiles with no errors, yet its assertNever helper throws "Unexpected value" in production. How is that possible, and how would you harden the code?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Types are erased at compile time, so the exhaustiveness proof covers only the code — not the data. A value whose tag is outside the declared union reached the dispatch, almost always through an unvalidated payload or an as assertion at a boundary.

open as a page

A TypeScript service switches over a discriminated union of message types that it built by casting the result of `JSON.parse`, and the compiler narrows the value in the `default` branch to `never`. Can that branch still execute at runtime, and what does that mean for how you write it?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Yes, it can run. Types are erased, so the emitted switch has no idea the default is supposed to be impossible, and a payload with an unrecognised tag lands there. Write it defensively: log the actual tag and fail loudly.

open as a page

In TypeScript, you are choosing the discriminant for a union whose values are also serialized as JSON and exchanged with other services. What drives your choice of tag name and tag values, and what do you commit to by making that choice?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Pick one conventional, required field name that carries no data of its own, give it readable string-literal values that are unique within the union, and treat those values as a published contract: renaming one is a breaking change for every producer and consumer.

open as a page

Your TypeScript library exports a discriminated union of event types, and consumers switch over it exhaustively so the leftover value in `default` is `never`. Adding one member breaks every consumer's build. How do you weigh keeping that compile-time exhaustiveness against letting consumers absorb unknown members?

level: principalimportance: nice to knowfreq 27%

basics

~20 s

Decide whether the union is closed or open, and say so in the contract. A closed union makes every addition a breaking change consumers must handle; an open union asks consumers to keep a real fallback branch and gives up compile-time exhaustiveness.

open as a page