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?
answer
- the proof covers code, not data
- what the compiler leaves in the output
- where the value entered the system
- assertions do not check
- fix the boundary, keep the throw
basics
~20 sTypes 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.
solid answer
~50 sThe compile-time check proves that *your code* handles every member of the union you declared. It proves nothing about the values that arrive, because TypeScript erases types and emits no runtime checks. So a tag outside the union got in — typically from a `fetch` response or `JSON.parse` result annotated with `as`, a persisted record written by an older schema version, a value crossing a boundary from untyped JavaScript, or a producer service that shipped a new enum member before the consumer was updated. The fix is at the boundary, not at the helper: parse and validate incoming data into the union before it flows inward, so the type annotation becomes a checked claim rather than a promise. Keep the throw and make it log the offending value and its source; never soften it into a silent default, because that turns a loud, diagnosable failure into corrupted behaviour you will find much later.
code
typescript · 13 linestype Status = 'active' | 'paused';
const STATUSES: readonly Status[] = ['active', 'paused'];
// Boundary check: unknown in, a real union member out (or a loud failure).
function parseStatus(raw: unknown): Status {
if (typeof raw === 'string' && (STATUSES as readonly string[]).includes(raw)) {
return raw as Status;
}
throw new Error(`Unsupported status from API: ${String(raw)}`);
}
console.log(parseStatus('paused'));go deeper
Say that TypeScript types disappear when the code is compiled, so nothing checks incoming data against a union at runtime. Recognise that as asserts a type without verifying it.
Trace the value to its entry point — a parsed response, a stored record, an untyped module — and explain why an exhaustive switch still compiles while such a value exists. Describe validating input before it takes the union type.
Diagnose from the logged value backwards to the boundary, fix there rather than at the dispatch, and reason about deployment skew between producer and consumer. Justify keeping a loud failure over a silent fallback.
Set the contract: who may introduce a new variant, in what deploy order, and what a consumer is required to do with an unrecognised one. Decide where an unknown variant may crash a path and where it must degrade, and make that policy visible in the code.
## Why this is not a contradiction The single most important idea in TypeScript is that the type layer is erased: the compiler checks, then emits JavaScript with the annotations removed. There is no runtime representation of a union, no check that an object tagged `kind: 'circle'` really matches its declared member, and no cost. So the exhaustiveness proof has a precise scope. `assertNever` compiling means *every member of the union as declared in this codebase has a branch*. It does not mean *every value that will reach this function is a member of that union*. The moment those two diverge, the branch the compiler proved unreachable executes, and the helper throws. That throw is the system working as designed — it is the fail-loud half of the helper doing its job. ## Where the rogue value comes from In practice there are four recurring sources, and a good answer names them concretely. **Unvalidated I/O.** `const user = await res.json() as User;` — `json()` returns `any` (or `unknown` in stricter setups), and `as` is an assertion, not a conversion: it performs no check and emits no code. Whatever the server sent is now labelled `User`. Same for `JSON.parse`, `localStorage`, message-queue payloads and query results. **Version skew.** The producer deploys a new tag value; the consumer's union has not been updated and redeployed yet. Every deployment window where two versions run side by side is an opportunity for this. It is also the case that no amount of local type checking can prevent — only a shared contract and a rollout order can. **Persisted history.** A row or document written months ago under a schema that has since dropped or renamed a variant. The union describes what you write today; the store holds everything you ever wrote. **Untyped edges.** A JavaScript module, a loosely-typed third-party package, or an `any` that leaked from a generic and washed the tag out of the type system somewhere upstream. ## Diagnosing the specific incident The first question is *what value*, and that is why the helper's message should stringify its argument: ```ts function assertNever(value: never): never { throw new Error(`Unhandled variant: ${JSON.stringify(value)}`); } ``` With the offending object in the log, the tag identifies the variant, and the surrounding fields usually identify the producer. From there, trace backwards to the boundary where that value entered and look for the `as` assertion or the untyped call that let it in — the fault is almost never in the dispatch itself. ## Hardening, in priority order **1. Validate at the boundary.** Convert unknown input into the union with a function that actually checks, so that the type annotation is earned rather than asserted. A runtime schema validator does this well; a hand-written check works fine for small unions. The shape is: take `unknown`, verify, and return the union type (or fail with a message naming the input). After that single choke point, everything inward can trust its annotations, and the dispatch's exhaustiveness proof becomes meaningful. **2. Ban blind assertions at edges.** Every `as SomeUnion` on I/O is a place where the type system was told to stop asking. Treat those as review-worthy; they are the mechanism by which a compiled-clean program throws in production. **3. Keep the throw, and keep it informative.** Weakening the helper to return a default is the tempting fix and usually the wrong one: it converts a precise crash at the point of contradiction into wrong output that surfaces somewhere else, later, without the offending value in hand. **4. Decide the blast radius deliberately.** There is a legitimate judgment call for user-facing rendering paths, where throwing can take down a whole surface for one unknown variant. A team may choose a variant of the helper that reports the anomaly to monitoring and renders a neutral fallback, while keeping the `never` parameter so the compile-time enforcement is unchanged. The key is that this is a conscious availability trade, made per call path — not a blanket softening of the helper because an alert was noisy. **5. Handle version skew at the protocol level.** If producers can introduce tags unilaterally, the consumer's contract should say what to do with an unrecognised one, and the union should model that explicitly rather than pretend it cannot happen. Rollout order — consumers that understand the new tag deploy before producers emit it — is the operational half of the same problem. ## What separates a senior answer A weaker answer says "someone cast something" and stops. A senior answer states the erasure principle as the root cause, names the concrete entry points, insists the fix belongs at the boundary rather than at the dispatch, and shows judgment about whether the throw should stay a throw in that particular path. It also resists the reflex of widening the helper's parameter type — that removes the compile-time check entirely and leaves you strictly worse off than before the incident.
- Why is widening the helper's parameter to `unknown` the wrong response to this incident?It deletes the only thing the helper contributes at compile time. With `unknown`, every dispatch compiles regardless of unhandled members, so you lose the build-time gap detection and keep the runtime throw you were trying to avoid. The incident was caused by unvalidated input, and the fix belongs at that boundary.
- Is there ever a good reason to make the helper log instead of throw?Yes, in a user-facing rendering path where one unknown variant should not take down the whole surface. Keep the `never` parameter so compile-time enforcement is unchanged, report the value to monitoring, and render a neutral fallback. Make it a per-path availability decision, not a blanket softening because an alert was noisy.
- How does producer-consumer deploy order relate to this failure?If a producer starts emitting a new tag before consumers that understand it are deployed, consumers hit values outside their declared union during the whole rollout window. Deploying tolerant consumers first, or specifying explicitly what an unrecognised tag means, turns a crash into a defined behaviour.
saying these in an interview costs you the question
- Claiming the compiler must have had a bug
- Assuming the union is enforced on incoming JSON
- Widening the helper's parameter to unknown to stop the throw
- Replacing the throw with a silent default value
- Treating `as` as a runtime conversion that validates