Exhaustive assertNever Switch
Discriminated unions are TypeScript's superpower for state machines, redux reducers, and API response shapes, but a `switch` over them silently goes stale the moment a new variant appears. The `assertNever` helper flips silent staleness into a compile error: any unhandled branch means the type passed in is not actually `never`. This snippet covers the helper, a reducer that uses it for compile-time safety, and an interim `default` strategy for production safety while you iterate.
894 views
14
function assertNever(value: never): never {
throw new Error(`unhandled variant ${JSON.stringify(value)}`);
}
type Shape = { kind: 'circle'; r: number } | { kind: 'square'; s: number };
function area(shape: Shape): number {
if (shape.kind === 'circle') return Math.PI * shape.r * shape.r;
if (shape.kind === 'square') return shape.s * shape.s;
return assertNever(shape);
}
const c = { 'kind': 'circle', 'r': 2 } as Shape;
console.log(area(c).toFixed(2));assertNever accepts a value typed as never and throws at runtime. The compile-time win is that, after every other branch narrows away its share of the union, the leftover variable must be never for the call to type-check. Add a third variant to Shape and TypeScript reports an error at the assertNever(shape) line because shape is no longer never there. Throwing in the body keeps runtime behavior loud (instead of silently returning undefined) so the bug surfaces immediately if exhaustiveness was bypassed via as.
function assertNever(value: never): never {
throw new Error(`unhandled variant ${JSON.stringify(value)}`);
}
type Action = { type: 'inc'; by: number } | { type: 'reset' } | { type: 'set'; value: number };
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'inc':
return state + action.by;
case 'reset':
return 0;
case 'set':
return action.value;
default:
return assertNever(action);
}
}
console.log(reducer(5, { 'type': 'inc', 'by': 3 } as Action));
console.log(reducer(5, { 'type': 'reset' } as Action));switch on the discriminator (action.type) is the most ergonomic shape for a reducer because each case body can use the narrowed action type for free (TypeScript figures out which branch you are in from the literal). Putting assertNever(action) in the default makes the entire reducer exhaustive: add an Action variant and TS reports an error at the default until you handle it. This is the canonical pattern for typed Redux/Zustand stores, parser dispatch tables, and event handlers, and it composes cleanly with branded types and ValueOf constants.
type SoftFallback = () => void;
function assertNeverSoft(value: never, fallback: SoftFallback): void {
if (typeof console !== 'undefined') console.warn('unhandled variant', value);
fallback();
}
type Notify = { kind: 'email'; to: string } | { kind: 'sms'; to: string };
function send(n: Notify): string {
switch (n.kind) {
case 'email':
return `email to ${n.to}`;
case 'sms':
return `sms to ${n.to}`;
default: {
assertNeverSoft(n, () => {});
return 'noop';
}
}
}
console.log(send({ 'kind': 'email', 'to': '[email protected]' } as Notify));Throwing in production is right for invariants you fully control, but for inputs that arrive from the wire (older clients, third-party webhooks, server-deployed-before-client cases), a soft fallback that logs and continues is safer. The assertNeverSoft variant keeps the static-exhaustiveness benefit (the call signature still requires never, so TS catches missing branches at compile time) while letting the runtime degrade gracefully. Use it at trust boundaries; use the throwing version inside pure logic where any unexpected variant truly is a bug.
