JavaScript Error Handling

AI-assistedDrafted with AI assistance and reviewed before publishing; examples are version-pinned. Verify against official docs when in doubt.

Node.js 22 LTS

Failures are normal. The dangerous case is a swallowed error: the program keeps running and the symptom shows up later as wrong data with no clue where it came from.

The Error object

const err = new Error("file not found");

err.name;      // "Error"
err.message;   // "file not found"
err.stack;     // stack trace string

Creating an Error does nothing on its own. It has to be thrown.

Built-in types you will meet:

Type Typical trigger
TypeError operation on the wrong type, e.g. null.name
ReferenceError reading a variable that was never declared
SyntaxError invalid code, also thrown by JSON.parse
RangeError value out of range, e.g. new Array(-1)
AggregateError several failures at once, e.g. Promise.any

throw

function divide(a, b) {
  if (b === 0) {
    throw new Error("b must not be zero");
  }
  return a / b;
}

Throwing abandons the current function immediately and unwinds to the nearest catch. With no handler, Node prints the stack and exits; the browser logs it and stops that task.

Throw Error instances, not strings:

throw new Error("failed");   // keeps stack, name, instanceof
throw "failed";              // no stack, no type information

Throwing a string also breaks every caller that checks err instanceof Error.

try / catch / finally

try {
  const data = JSON.parse(raw);
  console.log(data);
} catch (err) {
  console.error("parse failed:", err.message);
} finally {
  console.log("always runs");
}

Order of execution:

  1. An exception inside try jumps to catch; the rest of try is skipped.
  2. finally runs whether or not something threw, including when catch returns or rethrows.
  3. If finally itself throws, that exception replaces the original one.

finally is for cleanup: releasing a lock, clearing a timer, resetting a loading flag.

let busy = true;
try {
  await loadData();
} finally {
  busy = false;   // runs even when loadData rejects
}

Do not return from finally; it discards the try value and hides exceptions.

Filtering by error type

try {
  JSON.parse(raw);
} catch (err) {
  if (err instanceof SyntaxError) {
    console.log("invalid JSON");
  } else {
    throw err;      // not mine to handle
  }
}

“Handle what you understand, rethrow the rest” is the habit that keeps logs useful. A catch-all that logs nothing turns failures into silence.

Since ES2022 the binding is optional when you do not need the error:

try {
  doSomething();
} catch {
  console.log("failed");
}

That is fine for cleanup paths; it is not fine as a substitute for real handling.

Custom error classes

Branching on message strings is brittle. Give each failure mode a type:

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

function requireName(user) {
  if (!user.name) {
    throw new ValidationError("name is required", "name");
  }
}

try {
  requireName({});
} catch (err) {
  if (err instanceof ValidationError) {
    console.log(`${err.field}: ${err.message}`);   // name: name is required
  }
}

Set this.name in the constructor; otherwise logs show Error for every subclass. If you target older environments, also restore the prototype chain, though modern class syntax handles it.

Keep the hierarchy shallow: one project base class (AppError) plus a few specific subclasses is easier to handle than a deep tree.

Preserving context with cause

try {
  readConfig();
} catch (err) {
  throw new Error("startup failed", { cause: err });
}

The outer error carries the original in err.cause, so the stack shows both. Node 18+ and all modern browsers support the option. Prefer it over stuffing the original message into a new string.

Async errors escape try/catch

// does not work
try {
  setTimeout(() => {
    throw new Error("async failure");
  }, 100);
} catch (err) {
  console.log("caught");   // never runs
}

The callback runs later, on a different call stack, after the try block has finished. How you catch depends on the async style:

Promises use .catch():

fetchUsers()
  .then((users) => console.log(users))
  .catch((err) => console.error("request failed:", err.message));

async / await works with try / catch (preferred):

async function load() {
  try {
    return await fetchUsers();
  } catch (err) {
    console.error("request failed:", err.message);
    return [];
  }
}

Every promise chain needs a .catch() at the end, or an await inside try. Otherwise you get unhandled rejections: a warning in Node today, and a process exit in modern runtimes with --unhandled-rejections=throw.

For a whole application, add global handlers as a safety net, not as the primary strategy:

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

Validation beats exceptions

Exceptions are for exceptional states. Ordinary branching should be ordinary code:

// avoid: using exceptions for control flow
let value;
try {
  value = obj.field;
} catch {
  value = "default";
}

// prefer
const value = obj?.field ?? "default";

The same applies to parsing input: check the shape first, and reserve throw for data that violates an invariant.

Common mistakes

1. Empty catch {}

The error disappears. Log it, or write a comment explaining why ignoring it is safe.

2. Throwing strings or plain objects

No stack, no instanceof, no name. Always new Error(...).

3. Wrapping a callback in try/catch

Asynchronous errors are not catchable that way. Use promises and await.

4. return inside finally

Overrides the return value and suppresses exceptions.

5. Missing .catch() on a promise chain

Unhandled rejections are hard to trace, especially in fire-and-forget code.

6. Custom errors without name

Every log line says Error, and you cannot tell validation failures from network failures.

7. Losing the original error

throw new Error(err.message) discards the stack. Use { cause: err }.

Summary

  • throw new Error(msg, { cause }); never throw primitives.
  • finally always runs — use it for cleanup, never for return.
  • Catch only what you can handle and rethrow the rest with throw err.
  • Custom errors: extend Error, set name, keep the hierarchy shallow.
  • Async failures belong to .catch() or await inside try / catch.
  • Do not use exceptions for expected branches.

That completes the JavaScript basics series: bindings, coercion, control flow, functions, arrays, objects, strings, and errors.