🔷 TypeScript Q81 / 101

What is exhaustive type checking?

AI-Powered Answer ✓ Answered

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.

typescript
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 Shape with kind as the discriminant property.
  • The getArea function uses a switch statement to narrow down the shape type based on its kind property.
  • The default case calls assertNever(shape). If all possible kind values are handled by the case statements, TypeScript's control flow analysis determines that shape in the default branch must be of type never (because it's impossible to reach that branch with any of the Shape types).
  • If, for example, we remove the case "triangle" branch, TypeScript will detect that shape in the default branch could still be a Triangle. Since Triangle is not assignable to never, assertNever(shape) will produce a compile-time error, reminding us to handle the Triangle case.

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.