skip to content

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%

answer

  1. required properties, one per tag
  2. the object literal is what fails
  3. errors before anything calls it
  4. not an index signature
  5. handlers see the whole union

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.

solid answer

~50 s

`Record<K, V>` over a finite union of literal tags produces one required property per tag, so the lookup object must supply a handler for every member. Omit one and the object literal fails to compile with "Property 'square' is missing in type ... but required in type"; add a key that is not in the union and excess-property checking rejects it. The error lands at the table's declaration rather than at a dispatch site, and it fires even if nothing ever calls the table — a real advantage when the same union is dispatched in several places. Indexing is safe too: a Record over literal keys creates concrete properties, not an index signature, so `table[shape.kind]` is not widened by `noUncheckedIndexedAccess`. The cost is narrowing — each handler's parameter is the whole union, not the matching member, unless you build the table type with a mapped type instead.

code

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

// Record over the tag union: both keys are required properties.
const label: Record<Shape['kind'], (s: Shape) => string> = {
  circle: (s) => `circle ${JSON.stringify(s)}`,
  square: (s) => `square ${JSON.stringify(s)}`,
};

function describe(shape: Shape): string {
  return label[shape.kind](shape);
}

console.log(describe({ kind: 'circle', radius: 2 }));

go deeper

for a junior

Know that Record over a union of string literal tags requires a property for every tag, so an object literal missing one will not compile. Recognise the missing-property error message.

for a middle

Explain that Shape['kind'] yields the tag union, that the resulting type has concrete required properties rather than an index signature, and that the error lands at the table declaration whether or not it is used.

for a senior

Weigh the two dispatch shapes in a real codebase: where the error surfaces, whether handlers need member-specific payloads, eager construction costs, and what happens when an unknown tag reaches the lookup at runtime.

for a principal

Own the convention across modules — one table versus many switches changes who breaks when a shared union grows, and whether extending that union is a safe, mechanically-verified change for every consuming team.

## The technique Instead of branching, you build a table keyed by the discriminant and look the handler up: ```ts type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number }; const label: Record<Shape['kind'], (s: Shape) => string> = { circle: (s) => 'circle', square: (s) => 'square', }; function describe(shape: Shape): string { return label[shape.kind](shape); } ``` `Shape['kind']` is an indexed access over the union, which yields the union of tag literals `'circle' | 'square'`. Feeding that to `Record` produces `{ circle: (s: Shape) => string; square: (s: Shape) => string }` — two **required** properties. The exhaustiveness enforcement follows straight from that: an object literal that omits `square` is not assignable to the annotated type. ## Where the error appears, and why that matters ``` Property 'square' is missing in type '{ circle: (s: Shape) => string; }' but required in type 'Record<"circle" | "square", (s: Shape) => string>'. ``` The diagnostic points at the table's initialiser and names the missing key. Three consequences follow: - **Declaration-time, not use-time.** The table is a value; adding a union member breaks it immediately, even in a module nobody imports yet. - **Call-independent.** With a switch, the error is at the dispatch site, so a dispatch guarded behind a feature flag or hidden in dead code still has to be compiled to matter. The table breaks regardless. - **One place per dispatch strategy.** If four call sites all route through the same table, one error appears instead of four — pleasant for a small union, less informative if the four sites genuinely need different handling. The symmetric case is also covered: adding a key that is not a member of the union triggers excess-property checking on the object literal, so a typo'd or stale tag is rejected rather than silently ignored. ## Indexing is type-safe here A point worth making because it trips people up: `Record<'circle' | 'square', F>` is **not** an index signature. It expands into concrete, named properties. So `label[shape.kind]` where `shape.kind` has type `'circle' | 'square'` yields `F`, not `F | undefined`, and the `noUncheckedIndexedAccess` flag — which adds `undefined` to index-signature reads — does not apply. You can call the result directly without a non-null assertion. That only holds while the key type is the finite union. `Record<string, F>` *is* an index signature and behaves entirely differently. ## What the table gives up The honest weakness is per-case narrowing. In the type above, every handler is declared `(s: Shape) => string`, so inside the `circle` handler the parameter is still the whole union and `s.radius` does not type-check without narrowing again. A switch gives you the narrowed member for free in each case. The fix is to describe the table with a mapped type over the tags so each handler receives the member matching its own key, extracting it from the union by its tag. That recovers narrowing at the cost of a more advanced type expression that every reader of the file has to parse. Whether that trade is worth it is a judgment call: for handlers that only need shared fields, plain `Record` is fine; for handlers that use member-specific payloads, either use the mapped form or go back to a switch. Secondary costs: the table is constructed eagerly at module load, which matters if handlers close over expensive resources; fallthrough between cases is not expressible; and the dispatch is a property lookup, so a `kind` value that escapes the type system at runtime yields `undefined` and a "not a function" TypeError rather than a message you wrote. If that risk is real, validate the tag at the boundary or check the lookup before calling. ## Comparing the two techniques | | switch + assertNever | Record table | |---|---|---| | Error site | the dispatch that fell behind | the table's declaration | | Fires when uncalled | no | yes | | Per-case narrowing | yes, automatic | only with a mapped type | | Runtime code added | a helper that throws | none | | Names the missing case | in the argument type | in the missing-property message | Neither is universally better. Interviewers ask this to hear whether you know that exhaustiveness is a *property you can get from several different type-system mechanisms*, and whether you can say what each one costs. ## What to say Lead with the mechanism — `Record` over a finite literal union produces required properties — then the error location and its declaration-time nature, then the narrowing limitation. A candidate who only says "you can use an object map instead" has described the shape, not the checking.

  • Why is `table[shape.kind]` not possibly undefined, even with noUncheckedIndexedAccess on?
    Because `Record` over a finite union of literals expands to concrete named properties, not an index signature, and that flag only adds `undefined` to index-signature reads. The key expression is typed as exactly that union, so the lookup resolves to a declared property. `Record<string, F>` would be an index signature and would behave differently.
  • What does the Record table lose compared with a switch, and how would you recover it?
    Per-case narrowing: every handler is typed against the whole union, so member-specific fields are not accessible without narrowing again inside the handler. You recover it by describing the table with a mapped type over the tags so each key's handler receives the member with that tag, at the cost of a denser type expression.
  • What happens at runtime if a value with an unrecognised tag reaches the lookup?
    The property read returns `undefined` and the call throws a TypeError about undefined not being a function — a stack trace that does not say which tag was wrong. Types are erased, so nothing checks the tag. If untrusted data feeds the dispatch, validate it at the boundary or check the lookup result before invoking it.

saying these in an interview costs you the question

  • Thinking the error appears where the table is indexed
  • Believing noUncheckedIndexedAccess adds undefined to the lookup
  • Expecting each handler's parameter to be narrowed automatically
  • Assuming a missing key only fails when that tag arrives
  • Typing the table as Record<string, Handler> and expecting a check

context