Exception Handling

This section documents modern ECMAScript and core browser JavaScript APIs — it is not tied to any specific framework or library (React, Vue, Angular, etc.). This content was generated with the assistance of AI and should be verified against the current ECMAScript specification and MDN documentation before relying on it in production, since JavaScript language features and browser API support continue to evolve.

This section’s bibliography lists the reference material consulted while preparing these pages.

Exceptions are JavaScript’s mechanism for signaling and reacting to failure — an operation that cannot complete normally interrupts its own execution and hands control to whichever surrounding code is prepared to deal with it. This page covers throw, the full try/catch/finally mechanics, defining custom exception types, and how exceptions behave in asynchronous code. For the built-in Error class hierarchy itself (TypeError, RangeError, and the rest), see Standard Library: Dates, Errors & JSON.

throw

throw immediately stops normal execution of the current function and propagates a value — any value at all — up to the nearest enclosing exception handler:

function requirePositive(n) {
  if (n <= 0) {
    throw new Error(`expected a positive number, got ${n}`);
  }
  return n;
}

throw accepts any expression — a string, a number, a plain object — but throwing an Error (or subclass) instance is strongly conventional and almost always the right choice: constructing an Error captures the current call stack at the point of construction (available afterward as .stack), which is often the single most useful piece of information for diagnosing an exception after the fact. Throwing a bare string or plain object discards that — and forces every catch block to first check what kind of value it received before it can safely read a .message or similar.

try / catch / finally

A try block wraps code that might throw; a catch block, if present, runs when something inside try throws; a finally block, if present, always runs afterward, whether or not an exception occurred:

try {
  riskyOperation();
} catch (err) {
  console.error("Operation failed:", err.message);
} finally {
  cleanup();   // always runs
}

catch’s binding (`err above) is optional — write a bare catch { when the handler doesn’t need the actual exception value, for example when any failure is handled identically regardless of what went wrong:

try {
  return JSON.parse(text);
} catch {
  return null;   // any parse failure just falls back to null
}

finally always runs

finally is guaranteed to run in every case: after try completes normally, after catch handles an exception (caught or not), and even when neither block completes normally — including when an exception inside try is never caught at all (no catch, or a catch that itself throws), and when try or catch executes a return, break, or continue. This makes finally the correct place for cleanup that must happen unconditionally — releasing a lock, closing a connection, hiding a loading indicator:

function readConfig() {
  try {
    return JSON.parse(loadConfigText());   // may throw a SyntaxError
  } finally {
    console.log("config load attempted");   // runs whether parse succeeded, threw, or the throw propagates on
  }
}

In the example above, readConfig() has no catch at all — a SyntaxError inside try still runs finally before propagating on to readConfig()’s own caller. The same guarantee applies to a `return inside try:

function example() {
  try {
    return "from try";
  } finally {
    console.log("this still runs before the function actually returns");
  }
}
A return (or throw) written inside finally itself overrides whatever try or catch was about to do — it silently discards a pending return value or a pending exception. This is almost never intentional; avoid return/throw/break/continue inside finally.

Exception Propagation

An exception that isn’t caught by the immediately enclosing try keeps propagating outward, one enclosing scope at a time, until either a catch block along the way handles it or it reaches the top of the call stack uncaught — at which point, in a browser, the window.onerror handler runs (if one is registered) and the error is reported to the console; in Node.js, an uncaught exception terminates the process by default.

flowchart TB A["outer() calls middle()"] --> B["middle() calls inner()"] B --> C["inner() throws"] C --> D{"try/catch inside inner()?"} D -->|"no"| E{"try/catch inside middle()?"} D -->|"yes"| F["handled inside inner()
outer() and middle() never see it"] E -->|"no"| G{"try/catch inside outer()?"} E -->|"yes"| H["handled inside middle()
outer() never sees it"] G -->|"no"| I["uncaught: reaches top of call stack
(window.onerror / process crash)"] G -->|"yes"| J["handled inside outer()"]

Nesting try/catch blocks works the same way at a finer grain: a catch at an inner level can handle what it knows how to recover from and re-throw anything else, letting an outer catch handle the parts it’s actually equipped for:

function parseAndValidate(text) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (err) {
    throw new Error(`invalid JSON: ${err.message}`);   // re-throw as a more specific error
  }
  if (!data.id) {
    throw new Error("missing required field: id");
  }
  return data;
}

Custom Exception Types

Application code commonly defines its own Error subclasses so a catch block can distinguish what kind of failure occurred instead of parsing a message string. Subclassing follows the same extends/super() mechanics as any other class (see Classes):

class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = "ValidationError";   // shows up in stack traces and console output instead of "Error"
    this.field = field;
  }
}

class NotFoundError extends Error {
  constructor(message) {
    super(message);
    this.name = "NotFoundError";
  }
}

instanceof then lets a catch block branch on the exception’s actual type, handling each failure mode appropriately instead of treating every exception identically:

function handleRequest(request) {
  try {
    return processRequest(request);
  } catch (err) {
    if (err instanceof ValidationError) {
      return { status: 400, body: `Invalid ${err.field}: ${err.message}` };
    }
    if (err instanceof NotFoundError) {
      return { status: 404, body: err.message };
    }
    throw err;   // anything else is unexpected -- let it propagate rather than mask it
  }
}

Re-throwing anything the catch block doesn’t specifically recognize (the final throw err; above) is important: swallowing every exception indiscriminately hides genuine bugs and makes them far harder to diagnose later.

Exceptions in Asynchronous JavaScript

Asynchronous code has two idiomatic error-handling styles, corresponding to the two ways of consuming a Promise (see Asynchronous JavaScript for the underlying Promise/ async/await mechanics this section assumes as background).

try/catch around await

Inside an async function, await`ing a rejected Promise throws the rejection reason at the point of the `await — ordinary try/catch catches it exactly as if the failure had happened synchronously:

async function loadUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new NotFoundError(`user ${id} not found (HTTP ${response.status})`);
    }
    return await response.json();
  } catch (err) {
    console.error("Failed to load user:", err.message);
    throw err;   // still rejects the Promise loadUser() returns, for the caller to handle
  }
}

.catch() on a Promise chain

Outside an async function — or by choice, even inside one — the same failure can be handled with .catch() attached to the Promise chain instead:

function loadUser(id) {
  return fetch(`/api/users/${id}`)
    .then(response => {
      if (!response.ok) {
        throw new NotFoundError(`user ${id} not found (HTTP ${response.status})`);
      }
      return response.json();
    })
    .catch(err => {
      console.error("Failed to load user:", err.message);
      throw err;   // re-throw to keep the returned promise rejected for the caller
    });
}

Both versions behave identically to their caller: a rejected fetch(), a non-OK response, or a NotFoundError thrown from inside either handler all end up rejecting the Promise loadUser() returns, catchable by the caller with either try/catch around await loadUser(id) or .catch() on loadUser(id). .then()’s optional second argument (`promise.then(onFulfilled, onRejected)) can also handle rejection, but .catch() is generally clearer, since a rejection thrown by the first argument still falls through to a later .catch() rather than `.then()’s own second argument, which only catches rejection of the original promise.

Unhandled promise rejections

A rejected Promise with no .catch() (and no await inside a try that catches it) anywhere in its chain is an unhandled rejection. In browsers, this fires an unhandledrejection event on window, useful as a last-resort diagnostic or reporting hook:

window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled promise rejection:", event.reason);
  event.preventDefault();   // suppress the default browser console warning, if handled here instead
});

Relying on unhandledrejection as the primary error-handling strategy is a code smell — it exists as a safety net for genuinely unexpected failures, not a substitute for a .catch()/try/catch at the point where a rejection can actually be handled meaningfully.