What is exhaustive type checking?
Exhaustive type checking is a powerful technique in TypeScript that ensures all possible cases of a given type, typically a discriminated union or an enum, are explicitly handled. This helps prevent runtime errors and improves the robustness and maintainability of your code by catching unhandled scenarios at compile time.
What is Exhaustive Type Checking?
It refers to the practice of writing code in a way that the TypeScript compiler can verify that every possible variant of a type (like members of a union or an enum) has been accounted for. If a new variant is introduced to the type later, the compiler will flag any code that doesn't handle it, prompting you to update your logic.
Why is it Useful?
- Prevents Runtime Errors: Ensures that no valid case of a type is accidentally left unhandled, which could lead to unexpected behavior or crashes at runtime.
- Improves Code Robustness: Makes your application more resilient to changes in data structures.
- Aids Refactoring: When you modify a union type or an enum by adding a new member, the TypeScript compiler will automatically highlight all places where that new member needs to be handled, acting as a compile-time safety net.
- Better Readability: Explicitly handling all cases can make the intent of your code clearer.
How to Implement it with `never`
The most common and effective way to achieve exhaustive type checking in TypeScript is by leveraging the never type. The never type represents the type of values that never occur. TypeScript's control flow analysis is smart enough to realize that if all possible cases of a union have been handled, the remaining value in a default or final else branch must be never.
We typically define a helper function that takes a never type as an argument. If, for some reason, an unhandled value makes it to this function, it means the value was not never, and TypeScript will report a type error.
function assertNever(x: never): never {
throw new Error(`Unexpected object: ${x}`);
}
type Circle = { kind: "circle"; radius: number; };
type Square = { kind: "square"; sideLength: number; };
type Triangle = { kind: "triangle"; base: number; height: number; };
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
// If we comment out 'case "triangle"', TypeScript will error on assertNever(shape)
case "triangle":
return (shape.base * shape.height) / 2;
default:
// This line will cause a compile-time error if a new Shape type is added
// and not handled above.
return assertNever(shape);
}
}
Explanation of the Example
- We define a discriminated union
Shapewithkindas the discriminant property. - The
getAreafunction uses aswitchstatement to narrow down theshapetype based on itskindproperty. - The
defaultcase callsassertNever(shape). If all possiblekindvalues are handled by thecasestatements, TypeScript's control flow analysis determines thatshapein thedefaultbranch must be of typenever(because it's impossible to reach that branch with any of theShapetypes). - If, for example, we remove the
case "triangle"branch, TypeScript will detect thatshapein thedefaultbranch could still be aTriangle. SinceTriangleis not assignable tonever,assertNever(shape)will produce a compile-time error, reminding us to handle theTrianglecase.
Beyond Discriminated Unions
While most commonly used with discriminated unions, exhaustive checking can also be applied to enums or other scenarios where you want to ensure all possible values of a type are explicitly handled. The core principle remains the same: use never to ensure type safety through compiler checks.