Skip to content

TypeScript Error Handling — Complete Pattern Guide

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about TypeScript Error Handling. We cover key concepts, practical examples, and best practices to help you master this topic.

TypeScript error handling goes beyond try-catch with typed error classes, discriminated union return types, and the Result pattern — making error handling as type-safe as the rest of your application.

What You'll Learn

  • Typed custom error classes
  • Error-handling with discriminated unions
  • The Result pattern (Either monad)
  • Async error handling
  • Error boundaries in React
  • Logging and error reporting

Why It Matters

Untyped error handling — throwing and catching any — creates silent failures. A function might return null, throw "not found" as a string, or reject with an Error that has no statusCode property. TypeScript forces you to handle every case explicitly.

Real-World Use

The Durga Antivirus Pro license server processes millions of API requests daily with typed error handling. Every API endpoint returns a Result<T, AppError> type, ensuring that validation failures, database errors, and permission issues are handled explicitly — no unhandled errors reach the client.

Learning Path

flowchart LR
  A[SOLID Principles] --> B[Error Handling]
  B --> C[Async Patterns]
  B --> D[You Are Here]
  C --> E[Pattern Matching]
  D --> F[Performance]

Typed Custom Error Classes

Instead of throwing plain strings or generic Error, create typed error classes:

export class AppError extends Error {
  constructor(
    public readonly code: string,
    public readonly statusCode: number,
    message: string,
    public readonly details?: Record<string, unknown>
  ) {
    super(message);
    this.name = 'AppError';
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super('NOT_FOUND', 404, `${resource} with id ${id} not found`);
    this.name = 'NotFoundError';
  }
}

export class ValidationError extends AppError {
  constructor(message: string, details?: Record<string, unknown>) {
    super('VALIDATION_ERROR', 400, message, details);
    this.name = 'ValidationError';
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') {
    super('UNAUTHORIZED', 401, message);
    this.name = 'UnauthorizedError';
  }
}

// Usage — instanceof checks are type-safe
function handleError(error: unknown): never {
  if (error instanceof NotFoundError) {
    console.error(`Resource not found: ${error.message}`);
  } else if (error instanceof ValidationError) {
    console.error(`Validation failed:`, error.details);
  } else if (error instanceof AppError) {
    console.error(`App error ${error.code}: ${error.message}`);
  } else {
    console.error('Unknown error:', error);
  }
  throw error;
}

Why typed classes matter: The instanceof check narrows the error type, giving you access to .code, .statusCode, and .details without casting.

The Result Pattern

Instead of throwing exceptions, return success or failure as a discriminated union. This makes error handling explicit in the function's return type:

type Result<T, E = AppError> =
  | { success: true; data: T }
  | { success: false; error: E };

// Usage
async function getUserById(id: string): Promise<Result<User>> {
  try {
    const user = await db.users.findUnique({ where: { id } });

    if (!user) {
      return {
        success: false,
        error: new NotFoundError('User', id),
      };
    }

    return { success: true, data: user };
  } catch (error) {
    return {
      success: false,
      error: new AppError('DATABASE_ERROR', 500, 'Failed to fetch user'),
    };
  }
}

// Caller MUST handle both cases
async function handleRequest(userId: string): Promise<void> {
  const result = await getUserById(userId);

  if (!result.success) {
    // TypeScript narrows to the error branch
    console.error(`Error ${result.error.code}: ${result.error.message}`);
    return;
  }

  // TypeScript narrows to the success branch — data is typed as User
  console.log(`Found user: ${result.data.name}`);
}

The compiler forces you to check result.success before accessing result.data — no more accidental undefined access.

Typed Try-Catch with Type Guards

TypeScript 4.0+ lets you type the catch variable using unknown. Combine with type guards for safe error handling:

function isErrorWithMessage(error: unknown): error is { message: string } {
  return (
    typeof error === 'object' &&
    error !== null &&
    'message' in error &&
    typeof (error as Record<string, unknown>).message === 'string'
  );
}

function isAppError(error: unknown): error is AppError {
  return error instanceof AppError;
}

async function safeExecute<T>(fn: () => Promise<T>): Promise<Result<T>> {
  try {
    const data = await fn();
    return { success: true, data };
  } catch (error) {
    if (isAppError(error)) {
      return { success: false, error };
    }
    if (isErrorWithMessage(error)) {
      return {
        success: false,
        error: new AppError('UNKNOWN', 500, error.message),
      };
    }
    return {
      success: false,
      error: new AppError('UNKNOWN', 500, 'An unknown error occurred'),
    };
  }
}

Async Error Handling Patterns

For async operations, the Result pattern shines because try-catch can't distinguish error types at compile time:

interface ApiError {
  status: number;
  message: string;
}

async function fetchData<T>(url: string): Promise<Result<T, ApiError>> {
  try {
    const response = await fetch(url);

    if (!response.ok) {
      return {
        success: false,
        error: {
          status: response.status,
          message: `HTTP ${response.status}: ${response.statusText}`,
        },
      };
    }

    const data = await response.json();
    return { success: true, data };
  } catch (error) {
    return {
      success: false,
      error: {
        status: 0,
        message: error instanceof Error ? error.message : 'Network error',
      },
    };
  }
}

// Composing multiple async operations
async function loadDashboardData(userId: string) {
  const [userResult, postsResult, notificationsResult] = await Promise.all([
    fetchData<User>(`/api/users/${userId}`),
    fetchData<Post[]>(`/api/users/${userId}/posts`),
    fetchData<Notification[]>(`/api/users/${userId}/notifications`),
  ]);

  // Collect all errors
  const errors = [
    ...(!userResult.success ? [userResult.error] : []),
    ...(!postsResult.success ? [postsResult.error] : []),
    ...(!notificationsResult.success ? [notificationsResult.error] : []),
  ];

  if (errors.length > 0) {
    return { success: false, errors } as const;
  }

  return {
    success: true,
    data: {
      user: userResult.data,
      posts: postsResult.data,
      notifications: notificationsResult.data,
    },
  } as const;
}

Error Handling in Express with TypeScript

Typed error middleware for Express applications:

import { Request, Response, NextFunction } from 'express';
import { AppError } from './errors';

function errorMiddleware(
  error: Error,
  _req: Request,
  res: Response,
  _next: NextFunction
): void {
  if (error instanceof AppError) {
    res.status(error.statusCode).json({
      error: {
        code: error.code,
        message: error.message,
        ...(error.details && { details: error.details }),
      },
    });
    return;
  }

  // Log unexpected errors
  console.error('Unhandled error:', error);

  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
    },
  });
}

// Wrapper for async route handlers
function asyncHandler(
  fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) {
  return (req: Request, res: Response, next: NextFunction): void => {
    fn(req, res, next).catch(next);
  };
}

// Usage in routes
router.get(
  '/users/:id',
  asyncHandler(async (req: Request, res: Response) => {
    const user = await getUserById(req.params.id);
    if (!user) {
      throw new NotFoundError('User', req.params.id);
    }
    res.json(user);
  })
);

Error Handling in React with TypeScript

Error Boundaries in React with TypeScript:

import React, { Component, ErrorInfo, ReactNode } from 'react';

interface ErrorBoundaryProps {
  children: ReactNode;
  fallback?: ReactNode;
  onError?: (error: Error, errorInfo: ErrorInfo) => void;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    console.error('Error caught by boundary:', error, errorInfo);
    this.props.onError?.(error, errorInfo);
  }

  render(): ReactNode {
    if (this.state.hasError) {
      return this.props.fallback ?? <h1>Something went wrong</h1>;
    }
    return this.props.children;
  }
}

Common Mistakes

1. Throwing non-Error values

Throwing strings, numbers, or plain objects loses stack traces and makes instanceof checks impossible. Always throw Error instances.

2. Catching with any instead of unknown

catch (error: any) disables Type Checking. Use catch (error: unknown) and narrow with type guards.

3. Not distinguishing error types

A single catch block handling all errors equally loses specific context. Use typed error classes with instanceof checks.

4. Ignoring async error propagation

Promise rejections that aren't caught become unhandled promise rejections. Always add .catch() or use asyncHandler wrappers.

5. Swallowing errors silently

An empty catch {} hides bugs. At minimum, log errors. Better: return a typed result.

6. Exposing internal error details to clients

Stack traces and database error messages leak implementation details. Always sanitize errors before sending to clients.

7. Not using discriminated union return types

Functions that can fail should encode failure in their return type, not surprise callers with exceptions. The Result pattern makes this explicit.

Practice Questions

  1. What's the advantage of unknown over any in catch clauses? unknown forces you to narrow the type before using it. any disables type checking, allowing unsafe access to error properties.

  2. How does the Result pattern differ from exceptions? Result makes error handling explicit in the return type. Exceptions are invisible in function signatures and can be accidentally unhandled.

  3. What is an Error Boundary in React? A React component that catches JavaScript errors in its child component tree, logs them, and displays a fallback UI instead of crashing.

  4. Why create custom error classes instead of using Error directly? Custom classes enable instanceof differentiation, carry additional context (statusCode, code, details), and self-document the error type.

  5. How do you handle errors in async Express routes? Use an asyncHandler wrapper that catches promise rejections and passes them to Express's error middleware via next().

Challenge

Build a typed Result-based error handling system for a REST API client. Include typed error classes (NetworkError, TimeoutError, ValidationError, ServerError), automatic retry for transient errors, and a logger that records structured error data.

FAQ

Should I use Result types or exceptions?

Both have their place. Use Result for expected failures (validation errors, not found). Use exceptions for unexpected failures (network outages, bugs).

How do I handle errors in concurrent async operations?

Use Promise.allSettled() which returns results for each promise regardless of rejection, or use the Result pattern with Promise.all() collecting error branches.

What's the best way to log errors in TypeScript?

Use structured logging with error codes, context, and stack traces. Libraries like Winston or Pino integrate well with TypeScript error classes.

How do I make error classes serialize correctly over the network?

Custom error properties don't serialize with JSON.stringify. Override toJSON() in your error classes to include all relevant fields.

Should every function return a Result type?

No. Only functions with expected failure modes. Simple getters and pure computations can throw or return values directly.

How does TypeScript's `never` type relate to error handling?

Functions that always throw return never. TypeScript uses this for control flow analysis — code after a never-returning call is considered unreachable.

Mini Project

Build an error handling system for a payment processing service:

  • Error classes: InsufficientFundsError, CardDeclinedError, NetworkError, DuplicateTransactionError
  • Result type: PaymentResult<T> with success and error branches
  • Retry logic: Automatic retry with exponential backoff for transient errors
  • Express middleware: Typed error handler that maps errors to HTTP responses
  • Logger: Structured logging with error context

What's Next

You've mastered error handling with TypeScript. Now learn async patterns with {{< ref "52-async-await" >}}, or explore pattern matching with {{< ref "53-pattern-matching" >}}.

For performance optimization, see {{< ref "54-performance" >}}.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro