Foundations Updated 2026-09 View as Markdown

Error Handling

Throwing, catching, and the specific ways JavaScript error handling goes wrong — including the one that terminates your process.

javascript
try {
  const data = JSON.parse(input);
  process(data);
} catch (err) {
  console.error("could not parse:", err.message);
} finally {
  cleanup();          // runs whether or not it threw
}

The Error object#

javascript
throw new Error("something broke");

const err = new Error("boom");
err.name;        // "Error"
err.message;     // "boom"
err.stack;       // where it was created

Built-in subclasses carry meaning:

javascript
TypeError        // wrong type — null.foo, undefined is not a function
RangeError       // out of range — new Array(-1)
SyntaxError      // bad syntax — JSON.parse("{")
ReferenceError   // undeclared variable

Always throw an Error, never a string. A thrown string has no stack trace, and every catch block that does err.message gets undefined.

javascript
throw "not found";              // no stack, no name, breaks every handler
throw new Error("not found");   // correct

Custom error classes#

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

class ValidationError extends Error {
  constructor(field, reason) {
    super(`${field}: ${reason}`);
    this.name = "ValidationError";
    this.field = field;
  }
}

Now callers can distinguish failures and respond appropriately:

javascript
try {
  await createOrder(payload);
} catch (err) {
  if (err instanceof ValidationError) return respond(400, { field: err.field });
  if (err instanceof NotFoundError)   return respond(404);
  throw err;                          // not ours — let it propagate
}

That final throw err matters. Catching an error you cannot handle and swallowing it is how bugs become invisible.

Preserving the cause#

javascript
try {
  await db.query(sql);
} catch (err) {
  throw new Error(`loading user ${id} failed`, { cause: err });
}

The cause option keeps the original error attached, so you get context and the underlying failure. Without it you either lose the detail or lose the context.

javascript
catch (err) {
  console.error(err.message);        // "loading user 1 failed"
  console.error(err.cause.message);  // "connection refused"
}

Async errors#

javascript
try {
  const user = await getUser(id);
} catch (err) {
  // works — a rejected promise throws at the await
}

But only if you await. Without it, the error escapes:

javascript
try {
  getUser(id);            // not awaited
} catch (err) {
  // never runs. the rejection happens after this block.
}

Errors in callbacks#

javascript
try {
  setTimeout(() => { throw new Error("boom"); }, 0);
} catch (err) {
  // never runs
}

The callback runs later, on a fresh stack. The try block has long since finished. Anything asynchronous needs its error handling inside the callback, or needs to be promise-based so await can catch it.

What not to do#

Swallowing:

javascript
try { risky(); } catch (err) {}                    // the incident starts here
try { risky(); } catch (err) { console.log(err); } // barely better

If you cannot handle it, do not catch it. If you catch it deliberately — a cache read that is allowed to fail — say so in a comment.

Catching too broadly:

javascript
try {
  const data = JSON.parse(input);
  await save(data);              // a save failure now looks like a parse failure
} catch (err) {
  return { error: "invalid JSON" };
}

Keep the try around the operation you are actually handling.

Returning error codes instead of throwing:

javascript
const result = doThing();
if (result === -1) { }          // easy to ignore; no stack; no message

Throw, or return a discriminated result object ({ ok: true, value } / { ok: false, error }) — but pick one convention per codebase.

Result objects#

For expected failures, a returned result is often better than an exception, because the type system and the reader can both see it:

javascript
async function findUser(id) {
  const row = await db.get(id);
  return row ? { ok: true, value: row } : { ok: false, error: "not_found" };
}

const result = await findUser(id);
if (!result.ok) return respond(404);
use(result.value);

Reserve exceptions for the genuinely exceptional — a database being down, a bug — and use results for outcomes you expect, like "not found" or "invalid input".

Exercise#

javascript
// 1. Write a ValidationError class carrying a `field`.
// 2. Write validateOrder(order) that throws it for a missing customerId
//    or an empty items array.
// 3. Write handleRequest(order) that catches ValidationError and returns
//    { status: 400, field }, re-throws anything else, and wraps a database
//    failure with `cause` preserved.
console.log("start");

Common questions#

Should I throw or return an error?#

Throw for the unexpected — a bug, a dependency that is down, a broken invariant. Return a result for expected outcomes like "not found" or "invalid input", where the caller must handle it anyway and an exception is control flow in disguise.

Why is my catch not catching?#

The three usual causes: the promise was not awaited, the error was thrown inside a later callback, or something between you and the throw already caught and swallowed it. The first is by far the most common.

Is it ever fine to catch and ignore?#

Occasionally, and it should look deliberate: a cache read that may fail, a best-effort cleanup. Write the comment saying why, so the next reader knows it is a decision rather than an oversight.

Get the JavaScript agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for JavaScript. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.