Understanding TypeScript’s Exhaustiveness Checking for Advanced Error Handling

Learn how TypeScript’s exhaustiveness checking improves error handling by ensuring your code handles all possible cases in advanced scenarios.

When writing error handling code in TypeScript, it's crucial to handle all possible error cases explicitly. Missing a case might lead to unhandled exceptions or unexpected behaviors. TypeScript’s exhaustiveness checking helps you catch these situations early by enforcing that all possible cases in a union type are handled.

Exhaustiveness checking usually comes into play when you use a `switch` statement or conditional checks on union types. It ensures that every variant of the union has been addressed. If one is missed, TypeScript can warn you at compile time, improving the reliability of your code.

Let's look at a practical example involving error handling with a union type representing different error kinds.

typescript
type NetworkError = { type: 'network'; message: string };
type ServerError = { type: 'server'; code: number; message: string };
type ValidationError = { type: 'validation'; field: string; message: string };

type AppError = NetworkError | ServerError | ValidationError;

function handleError(error: AppError) {
  switch (error.type) {
    case 'network':
      console.log('Network issue:', error.message);
      break;
    case 'server':
      console.log(`Server error (code ${error.code}):`, error.message);
      break;
    case 'validation':
      console.log(`Validation failed on ${error.field}:`, error.message);
      break;
    // What if we forget to handle a case?
  }
}

In the example above, suppose we add a new error type in the future but forget to update the `handleError` function. TypeScript won’t immediately warn you because the `switch` statement technically covers all known cases. To catch such mistakes, we add an extra `default` case with a utility called `never` to enforce exhaustiveness.

typescript
function assertNever(x: never): never {
  throw new Error('Unexpected object: ' + x);
}

function handleErrorExhaustive(error: AppError) {
  switch (error.type) {
    case 'network':
      console.log('Network issue:', error.message);
      break;
    case 'server':
      console.log(`Server error (code ${error.code}):`, error.message);
      break;
    case 'validation':
      console.log(`Validation failed on ${error.field}:`, error.message);
      break;
    default:
      // This will cause a compile error if a new case is added and not handled above
      assertNever(error);
  }
}

The `assertNever` function takes an argument of type `never`, which is a special TypeScript type representing values that should never occur. If a new error type is added to `AppError` but not handled in `handleErrorExhaustive`, TypeScript will raise a compile-time error showing you the missing case. This helps prevent silent bugs caused by incomplete error handling.

To summarize, exhaustiveness checking combined with a `never`-type assertion lets you write safer and more maintainable error handling in TypeScript. It ensures all possible error types are handled explicitly, reducing runtime surprises.

Try using exhaustiveness checking with your union types today and enjoy stronger, more reliable TypeScript code!