JavaScript Error Handling

6 minute read

Robust JavaScript handles errors on purpose — at the right boundary, with enough context, and without swallowing them silently. This chapter covers try/catch/finally, throwing your own errors, the built-in error types, custom error classes, async error patterns, and the global “uncaught” handlers that catch anything you missed.

try/catch/finally

try {
  doRisky();
} catch (err) {
  console.error("failed:", err.message);
} finally {
  cleanup();
}
  • try — the protected block
  • catch (err) — runs only if something throws
  • finally — runs always (success, throw, or even return)

You can omit catch (with just try/finally) or omit the binding (catch {}) in ES2019+.

try {
  JSON.parse(input);
} catch {           // don't care about the error
  return null;
}

What Counts as Throwable

throw accepts any value, but you should always throw real Error objects (or subclasses) so you get a stack trace:

throw new Error("bad input");                  // ✅ standard
throw new TypeError("expected a number");      // ✅ built-in subclass
throw "oops";                                  // ⚠ string — no stack, no .name
throw { code: 42 };                            // ⚠ plain object — no stack

Tip: Always throw new Error(...) — never throw raw strings or objects. Stack traces and consistent .message are gold during debugging.

Built-In Error Types

Type When you’ll see it
Error generic
TypeError wrong type (null.foo, "x"())
ReferenceError undeclared name
RangeError out of valid range (new Array(-1))
SyntaxError parse / JSON.parse failure
URIError bad URI in decodeURI etc.
EvalError (rare)
AggregateError from Promise.any when all reject

The Error constructor takes a message and optionally { cause } (ES2022) to chain a lower-level error:

try {
  await fetch("/api");
} catch (origErr) {
  throw new Error("Failed to load config", { cause: origErr });
}

The cause is preserved on .cause and printed in modern stack traces.

Custom Error Classes

Standard pattern when your app needs typed errors:

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

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

throw new ValidationError("Email is required", { field: "email" });

Catch by type with instanceof:

try {
  saveUser(input);
} catch (err) {
  if (err instanceof ValidationError) {
    flashError(`${err.field}: ${err.message}`);
  } else if (err instanceof NotFoundError) {
    show404();
  } else {
    log(err);              // unknown — bubble up or report
    throw err;
  }
}

Async Errors

Errors in async functions become promise rejections; try/catch works as expected:

async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new NotFoundError("user");
    return await res.json();
  } catch (err) {
    log(err);
    throw err;                // re-throw so callers can react too
  }
}

For chained .then, a single trailing .catch covers all earlier rejections:

fetchUser(1)
  .then(u => fetchPosts(u.id))
  .then(render)
  .catch(showError);          // catches all three

Where to Handle — The Boundary Principle

Don’t try/catch everywhere. Handle errors where you have enough context to do something useful:

  • A leaf function (e.g. parseConfig) should usually let exceptions propagate.
  • The orchestrating function (e.g. bootApp) decides whether to recover, retry, or show a message.
// ❌ Defensive try/catch everywhere — error info is lost
function parse(text) {
  try { return JSON.parse(text); } catch { return {}; }
}
function loadConfig() {
  try { return parse(fs.readFileSync("./c.json")); } catch { return {}; }
}
// caller has no idea anything went wrong

// ✅ Let it bubble; one handler at the entry point
function parse(text) { return JSON.parse(text); }
function loadConfig() { return parse(fs.readFileSync("./c.json")); }

try { app.start(loadConfig()); }
catch (err) { console.error("startup failed:", err); process.exit(1); }

Re-throwing — Add Context

async function fetchProfile(id) {
  try {
    return await api(`/users/${id}/profile`);
  } catch (err) {
    throw new Error(`Failed to load profile for ${id}`, { cause: err });
  }
}

A stack trace alone often isn’t enough — the wrapped message tells future-you what the program was trying to do.

finally Pitfalls

function f() {
  try { return 1; }
  finally { return 2; }      // ⚠ overrides — function returns 2
}

async function g() {
  try { throw new Error("e"); }
  finally { return "fallback"; }    // ⚠ swallows the error
}

Warning: return (or throw) inside finally overrides whatever the try/catch produced. Almost always a bug. Use finally only for cleanup that has no return value.

Global Catch-All Handlers

You should still set these as a last line of defence — for unexpected errors and unhandled rejections:

Browser

window.addEventListener("error", (e) => {
  // Uncaught sync errors
  console.error("uncaught:", e.error);
  send("/log", { type: "error", message: e.message, stack: e.error?.stack });
});

window.addEventListener("unhandledrejection", (e) => {
  console.error("unhandled rejection:", e.reason);
  e.preventDefault();         // suppress browser console noise
});

Node.js

process.on("uncaughtException", (err) => {
  console.error("uncaughtException", err);
  // Best practice: log and exit; the process state is suspect
  process.exit(1);
});

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

Tip: Treat global handlers as a reporting mechanism, not a recovery one. Unknown errors mean unknown state — log and exit (or refresh the page) is safer than trying to continue.

Error in Loops — Don’t Stop Everything

When one item fails in a batch, you often want to keep going:

const results = await Promise.allSettled(items.map(processOne));

const failures = results
  .filter(r => r.status === "rejected")
  .map(r => r.reason);

const successes = results
  .filter(r => r.status === "fulfilled")
  .map(r => r.value);

if (failures.length) report(failures);
return successes;

Promise.allSettled is purpose-built for “do them all, tell me which failed”.

A Realistic Example — Validation Then API

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

function validateForm({ email, age }) {
  if (!email) throw new ValidationError("Email is required", "email");
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
    throw new ValidationError("Invalid email", "email");
  if (age != null && (age < 0 || age > 150))
    throw new ValidationError("Age out of range", "age");
}

async function submitForm(form) {
  try {
    validateForm(form);
    await api("/users", { method: "POST", body: form });
    showToast("Saved");
  } catch (err) {
    if (err instanceof ValidationError) {
      highlightField(err.field, err.message);
    } else {
      console.error(err);
      showToast("Save failed — please retry", "error");
    }
  }
}

Local validation throws specific errors; submitForm is the single decision point.

Common Mistakes

// ❌ Throwing strings
throw "broken";                // no stack, no .name

// ❌ Empty catch — silent failure
try { dangerous(); } catch {}

// ❌ Catching too broadly without re-throw
try { businessLogic(); }
catch (e) { /* logs only */ console.error(e); }
// app continues in unknown state

// ❌ try around an async call without await — the function returns the promise
try {
  asyncWork();                 // forgot await
} catch (err) { /* never reached */ }

// ❌ Throw inside event listener — bubbles to "error" event, not your try
btn.addEventListener("click", () => {
  try { /* outer */ }
  catch {}
  setTimeout(() => { throw new Error("never caught here"); }, 0);
});

Best Practices Checklist

  • ✅ Always throw new Error(...) (or a subclass), never a string or plain object
  • ✅ Catch at the boundary where you have enough context — not inside every helper
  • ✅ Use typed errors + instanceof for branching reactions
  • ✅ Chain with { cause } (ES2022) when re-throwing
  • ✅ Use try/catch around await — or a single trailing .catch for promise chains
  • ✅ Use Promise.allSettled when you want partial successes
  • ✅ Register error / unhandledrejection globals for reporting
  • ❌ Don’t swallow errors with empty catch {}
  • ❌ Don’t return/throw inside finally
  • ❌ Don’t rely on try/catch across async boundaries without await

What’s Next?

You can handle anything that goes wrong. Time to shift to organisation — building structured, reusable units with classes. Constructors, methods, static members, private fields, getters and setters.

← Previous: JS Fetch API Next: JS Classes →

Updated: