Advanced TypeScript Error Handling with Custom Error Classes and Discriminated Unions

Learn how to improve your TypeScript error handling using custom error classes and discriminated unions for more precise and maintainable code.

Error handling is an essential aspect of any software application. TypeScript provides powerful features that can help developers manage errors more effectively, especially when you want precise type checking and clear error categories. In this article, we'll explore how to create custom error classes and use discriminated unions to handle diverse errors gracefully and with type safety.

Custom error classes let you define your own error types with unique properties. This makes errors easier to identify and handle differently based on their type. Discriminated unions allow you to combine multiple error types into one union type with a common discriminant property, enabling TypeScript to narrow down errors accurately during handling.

Let's start by defining two custom error classes: a `NetworkError` to represent network-related issues and a `ValidationError` to represent input validation problems.

typescript
class NetworkError extends Error {
  readonly code: number;

  constructor(message: string, code: number) {
    super(message);
    this.code = code;
    this.name = 'NetworkError';
  }
}

class ValidationError extends Error {
  readonly field: string;

  constructor(message: string, field: string) {
    super(message);
    this.field = field;
    this.name = 'ValidationError';
  }
}

Next, we create a discriminated union type that encompasses both error types. We'll add a `type` property as the discriminant to each error class.

typescript
interface NetworkErrorType {
  type: 'network';
  error: NetworkError;
}

interface ValidationErrorType {
  type: 'validation';
  error: ValidationError;
}

type AppError = NetworkErrorType | ValidationErrorType;

To integrate this discriminant into our custom error classes, we can modify them to include a `type` property directly. Alternatively, we can create wrapper objects like above when throwing or catching errors.

Now let's see how we can use these errors in a function that might fail. We'll simulate a function that either throws a validation error or a network error.

typescript
function fetchData(input: string): string {
  if (input.length < 5) {
    throw new ValidationError('Input too short', 'input');
  }
  if (input === 'network') {
    throw new NetworkError('Network connection failed', 503);
  }
  return 'Data fetched successfully!';
}

When calling this function, we can catch the errors and use TypeScript's type narrowing on our discriminated union to handle each error type appropriately.

typescript
try {
  const result = fetchData('net');
  console.log(result);
} catch (err) {
  if (err instanceof ValidationError) {
    console.error(`Validation error on field: ${err.field}, message: ${err.message}`);
  } else if (err instanceof NetworkError) {
    console.error(`Network error code: ${err.code}, message: ${err.message}`);
  } else {
    console.error('Unknown error:', err);
  }
}

For better type safety, especially when processing many error variants, you can throw wrapped errors that fit the discriminated union and then narrow using the `type` property.

typescript
try {
  const input = 'network';
  if (input.length < 5) {
    throw { type: 'validation', error: new ValidationError('Input too short', 'input') } as AppError;
  }
  if (input === 'network') {
    throw { type: 'network', error: new NetworkError('Network connection failed', 503) } as AppError;
  }
} catch (err) {
  const appError = err as AppError;
  switch (appError.type) {
    case 'validation':
      console.error(`Validation error: ${appError.error.message} on field ${appError.error.field}`);
      break;
    case 'network':
      console.error(`Network error code ${appError.error.code}: ${appError.error.message}`);
      break;
  }
}

Using custom error classes combined with discriminated unions lets you write clear, maintainable, and type-safe error handling code. This approach improves readability and helps catch errors early during development.

Try extending this pattern with your own error types and see how it can simplify managing complex error scenarios in your TypeScript projects!