skip to content

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%

answer

  1. let the signature do the checking
  2. what happens when control falls off the end
  3. undefined versus the declared result
  4. annotate, do not infer
  5. useless when the function returns nothing

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.

solid answer

~50 s

Write the return type on the function and drop the default branch entirely. When every member is handled, every path returns and the end point is unreachable. Leave one out and the end point becomes reachable, so the function can fall through and produce `undefined` — which under `strictNullChecks` is not assignable to the declared `string` or `number`, and the compiler reports "Function lacks ending return statement and return type does not include 'undefined'". Turning on `noImplicitReturns` gives an equivalent error, "Not all code paths return a value". Two conditions are easy to miss: the return type must be *annotated*, because with inference TypeScript happily widens the result to include `undefined` and the error disappears or moves to a caller; and the function must actually return a value, so a void dispatcher gets nothing from this technique. That is where assertNever is still needed.

code

typescript · 16 lines
typescript
type Event =
  | { type: 'click'; x: number }
  | { type: 'key'; code: string };

// Explicit return type and no default branch:
// adding a member makes the end point reachable and errors here.
function describe(event: Event): string {
  switch (event.type) {
    case 'click':
      return `click at ${event.x}`;
    case 'key':
      return `key ${event.code}`;
  }
}

console.log(describe({ type: 'key', code: 'Enter' }));

go deeper

for a junior

Know that a function with a declared return type must return on every path, and that leaving a case out of a switch can make the function fall off the end. Recognise the "lacks ending return statement" error.

for a middle

Explain the mechanism: the end point becomes reachable, the implicit result is undefined, and under strictNullChecks that clashes with the declared type. Name noImplicitReturns as the alternative and say why the annotation is mandatory.

for a senior

Judge when this is enough and when you still want an explicit helper — void dispatchers, unions with many members where the error message should name the missing case, and codebases where return types are routinely inferred.

for a principal

Set the standard: which strictness flags are on repo-wide, whether exported functions must carry explicit return annotations, and how those choices make exhaustiveness gaps fail at build rather than in a caller far from the switch.

## The idea: let the return type do the work An `assertNever` call is one way to make the compiler notice a missing case. It is not the only way. If the dispatch produces a value, you can get the same protection from the function's own signature, with no helper, no fallback branch, and no runtime code at all. The recipe is two rules: 1. Annotate the function's return type explicitly. 2. Do not write a `default` branch (or a final `else`). ```ts type Event = | { type: 'click'; x: number } | { type: 'key'; code: string }; function describe(event: Event): string { switch (event.type) { case 'click': return `click at ${event.x}`; case 'key': return `key ${event.code}`; } } ``` While the switch covers every member, every path returns, the end of the function body is unreachable, and the compiler is satisfied. Add a third member — say `{ type: 'scroll'; dy: number }` — and control can now fall out of the switch and off the end of the function, which in JavaScript means returning `undefined`. `undefined` is not assignable to `string`, so you get: ``` Function lacks ending return statement and return type does not include 'undefined'. ts(2366) ``` ## The two settings involved **`strictNullChecks`** (included in `strict`) is what makes this bite. With it off, `undefined` is assignable to `string`, the implicit fall-through return is harmless as far as the checker is concerned, and no error appears. So this technique presupposes the strictness a modern project already has. **`noImplicitReturns`** is the belt-and-braces alternative. It flags any function where some code path returns a value and another falls off the end, independently of nullability: "Not all code paths return a value. ts(7030)". With it on, the check works even where the declared return type would tolerate `undefined`. ## The two conditions people get wrong **You must annotate the return type.** This is the whole reason the technique is called return-type-driven. If you omit the annotation, TypeScript infers the return type from the branches, and after your union grows the inferred type simply becomes `string | undefined`. Nothing is wrong at the function — the error, if any, surfaces later at a caller that expected a `string`, with a diagnostic that points nowhere near the switch you forgot to extend. Worse, if the caller also tolerates `undefined`, the bug is silent. **You must not write a fallback branch.** A `default: return ''` or `default: throw new Error(...)` makes every path return, the end point unreachable, and the missing-case detection disappears. This is the direct trade: a fallback branch buys a runtime behaviour and costs you the compile-time check — unless the fallback is `assertNever`, which restores it. ## Where it does not apply The technique is powered by the *return*, so it does nothing for a dispatch that returns nothing: ```ts function handle(event: Event): void { switch (event.type) { case 'click': trackClick(event.x); break; case 'key': trackKey(event.code); break; } } ``` Nothing is missing here as far as the compiler is concerned — falling out of a `void` function is perfectly legal. A silently-ignored `scroll` event is exactly the bug you wanted caught. For void dispatchers, `assertNever` in the default branch is the tool that works. The same limitation applies to `if/else if` chains that end without an `else` and to any dispatch whose declared return type already includes `undefined` or `null` — if `undefined` is a legitimate result, the fall-through is legitimate too, and the check is gone. ## Reading the error correctly One mild downside compared with `assertNever` is *where* the error lands. Return-type-driven exhaustiveness reports at the function signature — "lacks ending return statement" — which tells you the function is incomplete but not which member you forgot. The `assertNever` diagnostic names the leftover member type in the message, which is faster to act on in a union with a dozen members. Teams that care about that use the helper even where the return type would have caught it. ## What a strong answer covers Name the mechanism (reachable end point plus a return type that excludes `undefined`), name `strictNullChecks` and `noImplicitReturns` as the settings, state the annotation requirement, and identify the void-returning dispatch as the case that forces you back to a helper. That last point is what distinguishes someone who has used the technique from someone who has read about it.

  • Why does adding `default: throw new Error('unhandled')` destroy this check?
    Because it makes every path leave the function, so the end point is unreachable and the return type is satisfied no matter how many members are unhandled. You have traded a build-time error for a runtime one. Calling `assertNever` in that default branch is how you keep both.
  • If the return type is inferred rather than annotated, where does the problem show up?
    Usually nowhere useful. The inferred type widens to include `undefined`, so the function itself is fine; a caller that requires a non-nullable value may complain, with a diagnostic pointing at the call rather than the incomplete switch, and a caller that tolerates `undefined` reports nothing at all.
  • Does this technique help a dispatcher that returns void?
    No. Falling out of a void function is legal, so a missing case produces no diagnostic and the event is silently ignored. That is precisely the situation where you put an `assertNever` call in the default branch, since the never-typed parameter does not care whether the function returns a value.

saying these in an interview costs you the question

  • Believing it works without annotating the return type
  • Keeping a default branch and expecting the error
  • Assuming it also protects void-returning dispatchers
  • Thinking the check works with strictNullChecks disabled
  • Confusing the error location with the missing case's name

context