TypeScript

Error Handling in TypeScript

35 min Lesson 21 of 40

Error Handling in TypeScript

Error handling is a critical aspect of building robust applications. TypeScript provides powerful tools for handling errors in a type-safe manner, going beyond JavaScript's traditional try-catch mechanisms. In this lesson, we'll explore typed errors, the Result type pattern, custom error classes, and error boundaries for creating resilient applications.

Understanding Typed Errors

In JavaScript, the catch clause catches values of type any. TypeScript 4.4+ introduced the unknown type for catch clause variables, making error handling safer:

// Traditional approach (unsafe) try { riskyOperation(); } catch (error) { // error is 'any' by default console.log(error.message); } // Modern TypeScript (safe) try { riskyOperation(); } catch (error: unknown) { if (error instanceof Error) { console.log(error.message); } else { console.log('Unknown error:', error); } } // With type guard helper function isError(error: unknown): error is Error { return error instanceof Error; } try { riskyOperation(); } catch (error) { if (isError(error)) { console.log(error.message); console.log(error.stack); } }
Note: Always use unknown instead of any for catch clause variables. This forces you to check the error type before using it, preventing runtime errors.

Custom Error Classes

Creating custom error classes allows you to define specific error types with additional context and type-safe handling:

// Base application error class AppError extends Error { constructor( message: string, public readonly code: string, public readonly statusCode: number = 500 ) { super(message); this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); } } // Specific error types class ValidationError extends AppError { constructor( message: string, public readonly fields: Record<string, string[]> ) { super(message, 'VALIDATION_ERROR', 400); } } class NotFoundError extends AppError { constructor( resource: string, public readonly id: string | number ) { super(\`${resource} not found\`, 'NOT_FOUND', 404); } } class UnauthorizedError extends AppError { constructor(message: string = 'Unauthorized access') { super(message, 'UNAUTHORIZED', 401); } } // Usage with type guards function handleError(error: unknown): void { if (error instanceof ValidationError) { console.log('Validation failed:', error.fields); } else if (error instanceof NotFoundError) { console.log(\`Resource ${error.id} not found\`); } else if (error instanceof UnauthorizedError) { console.log('Access denied'); } else if (error instanceof AppError) { console.log(\`Error ${error.code}:\`, error.message); } else { console.log('Unknown error'); } } // Example usage function validateUser(data: unknown): void { throw new ValidationError('Invalid user data', { email: ['Email is required', 'Email format is invalid'], password: ['Password must be at least 8 characters'] }); } function getUser(id: number): void { throw new NotFoundError('User', id); }
Tip: Always call Error.captureStackTrace in custom error constructors to maintain accurate stack traces for debugging.

The Result Type Pattern

The Result type pattern is an alternative to throwing errors, providing explicit success/failure handling at compile time:

// Result type definition type Result<T, E = Error> = | { success: true; value: T } | { success: false; error: E }; // Helper functions function ok<T>(value: T): Result<T, never> { return { success: true, value }; } function err<E>(error: E): Result<never, E> { return { success: false, error }; } // Usage example function parseJSON<T>(json: string): Result<T, Error> { try { const value = JSON.parse(json) as T; return ok(value); } catch (error) { return err(error instanceof Error ? error : new Error(String(error))); } } // Pattern matching with Result function processResult<T>(result: Result<T>): void { if (result.success) { console.log('Success:', result.value); } else { console.log('Error:', result.error.message); } } // Real-world example interface User { id: number; name: string; email: string; } function fetchUser(id: number): Result<User, NotFoundError> { const user = database.find(id); if (user) { return ok(user); } return err(new NotFoundError('User', id)); } // Chaining Results function map<T, U, E>( result: Result<T, E>, fn: (value: T) => U ): Result<U, E> { if (result.success) { return ok(fn(result.value)); } return result; } function flatMap<T, U, E>( result: Result<T, E>, fn: (value: T) => Result<U, E> ): Result<U, E> { if (result.success) { return fn(result.value); } return result; } // Usage const userResult = fetchUser(123); const emailResult = map(userResult, user => user.email); const upperEmailResult = map(emailResult, email => email.toUpperCase());
Note: The Result pattern makes error handling explicit in function signatures, forcing callers to handle both success and failure cases.

Option Type for Nullable Values

The Option type is similar to Result but specifically handles the presence or absence of a value:

// Option type definition type Option<T> = Some<T> | None; interface Some<T> { readonly kind: 'some'; readonly value: T; } interface None { readonly kind: 'none'; } // Constructors function some<T>(value: T): Option<T> { return { kind: 'some', value }; } function none(): Option<never> { return { kind: 'none' }; } // Helper functions function isSome<T>(option: Option<T>): option is Some<T> { return option.kind === 'some'; } function isNone<T>(option: Option<T>): option is None { return option.kind === 'none'; } function getOrElse<T>(option: Option<T>, defaultValue: T): T { return isSome(option) ? option.value : defaultValue; } function mapOption<T, U>( option: Option<T>, fn: (value: T) => U ): Option<U> { return isSome(option) ? some(fn(option.value)) : none(); } // Usage example function findUserById(id: number): Option<User> { const user = database.find(id); return user ? some(user) : none(); } const userOption = findUserById(123); const userName = getOrElse( mapOption(userOption, user => user.name), 'Anonymous' );

Error Boundaries Pattern

For React applications with TypeScript, error boundaries provide a way to catch errors in component trees:

import React, { Component, ErrorInfo, ReactNode } from 'react'; interface Props { children: ReactNode; fallback?: ReactNode; onError?: (error: Error, errorInfo: ErrorInfo) => void; } interface State { hasError: boolean; error?: Error; } class ErrorBoundary extends Component<Props, State> { constructor(props: Props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { console.error('Error caught by boundary:', error); console.error('Component stack:', errorInfo.componentStack); this.props.onError?.(error, errorInfo); } render(): ReactNode { if (this.state.hasError) { return this.props.fallback || ( <div> <h2>Something went wrong</h2> <p>{this.state.error?.message}</p> </div> ); } return this.props.children; } } // Usage function App(): JSX.Element { return ( <ErrorBoundary fallback={<ErrorFallback />} onError={(error, errorInfo) => { logErrorToService(error, errorInfo); }} > <MainContent /> </ErrorBoundary> ); }

Discriminated Union Errors

Use discriminated unions to create type-safe error hierarchies without classes:

// Error types as discriminated union type ApiError = | { type: 'network'; message: string; retryable: true } | { type: 'validation'; fields: Record<string, string[]> } | { type: 'unauthorized'; requiredRole: string } | { type: 'notFound'; resource: string; id: string } | { type: 'server'; statusCode: number; message: string }; // Error handlers with exhaustive checking function handleApiError(error: ApiError): string { switch (error.type) { case 'network': return \`Network error: ${error.message}. Retrying...\`; case 'validation': return \`Validation failed: ${Object.keys(error.fields).join(', ')}\`; case 'unauthorized': return \`Access denied. Required role: ${error.requiredRole}\`; case 'notFound': return \`${error.resource} with id ${error.id} not found\`; case 'server': return \`Server error (${error.statusCode}): ${error.message}\`; default: // TypeScript ensures this is never reached const _exhaustive: never = error; return _exhaustive; } } // Factory functions for creating errors const ApiErrors = { network: (message: string): ApiError => ({ type: 'network', message, retryable: true }), validation: (fields: Record<string, string[]>): ApiError => ({ type: 'validation', fields }), unauthorized: (requiredRole: string): ApiError => ({ type: 'unauthorized', requiredRole }), notFound: (resource: string, id: string): ApiError => ({ type: 'notFound', resource, id }), server: (statusCode: number, message: string): ApiError => ({ type: 'server', statusCode, message }) }; // Usage const error = ApiErrors.validation({ email: ['Invalid format'], password: ['Too short'] }); console.log(handleApiError(error));
Warning: Never use any for error types. Always use unknown and type guards to ensure type safety in error handling.

Async Error Handling

Combining Result types with async functions provides robust error handling:

// Async Result type type AsyncResult<T, E = Error> = Promise<Result<T, E>>; // Helper to wrap async operations async function tryCatch<T>( fn: () => Promise<T> ): AsyncResult<T> { try { const value = await fn(); return ok(value); } catch (error) { return err(error instanceof Error ? error : new Error(String(error))); } } // Usage example async function fetchUserSafely(id: number): AsyncResult<User, ApiError> { const result = await tryCatch(async () => { const response = await fetch(\`/api/users/${id}\`); if (!response.ok) { throw ApiErrors.server(response.status, response.statusText); } return response.json(); }); if (!result.success) { if (result.error.message.includes('network')) { return err(ApiErrors.network(result.error.message)); } return err(ApiErrors.server(500, result.error.message)); } return ok(result.value); } // Chaining async operations async function getUserEmailSafely(id: number): AsyncResult<string, ApiError> { const userResult = await fetchUserSafely(id); if (!userResult.success) { return userResult; } return ok(userResult.value.email); }
Exercise:
  1. Create a custom error hierarchy for a blog application with errors for: PostNotFound, UnauthorizedEdit, InvalidContent, and PublishError.
  2. Implement a Result-based API client that handles network errors, validation errors, and server errors.
  3. Create an Option-based cache system with get, set, and delete methods.
  4. Build a type-safe error logger that categorizes errors by severity (info, warning, error, critical) using discriminated unions.
  5. Implement an ErrorBoundary component with TypeScript that logs errors to an external service and shows different fallback UIs based on error type.

Summary

TypeScript error handling goes far beyond try-catch blocks. By leveraging typed errors, custom error classes, the Result and Option patterns, and discriminated unions, you can build applications with explicit, type-safe error handling. These patterns force you to handle errors at compile time, reducing runtime surprises and making your code more maintainable and robust.