skip to content

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%

answer

  1. each case subtracts members from the union
  2. the tail holds the empty type
  3. nothing is assignable to it
  4. the error needs a use site
  5. adding a member becomes a build failure

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.

solid answer

~50 s

Narrowing is subtractive. Every `case` clause eliminates the members whose tag could match it, so by the time control reaches `default` the compiler has removed all of them and the value's type is `never` — the empty type, the type with no values. That fact is what you exploit for exhaustiveness: `never` is assignable to everything, but nothing except `never` is assignable to `never`. So if you put the leftover value into a position that only accepts `never` — for example `const exhaustive: never = shape;` — the code compiles today and breaks the day someone adds a third member, because that member survives to `default` and is no longer assignable. The error points straight at the switch that was silently missing a case. Teams usually package the check as a small shared helper rather than repeating the local declaration.

code

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

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
    default: {
      // shape is `never` here today; adding a member breaks this line
      const exhaustive: never = shape;
      throw new Error(`unhandled shape: ${JSON.stringify(exhaustive)}`);
    }
  }
}

go deeper

for a junior

Know that the value left over in the default branch of a fully handled switch has the type never, and that never means "no value can be here".

for a middle

Explain the subtraction: each case removes members, never is the empty type, and the compile error only appears where the leftover value is assigned into a never-typed position.

for a senior

Demonstrate why this matters in a growing codebase — it converts a silent behavioural gap into an enumerated build failure — and name the conditions that silently disable it, such as a permissive default or a widened tag.

for a principal

Be ready to argue when a codebase should mandate this check in review or lint policy, and what it costs at module boundaries where a union is expected to keep growing.

## Narrowing is subtractive Control-flow analysis treats each `case` clause as removing possibilities. Given ```ts type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number }; ``` the switch on `shape.kind` starts with both members in play, drops the circle member after the `case 'circle':` clause and the square member after `case 'square':`. Whatever reaches `default` is a value of the union with every member removed. TypeScript spells the empty type `never`. This only works if the tag really is a union of literal types. If the discriminant is typed `string`, or the value is `any`, or one member's tag was widened somewhere along the way, nothing gets subtracted and the tail is not `never`. ## What `never` means `never` is the type with no possible values — the bottom type. Two assignability rules follow from that and they are the whole trick: - `never` is assignable to **every** type, because a value that cannot exist can be used anywhere vacuously. - **Nothing** is assignable to `never` except `never` itself, because there is no value it could be. So a `never`-typed position is a compile-time assertion that control cannot get here. ## The error needs a use site This is the part candidates miss. Having the type be `never` does not, by itself, produce a diagnostic. A `default` branch that just logs and returns a fallback compiles happily whether or not the union is exhausted — you have written nothing that requires `never`. You must actually consume the leftover value in a `never`-only position: ```ts function area(shape: Shape): number { switch (shape.kind) { case 'circle': return Math.PI * shape.radius ** 2; case 'square': return shape.side ** 2; default: { const exhaustive: never = shape; throw new Error(`unhandled shape: ${JSON.stringify(exhaustive)}`); } } } ``` Add a third member to `Shape` and forget the case, and the assignment is the line that fails, with a message of the form *Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'*. The message names the exact member you forgot, which is why this pattern is such a good refactoring aid: the type system turns "someone might forget to update a switch" into a build failure with an address. ## Why this is worth doing at all Without it, extending a union is a silent hazard. The union grows, every switch over it keeps compiling, and the missing branches surface as wrong behaviour in production rather than as errors in CI. With it, the compiler enumerates every place in the codebase that must be revisited — the closest TypeScript gets to a checked "handle all cases" contract. ## Things that quietly defeat it - **A permissive `default`.** If the branch swallows the value without a `never`-typed use, you get no protection. This is the single most common way the pattern is present in the code and doing nothing. - **A widened discriminant.** A tag inferred as `string` — commonly from a mutable object literal — leaves the union unsubtracted. - **`any` anywhere upstream.** An `any`-typed value narrows to `any`, and `any` is assignable to `never`... actually `any` is assignable to every type except `never`, so an `any` value in that assignment does still error; the real hazard is that an `any` value never entered the union check meaningfully in the first place. - **Extra cases.** A `case` for a tag that is not in the union is itself a type error on the comparison, which is a separate and useful signal. ## It buys you nothing at runtime Types are erased. The emitted JavaScript is a plain `switch`; the `never` declaration compiles to an ordinary variable assignment and disappears from the type layer entirely. The `default` branch stays in the output and is genuinely reachable if a value ever arrives that does not match the declared type. That is why the branch still throws or logs: the exhaustiveness check protects the *code* from drifting behind the *type*, not the process from bad data.

  • If the leftover value already has type `never`, why does the switch still need code in the `default` branch at all?
    Because a type on its own produces no diagnostic. The error comes from an assignability check, and there is no check unless you write a position that demands `never`. A `default` that only logs compiles identically before and after someone adds an unhandled member.
  • What error message does the developer see the day they add a member and forget the case?
    An assignability failure naming the forgotten member — of the form "Type '{ kind: \"triangle\"; ... }' is not assignable to type 'never'". That is unusually useful for a type error: it points at the switch and spells out exactly which shape is unhandled.
  • Does the same exhaustiveness trick work for an if/else-if chain?
    Yes. The narrowing rules are the same, so the final `else` holds `never` once every member has been tested. Switch is preferred mainly because a single discriminant compared against many literals reads better and gives one obvious tail branch.
  • What breaks the pattern if the discriminant is not a literal type?
    Nothing is subtracted. Comparing a `string`-typed tag against a literal cannot eliminate a union member, so the tail keeps the full union type and the `never` position rejects the code immediately — or, worse, the union was never discriminated and no case narrowed at all.

saying these in an interview costs you the question

  • Thinks the never type alone produces the compile error
  • Says the default branch is removed from the emitted JavaScript
  • Claims exhaustiveness is checked at runtime
  • Believes any value can be assigned to never
  • Says a logging-only default still catches a new member

context