How it works
To narrow shape.kind === "circle", the checker treats kind as a discriminant property of the union, then filters the members: the true branch keeps those whose kind can be "circle", the false branch drops those whose kind is exactly "circle". A switch applies the same filter per case. (Mechanism as described in the handbook; I did not trace the checker source for this one: ❓ for exact discriminant rules.)
Exhaustiveness is the same filtering run to completion. After "circle" and "square" are removed, only the triangle member remains in default; if all are removed, shape is never, and never is assignable to everything while only never is assignable to it. So the assignment either compiles (nothing left) or fails with the leftover member in the message. That never rule is covered in the any/unknown/never question.
Edge cases
Three exhaustiveness spellings, and one case where the tag does not discriminate. Each error below was seen on 7.0.2 and 5.9.3.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
// No default: the return type does the checking.
function areaA(shape: Shape): number {
// Error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
// (reported on the return type; the switch is not exhaustive)
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
}
}
// `satisfies never` (4.9+) checks without a helper function.
function areaB(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
default:
shape satisfies never;
// Error TS1360: Type '{ kind: "triangle"; base: number; height: number; }' does not satisfy the expected type 'never'.
return 0;
}
}
// A discriminant typed as `string` does not discriminate.
type Wide = { kind: string; radius: number } | { kind: string; side: number };
function wide(shape: Wide) {
if (shape.kind === "circle") return shape.radius;
// Error TS2339: Property 'radius' does not exist on type 'Wide'.
}
satisfies never needs TS 4.9+; assertNever works on all versions and adds a runtime throw.
- The return-type form has a gap: it only detects a missing case when the function must return a value. A
void function with a missing case compiles.
- Narrowing needs the property to be a literal in the declared type. A value that widened to
string (for example from a let or an un-as const object) loses it. See the inference question.
Real-world scenario
Async UI state and a reducer. Two unions, two exhaustive switches. Adding a "cancel" action or a "stale" status makes both functions fail to compile until handled, instead of breaking at runtime in one screen.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; items: string[] }
| { status: "error"; message: string };
type Action =
| { type: "fetch" }
| { type: "resolve"; items: string[] }
| { type: "reject"; message: string };
function assertNever(value: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "fetch": return { status: "loading" };
case "resolve": return { status: "success", items: action.items };
case "reject": return { status: "error", message: action.message };
default: return assertNever(action);
}
}
function label(state: State): string {
switch (state.status) {
case "idle": return "Nothing yet";
case "loading": return "Loading...";
case "success": return `${state.items.length} items`;
case "error": return `Failed: ${state.message}`;
}
}
let state: State = { status: "idle" };
state = reducer(state, { type: "fetch" });
console.log(label(state)); // → Loading...
state = reducer(state, { type: "resolve", items: ["a", "b"] });
console.log(label(state)); // → 2 items
Verified on 7.0.2 and 5.9.3 and run on Node 24. label has no default: its annotated string return type enforces completeness through TS2366.
Trade-offs
Discriminated unions make illegal states unrepresentable (no success without items) at the cost of more type declarations and a switch at each use site. For a handful of variants this is the right default. For many variants with shared behavior, polymorphism (classes with methods) keeps new cases in one place instead of touching every switch, at the cost of losing the compiler's exhaustiveness proof. The tag costs a few bytes at runtime, which matters only when you serialize it.