skip to content

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%

answer

  1. a claim about the declaration, not the data
  2. the cast was a promise, not a check
  3. erased types enforce nothing at runtime
  4. the branch is ordinary emitted JavaScript
  5. log the tag, then fail deliberately

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.

solid answer

~40 s

`never` in the `default` branch is a statement about the *declared* type, not about the bytes arriving over the wire. The union was asserted onto `JSON.parse` output, and an assertion performs no check — so the compiler's belief that every possible tag is handled rests entirely on a promise nobody verified. After compilation the types are gone; the emitted JavaScript is a plain `switch` whose `default` clause is a perfectly ordinary, reachable path. When the producer ships a new message type, that branch runs. So write it as real code: capture the offending tag, log or report it with enough context to identify the sender, and either throw or route the message to a quarantine path rather than silently dropping it. Keep the compile-time exhaustiveness check as well — the two guard different failures.

go deeper

for a junior

Remember that TypeScript types disappear when the code is compiled, so a branch the compiler calls impossible is still ordinary JavaScript that can run.

for a middle

Explain that narrowing to never is derived from the declared type, and that a cast onto parsed data makes that declaration an unverified assumption rather than a fact.

for a senior

Show the operational judgement: log the actual tag with correlation context, choose between throwing and quarantining based on how the consumer is deployed, and keep both the compile-time and runtime checks because they catch different failures.

for a principal

Own the boundary policy — where untrusted data is allowed to acquire a type, what a violated invariant should do to the process, and how protocol drift between independently deployed producers and consumers is detected and rolled out.

## Two different claims wearing the same word There is a type-level `never` and a runtime notion of "unreachable", and this question is about the gap between them. The type-level claim is: *given the declared type of this value, no member survives to the default branch.* It is derived, mechanically and correctly, from the declaration. The runtime question is entirely different: *can control actually arrive here when the program runs?* The compiler answers the first question and says nothing at all about the second, because it has no access to the data. ## Where the untruth enters The union came from a cast: ```ts type Message = | { kind: 'ping' } | { kind: 'text'; body: string }; const message = JSON.parse(raw) as Message; ``` `JSON.parse` returns `any`. The assertion does not inspect the object, coerce it, or validate it — it instructs the checker to stop asking. From that line onward the compiler reasons about a `Message`, and every conclusion it draws downstream, including "the default branch is `never`", is conditional on an assumption that was never tested. If the server starts sending `{ kind: 'binary', data: ... }`, the type is simply wrong about the program. ## What the emitted code does Types are erased. The compiler emits: ```js switch (message.kind) { case 'ping': /* ... */ break; case 'text': /* ... */ break; default: /* still here, still reachable */ } ``` No tag check is inserted, the `default` clause is not eliminated as dead code, and a helper variable declared as `never` compiles to an ordinary assignment. So the branch is exactly as reachable as any other `default` in JavaScript. ## How to write the branch Treat it as a real error path, not a formality: - **Capture the tag.** The value's static type is `never`, so read the discriminant off a widened view of it before logging — for example through a `Record<string, unknown>` or an `unknown`-typed alias — and include the raw tag in the message. "Unhandled message" without the tag wastes the incident. - **Fail loudly by default.** Throwing surfaces the mismatch immediately, at the boundary, with a stack that names the switch. Silently continuing turns a protocol drift into corrupted state discovered days later. - **Decide the operational policy explicitly.** For a request handler, throwing and returning a 4xx/5xx is usually right. For a durable queue consumer, throwing may mean an infinite redelivery loop, so quarantining the message and alerting is often better than crash-looping. - **Include correlation context.** Sender, message id, schema version if you have one. The point of this branch firing is to tell you *who* is ahead of you. ## Keep both checks — they catch different bugs The compile-time exhaustiveness check catches **your** code falling behind **your** type: someone extends the union and forgets a switch. The runtime branch catches **the world** falling ahead of your type: a producer sends something your declaration never described. Neither substitutes for the other, and a codebase that removes the runtime branch because "the compiler proved it cannot happen" has confused a proof about the declaration with a proof about the data. ## The real fix is upstream The deeper problem is not the switch, it is that untrusted input entered the type system by assertion. Anywhere a value crosses a trust boundary — network, storage, message bus, user input — the type it receives should be earned by checking the data rather than asserted onto it. Once that boundary is honest, the `default` branch becomes a genuine invariant violation rather than an expected occurrence, and throwing from it is unambiguously correct. ## What interviewers listen for The strong answer connects three things without being prompted: erasure means no runtime enforcement; assertions are promises rather than checks; therefore an "impossible" branch is an operational path that deserves logging, alerting and a deliberate failure policy. The weak answer says the branch cannot run because the type is `never` — which is exactly the confusion the question is probing.

  • The value's static type in that branch is `never`, so how do you even log the tag that arrived?
    Read it through a widened view rather than off the `never` value directly — assign the switched expression to an `unknown` or `Record<string, unknown>` alias before the switch, or capture the discriminant into a local at the top. You want the raw tag in the log, so grab it while it still has a usable type.
  • Would the same risk exist if the union came from a checked source instead of a cast?
    The runtime branch is still emitted and still reachable, but it stops being *expected*. If the value's type was earned by inspecting the data, reaching the default means a genuine invariant violation, and throwing is unambiguously the right response rather than a judgement call.
  • Is throwing always the right thing to do in that branch?
    No. In a request handler, throwing surfaces the problem immediately and cleanly. In a durable queue consumer it can produce a redelivery loop that takes the consumer down, so quarantining the message with an alert is usually better. The policy should be chosen deliberately, not inherited from a snippet.

saying these in an interview costs you the question

  • Says the branch cannot run because the type is never
  • Claims the compiler removes never branches as dead code
  • Thinks a cast validates the parsed payload
  • Says exhaustiveness makes runtime validation unnecessary
  • Leaves the branch empty because it is unreachable

context