How do you make the compiler fail when somebody adds a new variant to a union you switch on?
Narrow the discriminant in a switch and pass the fully narrowed value to a helper taking never, so an unhandled variant is no longer assignable and the build breaks at the place that forgot it rather than at runtime.
What the interviewer is scoring
- Does the candidate explain exhaustiveness as narrowing to never rather than as a lint rule
- Whether the annotated return type is identified as what converts a missing case into an error
- That a throwing default clause is recognised as trading the compile-time check for a runtime one
- Whether a lookup table keyed by the discriminant is offered as exhaustiveness by construction
- Does the answer account for never collapsing silently inside unions and conditional types
Answer
The failure you are trying to buy insurance against
A union of shapes discriminated by a literal field is the standard way to model a value that comes in several forms, and it works well until the union grows. Someone adds a fifth variant to the type, fixes the three places the compiler complained about, ships, and the two places that silently fell through to a default branch now render nothing or log a warning nobody reads. The type was updated and the code that consumes it was not, which is precisely the class of mistake a type system should be able to catch.
What makes it catchable is never. It is the bottom type: nothing is assignable to it except never itself. So if you can get the compiler to a point where a value's narrowed type is never, you have proved every possibility was handled, and if a new variant appears the narrowed type is no longer never and the assignment fails.
Narrowing, then a parameter of type never
Inside a switch on the discriminant, each case narrows the value to the matching member. After the last handled case, the remaining type is whatever is left over, which for a fully handled union is never. Pass that leftover to a function whose parameter is never and the call only compiles while nothing is left.
type Shape =
| { kind: 'circle'; r: number }
| { kind: 'square'; side: number };
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
// The annotated return type is what makes a missing case an error
// rather than a quietly widened union including undefined.
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle': return Math.PI * shape.r ** 2;
case 'square': return shape.side ** 2;
// Add a third variant to Shape and this line stops compiling.
default: return assertNever(shape);
}
}
Two details in that snippet do the real work, and both are easy to leave out. The return type annotation is one: without it the compiler infers the return type from the branches that exist, a missing case simply widens the result to include undefined, and the error appears at some call site later or not at all. The other is that the helper's parameter must be never and not unknown or the union type, because only never rejects a leftover variant.
This is also the answer to why the check evaporates when someone rewrites the switch as a chain of if statements with early returns. Narrowing works in that form too, but only if the chain genuinely ends in an else; a sequence of if (x.kind === ...) return with nothing after it has an implicit fall-through that the compiler treats as returning undefined, and there is no expression left in which to observe the never. Turning on noImplicitReturns closes that specific gap.
The default clause that throws away the guarantee
Here is where teams undo the protection while believing they have strengthened it. Adding a default branch that throws a runtime error, or that returns a fallback value, satisfies the compiler about the return type in every case, so the missing variant no longer produces a compile error. What you now have is the same bug detected in production instead of in the build, on whichever code path a user happens to take first.
The distinction worth stating out loud is that assertNever is not a runtime guard that happens to be typed. Its value is entirely in the parameter type, and the throw is only there for the case where an unexpected discriminant arrives from outside the type system, from a JSON payload or a persisted record written by an older version of the code. If a fallback is genuinely the right product behaviour for an unknown variant, keep the fallback and put the exhaustiveness check somewhere else, so you still get told.
Exhaustiveness by construction
The switch pattern is per call site, which means every consumer of the union has to remember to do it. Where the mapping is data rather than logic, a table keyed by the discriminant gives you the same guarantee from a single declaration, because a Record over the union of keys requires every key to be present and the object literal errors at its own definition rather than at some distant use.
// Missing or misspelled keys are an error here, at the definition.
const icons: Record<Shape['kind'], string> = { circle: 'circle-icon', square: 'square-icon' };
That is often the better structure for renderers, label lookups and permission maps, and it moves the failure to a place that is easy to fix: one file that lists the variants next to each other.
Why never sometimes says nothing at all
The property that makes never useful also makes it quiet, and this is the part that separates a fluent answer from a memorised one. never is the empty union, so it disappears when combined with anything: string | never is just string. That is deliberate and it is what lets Exclude filter union members. But it means a mistake in a conditional or mapped type that produces never does not announce itself. A property whose computed type came out as never accepts no value at all, and an intersection of incompatible primitives reduces to never in the same silent way, so the error you eventually see is a confusing complaint at an assignment rather than at the type definition that went wrong.
The practical habit is to look at the resolved type when an assignment is rejected for no apparent reason. If the target turns out to be never, the fault is upstream in the type-level code, not in the value you are trying to assign.
Exhaustiveness is not a lint rule, it is an assignability check: narrow the discriminant until nothing is left, hand the nothing to a parameter typed
never, and annotate the return type so the compiler has to care.
Likely follow-ups
- The variants come from a string enum rather than literal types. What changes about any of this?
- A colleague rewrites the switch as an if-else chain and the check stops working. Why?
- How do you get a comparable guarantee for a discriminant that arrived from an API and might be a variant you have never heard of?
- Would you rather the failure appeared where the handler table is defined or at the call site, and why?
Related questions
- How do you use TypeScript's type system to make invalid states unrepresentable in a service?mediumAlso on discriminated-unions and exhaustiveness4 min
- You pass an object with extra properties to something typed to accept fewer. When does TypeScript complain, and when does it not?mediumAlso on typescript and type-safety4 min
- How do you use TypeScript to make a component's props hard to misuse?mediumAlso on typescript and discriminated-unions4 min
- Your endpoint takes a typed request body and it still crashed on a missing field. How is that possible when it compiled?mediumAlso on typescript4 min
- Where does TypeScript stop protecting you, and what do you do at those points?hardAlso on typescript4 min
- A list of a million small objects uses far more memory than the data inside them. Where is it going?mediumSame kind of round: concept5 min
- A promise rejects and nothing is awaiting it. What does Node do?hardSame kind of round: concept4 min
- You kick off background work from a controller action and it throws once the response has gone out. What is happening?mediumSame kind of round: coding5 min