Discriminated Unions & Exhaustiveness
Tag each variant with a literal field, then let the never type prove you handled them all.
A discriminated union gives every member a shared literal tag β kind, type, status, whatever reads well. Switching on that tag narrows the union to exactly one shape, and the compiler unlocks that shape's fields.
type Loading = { state: "loading" };
type Success = { state: "success"; data: string };
type Failure = { state: "error"; message: string };
type Result = Loading | Success | Failure;
function render(r: Result) {
switch (r.state) {
case "loading": return "...";
case "success": return r.data; // narrowed to Success
case "error": return r.message; // narrowed to Failure
}
}The exhaustiveness trick
Assign the value to a never in the default branch. If a teammate later adds a fourth variant and forgets to handle it, that assignment fails to compile β your switch becomes self-auditing. This is the single highest-return pattern in day-to-day TypeScript.
Example
When to use it
- A Redux reducer uses a discriminated action union with a never default to catch unhandled action types at compile time.
- A notification system discriminates on a type field to render different components for email, push, and SMS notifications.
- A compiler error formatter uses a discriminated union of error codes so adding a new code forces a handler to be written.
More examples
Exhaustive switch with never
The never-typed default branch causes a compile error if a new Shape variant is added without a matching case.
type Shape = { kind: 'circle'; radius: number }
| { kind: 'rect'; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'rect': return s.w * s.h;
default:
const _exhaustive: never = s;
return _exhaustive;
}
}Discriminated action union
Models a Redux-style reducer with a discriminated union so each action type narrows to its specific payload shape.
type Action =
| { type: 'INCREMENT'; by: number }
| { type: 'DECREMENT'; by: number }
| { type: 'RESET' };
function reducer(count: number, action: Action): number {
switch (action.type) {
case 'INCREMENT': return count + action.by;
case 'DECREMENT': return count - action.by;
case 'RESET': return 0;
}
}Exhaustiveness helper function
Extracts the never assertion into a reusable helper that also throws at runtime, covering both compile-time and runtime safety.
function assertNever(x: never): never {
throw new Error('Unhandled case: ' + JSON.stringify(x));
}
type Fruit = 'apple' | 'banana' | 'cherry';
function describe(f: Fruit): string {
switch (f) {
case 'apple': return 'red';
case 'banana': return 'yellow';
case 'cherry': return 'dark red';
default: return assertNever(f);
}
}
Discussion