Assign the narrowed value to never in the default branch. Adding a member to the union then breaks the build at every switch that doesn’t handle it.
type Shape = { kind: "circle"; r: number } | { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.r ** 2;
case "square":
return shape.side ** 2;
default: {
const unreachable: never = shape;
return unreachable;
}
}
}
Add { kind: "triangle"; ... } to Shape and the assignment errors with Type '{ kind: "triangle"; ... }' is not assignable to type 'never', naming the case you forgot.
Why it works
Each case narrows shape. By default, the compiler has removed every handled member, and what’s left is never only if nothing remains. A leftover member can’t be assigned to never, so the error appears exactly when the switch stops being exhaustive.
Reuse it as a helper
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
default:
return assertNever(shape);
The throw covers the runtime case where a value from outside the type system (an API response, a database row) carries a kind the code never saw.
Gotchas
- The union needs a discriminant, a shared literal property like
kind. Without one the compiler can’t narrow and the trick errors on the first case. - A
returnin every case is what makes thedefaultreachable for narrowing. If a case falls through or breaks, the narrowing still works but the function’s return type may complain instead. noImplicitReturnsplus aneverdefault is the combination that catches both a missing case and a forgotten return.