Skip to content

07 · Error Handling Advanced

This module extends Level 1 · Module 8 into custom error types and the asynchronous cases that basic try/catch doesn't cover on its own.

Custom Error subclasses

Extending the built-in Error class lets calling code distinguish failure kinds by type, instead of parsing message strings.

class ValidationError extends Error {
  constructor(message, field) {
    super(message);       // sets this.message
    this.name = "ValidationError"; // overrides the default "Error"
    this.field = field;    // extra context specific to this error type
  }
}

class NotFoundError extends Error {
  constructor(resource, id) {
    super(`${resource} with id ${id} not found`);
    this.name = "NotFoundError";
    this.resource = resource;
    this.id = id;
  }
}

function findUser(users, id) {
  const user = users.find((u) => u.id === id);
  if (!user) throw new NotFoundError("User", id);
  return user;
}

try {
  findUser([{ id: 1, name: "Ada" }], 99);
} catch (error) {
  console.log(error.name);    // NotFoundError
  console.log(error.message); // User with id 99 not found
  console.log(error instanceof Error); // true — still a real Error
}

Branching on error type

With custom classes, catch blocks can handle different failures differently using instanceof, instead of one generic handler for everything.

function validateAge(age) {
  if (typeof age !== "number") {
    throw new ValidationError("age must be a number", "age");
  }
  if (age < 0) {
    throw new ValidationError("age cannot be negative", "age");
  }
  return age;
}

function processSignup(age) {
  try {
    validateAge(age);
    console.log("signup accepted");
  } catch (error) {
    if (error instanceof ValidationError) {
      console.log(`validation problem on '${error.field}': ${error.message}`);
    } else {
      console.log("unexpected error:", error.message);
      throw error; // re-throw errors we don't know how to handle
    }
  }
}

processSignup(-5);    // validation problem on 'age': age cannot be negative
processSignup("old"); // validation problem on 'age': age must be a number
processSignup(30);    // signup accepted

Errors in async/await code

try/catch around await catches both synchronous throws and rejected Promises — this is the main reason async/await is easier to reason about than raw .then() chains.

async function fetchWithValidation(id) {
  if (id <= 0) {
    throw new ValidationError("id must be positive", "id"); // sync throw
  }

  const response = await delay(id); // could reject — both are caught below
  return response;
}

function delay(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id > 100) reject(new NotFoundError("Item", id));
      else resolve({ id, name: `item-${id}` });
    }, 50);
  });
}

async function run() {
  for (const id of [-1, 500, 5]) {
    try {
      const item = await fetchWithValidation(id);
      console.log("loaded:", item);
    } catch (error) {
      console.log(`${error.name}: ${error.message}`);
    }
  }
}

run();
// ValidationError: id must be positive
// NotFoundError: Item with id 500 not found
// loaded: { id: 5, name: 'item-5' }

Unhandled promise rejections

A rejected Promise with no .catch() (and not awaited inside a try) is an unhandled rejection — silent in older code, but modern runtimes warn or crash on it. Always attach error handling to every Promise chain you start.

function riskyOperation() {
  return new Promise((resolve, reject) => {
    reject(new Error("boom"));
  });
}

// Missing entirely: riskyOperation(); would trigger an unhandledRejection warning

// Handled correctly:
riskyOperation().catch((error) => {
  console.log("caught it:", error.message); // caught it: boom
});

In Node.js, you can add a last-resort safety net for anything that slips through — useful for logging, not for routine error handling:

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

process.on("uncaughtException", (error) => {
  console.error("Uncaught exception:", error);
  process.exit(1); // the process is in an unknown state — restart rather than continue
});

Browsers have an equivalent event for the same purpose:

window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled rejection:", event.reason);
});

Wrapping and re-throwing with context

When an error crosses a boundary (e.g. from a data layer into business logic), wrapping it preserves the original cause while adding useful context — the cause option keeps the chain traceable.

async function loadConfig(path) {
  try {
    const response = await fetch(path);
    if (!response.ok) throw new Error(`status ${response.status}`);
    return await response.json();
  } catch (error) {
    throw new Error(`failed to load config from ${path}`, { cause: error });
  }
}

loadConfig("/missing.json").catch((error) => {
  console.log(error.message);      // failed to load config from /missing.json
  console.log(error.cause?.message); // the original underlying error
});

Error-handling strategy cheat sheet

Situation Strategy
Expected, recoverable failure (bad input) throw a specific Error subclass, catch and handle it
Unexpected failure inside async code try/catch around await, or .catch() on the promise chain
Failure crossing a module boundary wrap with { cause: originalError } for traceability
Promise created but never awaited/caught always attach .catch(), or listen for unhandledrejection/unhandledRejection as a safety net
Truly unknown/corrupted state log and crash/restart (uncaughtException) rather than continue silently

How It Actually Works

Custom error classes that extend Error hit a real V8/transpiler interaction worth understanding: Error's constructor uses Error.captureStackTrace to attach the stack trace to this, and because class requires super() to run before this exists, skipping super(message) in a subclass means the resulting object never gets a constructed stack at all. When code is transpiled down to ES5 (older Babel targets), extends Error famously breaks instanceof checks, because ES5 "classes" are just constructor functions calling Error.call(this), which returns a new plain Error object rather than initializing this — the fix (Object.setPrototypeOf(this, new.target.prototype)) is a workaround for exactly this gap between real class semantics and their ES5 emulation.

Async error propagation follows the microtask model, not the synchronous stack-unwind model: a throw inside an async function doesn't propagate up the JS call stack the way a synchronous throw does — it's captured by the generator-driven runner (see the async/await lesson) and turned into a rejected promise. This is why wrapping an await someAsyncCall() in a synchronous try/catch works (the await desugars to control flow that re-throws into that same function body when the underlying promise rejects), but a rejection from a promise you merely created and didn't await or .catch() becomes an "unhandled rejection" — detected only after a full microtask-queue drain finds no handler was ever attached.

Exercise

Create a TimeoutError class extending Error. Then write an async function withRetry(fn, retries) that calls fn() (an async function that may reject), retrying up to retries times on failure, and finally throwing a TimeoutError with a message like "failed after 3 attempts" if every attempt fails.