Skip to content
Codeloom
JavaScript

JavaScript Error Handling Patterns and Best Practices

Master JavaScript error handling with custom error classes, async error patterns, Result types, error boundaries, and strategies for robust production code.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How to design custom error hierarchies
  • Patterns for handling errors in async code, promises, and event emitters
  • The Result pattern as an alternative to try/catch

Prerequisites

  • JavaScript try/catch basics
  • Promises and async/await
  • ES6 classes

Every JavaScript application encounters errors. The difference between fragile code and robust code is not whether errors occur, but how they are anticipated, categorized, and handled. This article covers practical patterns that go beyond basic try/catch.

Custom error classes

The built-in Error class provides a message and stack trace, but production code needs more structure. Custom error classes let you categorize errors and attach relevant data.

class AppError extends Error {
  constructor(message, options = {}) {
    super(message);
    this.name = this.constructor.name;
    this.code = options.code || "UNKNOWN_ERROR";
    this.statusCode = options.statusCode || 500;
    this.context = options.context || {};
  }
}

class ValidationError extends AppError {
  constructor(message, fields) {
    super(message, { code: "VALIDATION_ERROR", statusCode: 400 });
    this.fields = fields;
  }
}

class NotFoundError extends AppError {
  constructor(resource, id) {
    super(`${resource} with id ${id} not found`, {
      code: "NOT_FOUND",
      statusCode: 404,
      context: { resource, id },
    });
  }
}

class AuthenticationError extends AppError {
  constructor(message = "Authentication required") {
    super(message, { code: "AUTH_REQUIRED", statusCode: 401 });
  }
}

Using these in practice:

function getUser(id) {
  const user = database.find(id);
  if (!user) {
    throw new NotFoundError("User", id);
  }
  return user;
}

try {
  const user = getUser(999);
} catch (error) {
  if (error instanceof NotFoundError) {
    console.log(error.statusCode); // 404
    console.log(error.context);    // { resource: "User", id: 999 }
  }
}

The Error cause chain

ES2022 introduced the cause property, letting you chain errors to preserve the original context.

async function fetchUserProfile(userId) {
  try {
    const response = await fetch(`/api/users/${userId}`);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    throw new AppError("Failed to load user profile", {
      code: "PROFILE_LOAD_ERROR",
      cause: error, // Preserves the original error
      context: { userId },
    });
  }
}

try {
  await fetchUserProfile(123);
} catch (error) {
  console.log(error.message);       // "Failed to load user profile"
  console.log(error.cause?.message); // "HTTP 500" (original error)
}

The cause chain lets high-level code report a user-friendly error while preserving the technical details for debugging.

Async error handling patterns

Promise chains

In promise chains, a single .catch() at the end handles errors from any step.

fetchUser(123)
  .then((user) => fetchOrders(user.id))
  .then((orders) => calculateTotal(orders))
  .then((total) => displayTotal(total))
  .catch((error) => {
    // Handles errors from any step above
    console.error("Pipeline failed:", error);
  });

async/await with centralized handling

async function processOrder(orderId) {
  try {
    const order = await fetchOrder(orderId);
    const payment = await chargePayment(order);
    const confirmation = await sendConfirmation(payment);
    return confirmation;
  } catch (error) {
    if (error instanceof ValidationError) {
      return { success: false, errors: error.fields };
    }
    if (error instanceof AuthenticationError) {
      redirectToLogin();
      return;
    }
    // Unexpected error -- rethrow
    throw error;
  }
}

Handling errors from Promise.allSettled

When running multiple operations, Promise.allSettled lets you collect both successes and failures.

async function fetchMultipleUsers(ids) {
  const results = await Promise.allSettled(
    ids.map((id) => fetchUser(id))
  );

  const users = [];
  const errors = [];

  for (const result of results) {
    if (result.status === "fulfilled") {
      users.push(result.value);
    } else {
      errors.push(result.reason);
    }
  }

  if (errors.length > 0) {
    console.warn(`${errors.length} user fetches failed`);
  }

  return { users, errors };
}

The Result pattern

Instead of throwing exceptions, return a value that explicitly represents success or failure. This makes error handling part of the type system rather than the control flow.

class Result {
  #value;
  #error;
  #isOk;

  constructor(isOk, valueOrError) {
    this.#isOk = isOk;
    if (isOk) {
      this.#value = valueOrError;
    } else {
      this.#error = valueOrError;
    }
  }

  static ok(value) {
    return new Result(true, value);
  }

  static err(error) {
    return new Result(false, error);
  }

  isOk() {
    return this.#isOk;
  }

  isErr() {
    return !this.#isOk;
  }

  unwrap() {
    if (this.#isOk) return this.#value;
    throw this.#error;
  }

  unwrapOr(defaultValue) {
    return this.#isOk ? this.#value : defaultValue;
  }

  map(fn) {
    return this.#isOk ? Result.ok(fn(this.#value)) : this;
  }

  mapErr(fn) {
    return this.#isOk ? this : Result.err(fn(this.#error));
  }
}

Using the Result pattern:

function parseJSON(text) {
  try {
    return Result.ok(JSON.parse(text));
  } catch (error) {
    return Result.err(new ValidationError("Invalid JSON", { raw: text }));
  }
}

function validateAge(data) {
  if (typeof data.age !== "number" || data.age < 0 || data.age > 150) {
    return Result.err(new ValidationError("Invalid age", { age: data.age }));
  }
  return Result.ok(data);
}

// Usage -- no try/catch needed
const result = parseJSON('{"age": 25}');

if (result.isOk()) {
  const validated = validateAge(result.unwrap());
  console.log(validated.unwrapOr({ age: 0 }));
} else {
  console.error("Parse failed");
}

Safe wrapper for async functions

A utility that wraps any async function to return [error, data] tuples instead of throwing.

async function safe(asyncFn, ...args) {
  try {
    const result = await asyncFn(...args);
    return [null, result];
  } catch (error) {
    return [error, null];
  }
}

// Usage
const [error, user] = await safe(fetchUser, 123);

if (error) {
  console.error("Failed:", error.message);
} else {
  console.log("User:", user.name);
}

This pattern, inspired by Go’s error handling, eliminates nested try/catch blocks when calling multiple async functions sequentially.

const [err1, user] = await safe(fetchUser, userId);
if (err1) return handleError(err1);

const [err2, orders] = await safe(fetchOrders, user.id);
if (err2) return handleError(err2);

const [err3, total] = await safe(calculateTotal, orders);
if (err3) return handleError(err3);

displayTotal(total);

Global error handlers

Browser unhandled errors

window.addEventListener("error", (event) => {
  reportError({
    type: "uncaught",
    message: event.message,
    filename: event.filename,
    line: event.lineno,
    column: event.colno,
    stack: event.error?.stack,
  });
});

window.addEventListener("unhandledrejection", (event) => {
  reportError({
    type: "unhandled-rejection",
    reason: event.reason?.message || String(event.reason),
    stack: event.reason?.stack,
  });
  event.preventDefault(); // Prevent default console error
});

Node.js

process.on("uncaughtException", (error) => {
  console.error("Uncaught exception:", error);
  // Perform synchronous cleanup, then exit
  process.exit(1);
});

process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
});

Error handling strategy

A consistent strategy across your application is more important than any individual pattern.

  1. Throw early — validate inputs at the boundaries (API endpoints, form handlers) and throw immediately if data is invalid.
  2. Catch late — let errors propagate up to a handler that has enough context to respond appropriately.
  3. Add context — use error wrapping (cause) to preserve the original error while adding high-level meaning.
  4. Categorize errors — distinguish between operational errors (expected failures like network issues) and programmer errors (bugs like null reference). Handle operational errors; crash on programmer errors.
  5. Log consistently — every caught error should be logged with its stack trace, context, and any relevant request/user identifiers.

Summary

Robust error handling requires design, not just try/catch:

  • Custom error classes categorize errors with codes, status codes, and context data.
  • The cause property chains errors to preserve both high-level meaning and low-level details.
  • Promise.allSettled collects results from multiple operations without short-circuiting on the first failure.
  • The Result pattern makes error handling explicit in return values rather than exception-based control flow.
  • The safe() wrapper provides Go-style [error, data] tuples for cleaner sequential async code.
  • Global handlers (error, unhandledrejection) act as a safety net for errors that escape all other handlers.