Difficulty: Advanced
Exhaustive checking is a technique that ensures every possible variant of a union type is handled in your code. The key insight is that if you narrow a union type through all its members, the remaining type should be `never` - meaning no possible value is left. By assigning the remaining value to a variable of type `never`, you create a compile-time error if any variant is unhandled.
The pattern works with switch statements, if/else chains, or any control flow that narrows a union. In the default case of a switch, if all variants are covered, the discriminant variable has type `never`. If you add a new variant to the union but forget to add a case, the variable will not be `never`, and the assignment to a `never` variable will fail to compile. This turns a potential runtime bug into a guaranteed compile error.
You can also create a helper function like `assertNever(value: never): never` that throws at runtime while also serving as a compile-time exhaustiveness check. This provides both compile-time safety (the type system catches missing cases) and runtime safety (an error is thrown if an unexpected value somehow reaches the default case).
The `satisfies` operator, introduced in TypeScript 4.9, is a different but related concept. It validates that an expression conforms to a type without widening it. The syntax `expression satisfies Type` checks that the expression matches the Type while preserving the expression's own narrower type for inference. This is useful when you want type checking on an object literal but don't want to lose the specific literal types of its properties.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.round(Math.PI * shape.radius 2 * 100) / 100;
case "square":
return shape.side 2;
case "triangle":
return (shape.base * shape.height) / 2;
default:
return assertNever(shape);
}
}
console.log(area({ kind: "circle", radius: 3 }));
console.log(area({ kind: "square", side: 5 }));
console.log(area({ kind: "triangle", base: 4, height: 6 }));
In the default branch, shape has type `never` because all variants are handled. If a new variant were added without a case, the code would not compile.
type ColorConfig = Record<string, string | number[]>;
const palette = {
red: "#ff0000",
green: [0, 255, 0],
blue: "#0000ff"
} satisfies ColorConfig;
// palette.red is still typed as string (not string | number[])
console.log(palette.red.toUpperCase());
// palette.green is still typed as number[] (not string | number[])
console.log(palette.green.length);
console.log(palette.blue.startsWith("#"));
Without `satisfies`, typing the object as ColorConfig would widen each property to `string | number[]`. With `satisfies`, TypeScript validates the shape while preserving the narrow literal types.
type Status = "pending" | "active" | "closed";
const statusLabels: Record<Status, string> = {
pending: "Pending Review",
active: "Currently Active",
closed: "Closed"
};
// Record<Status, string> already ensures all keys are present
// If a new Status is added, this object literal will error
function getLabel(status: Status): string {
return statusLabels[status];
}
const statuses: Status[] = ["pending", "active", "closed"];
statuses.forEach(s => console.log(getLabel(s)));
Using Record<Status, string> as the type of a config object is another form of exhaustive checking - TypeScript requires an entry for every member of the Status union.
never type, exhaustive switch, satisfies operator, compile-time safety