cheat sheet

Error Handling

JavaScript error handling with try/catch/finally, built-in error types, custom error classes, error chaining with cause (ES2022), async errors, and practical patterns like result objects.

#javascript#errors#languageupdated 04-26-2026

Error Handling

What it is

JavaScript error handling uses try/catch/finally blocks with a hierarchy of built-in Error types, and supports custom error classes for domain-specific error hierarchies. ES2022 added error.cause for chaining errors without losing the original context. Proper error handling is critical in both browser and Node.js environments where async failures can be silently swallowed — Node has terminated unhandled promise rejections by default since v15, and modern browsers surface them in the console with no further action from your code.

The mental model worth absorbing: in JavaScript, throw accepts any value, but only Error instances carry a stack trace and integrate with developer tooling. Everything that follows in this page assumes you always throw an Error (or a subclass) — never a string, plain object, or undefined. Pair this with runtime validation (see Zod) at trust boundaries, and the language gives you most of what Rust's Result and Go's if err != nil get for free.

Error hierarchy

All built-in error classes inherit from Error, which itself inherits from Object. This is what makes err instanceof Error a reliable check across every native error type. Understanding the hierarchy explains why instanceof TypeError returns false for a RangeError but instanceof Error returns true for both.

text
Object
└── Error
    ├── TypeError
    ├── RangeError
    ├── ReferenceError
    ├── SyntaxError
    ├── URIError
    ├── EvalError
    ├── AggregateError
    └── (your custom Error subclasses)

In Node, several internal errors (SystemError, AssertionError) also extend Error, so they participate in the same hierarchy.

Built-in error types

TypeWhen it appears
ErrorGeneric base class; also used directly for application errors
TypeErrorWrong type: calling a non-function, accessing property on null/undefined
RangeErrorValue out of allowed range: new Array(-1), Number.toFixed(200)
ReferenceErrorAccessing a variable that does not exist
SyntaxErrorInvalid JavaScript syntax (parse time); also thrown by JSON.parse()
URIErrorInvalid argument to encodeURI() / decodeURI()
EvalErrorRarely seen; historically from eval() misuse
AggregateErrorMultiple errors bundled together; thrown by Promise.any() when all reject
javascript
new TypeError("Expected a string").name;   // "TypeError"
new RangeError("Out of bounds").name;      // "RangeError"
new ReferenceError("x is not defined").name; // "ReferenceError"

Node-specific system errors

Node adds a code property to errors thrown by node:fs, node:net, node:dns, and node:http. Match on code rather than parsing the message — codes are stable across Node versions, but messages are not.

javascript
import { readFile } from "node:fs/promises";

try {
  await readFile("missing.txt", "utf8");
} catch (err) {
  if (err.code === "ENOENT") console.log("File not found");
  else if (err.code === "EACCES") console.log("Permission denied");
  else throw err;
}

Output:

text
File not found

Common Node error codes:

CodeMeaning
ENOENTFile or directory not found
EACCESPermission denied
EEXISTFile already exists
EISDIRExpected a file, got a directory
ENOTDIRExpected a directory, got a file
ECONNREFUSEDConnection refused
ETIMEDOUTOperation timed out
EADDRINUSEPort already bound

try / catch / finally

Wrap code that might throw in a try block; the catch block receives the thrown value and handles or re-throws it; finally runs unconditionally — even when an error escapes the catch or a return fires inside try.

javascript
try {
  const data = JSON.parse(rawInput); // may throw SyntaxError
  process(data);
} catch (err) {
  console.error("Caught:", err.message);
} finally {
  cleanup(); // always runs, whether or not an error was thrown
}

finally runs even when:

  • No error is thrown
  • An error is thrown and caught
  • An error is thrown and not caught (error still propagates after finally)
  • There is a return inside try or catch
javascript
function example() {
  try {
    return "from try";
  } finally {
    console.log("finally runs before the function returns");
  }
}
// logs: "finally runs before the function returns"
// returns: "from try"

Error properties

Every Error instance exposes three standard properties: message (the human-readable description), name (the error class name, e.g. "TypeError"), and stack (a multi-line string with the call stack at the point the error was constructed). ES2022 added cause for chaining errors.

javascript
try {
  null.property; // TypeError
} catch (err) {
  console.log(err.message); // "Cannot read properties of null (reading 'property')"
  console.log(err.name);    // "TypeError"
  console.log(err.stack);   // Full stack trace string
}

Output:

text
Cannot read properties of null (reading 'property')
TypeError
TypeError: Cannot read properties of null (reading 'property')
    at Object.<anonymous> (/app/index.js:2:3)
    at Module._compile (node:internal/modules/cjs/loader:1364:14)
    ...

error.cause — error chaining (ES2022)

error.cause lets you attach the original error as context, preserving the full error chain without losing lower-level details.

javascript
async function fetchUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    return await res.json();
  } catch (err) {
    throw new Error(`Failed to fetch user ${id}`, { cause: err });
  }
}

try {
  await fetchUser(42);
} catch (err) {
  console.log(err.message);       // "Failed to fetch user 42"
  console.log(err.cause.message); // "Failed to fetch" (original network error)
}

Output:

text
Failed to fetch user 42
Failed to fetch

Custom error classes

Extend Error to create domain-specific error types. This enables instanceof checks and typed error handling. Three things to get right when subclassing Error:

  1. Always call super(message, options) — passing options propagates cause to the base.
  2. Set this.name to the class name — without it, the stack trace prints Error: instead of YourError:.
  3. Preserve the prototype chain in classic-script and older transpiled output (see the gotcha below).
javascript
class AppError extends Error {
  constructor(message, options) {
    super(message, options); // passes 'cause' through to Error
    this.name = "AppError";
  }
}

class NetworkError extends AppError {
  constructor(message, { statusCode, cause } = {}) {
    super(message, { cause });
    this.name = "NetworkError";
    this.statusCode = statusCode;
  }
}

class ValidationError extends AppError {
  constructor(message, { fields } = {}) {
    super(message);
    this.name = "ValidationError";
    this.fields = fields; // e.g. { email: "Invalid format" }
  }
}

Preserving the prototype chain (older transpiler targets)

When TypeScript targets ES5 or you transpile with Babel's class-extends preset, instanceof checks against Error subclasses can incorrectly return false. Fix by restoring the prototype explicitly:

javascript
class LegacyAppError extends Error {
  constructor(message) {
    super(message);
    this.name = "LegacyAppError";
    Object.setPrototypeOf(this, LegacyAppError.prototype);  // ES5 workaround
  }
}

If you target ES2015+ in your tsconfig.json / Babel config, you don't need this line. It is, however, harmless on modern targets.

Usage:

javascript
throw new NetworkError("Service unavailable", {
  statusCode: 503,
  cause: originalFetchError,
});

throw new ValidationError("Invalid input", {
  fields: { email: "Must be a valid email address" },
});

Type narrowing in catch

JavaScript catch always catches unknown. Narrow the type before using error-specific properties.

javascript
try {
  await riskyOperation();
} catch (err) {
  if (err instanceof NetworkError) {
    console.log("HTTP status:", err.statusCode);
    retryWithBackoff();
  } else if (err instanceof ValidationError) {
    showFieldErrors(err.fields);
  } else if (err instanceof Error) {
    console.error("Unexpected error:", err.message);
  } else {
    // Someone threw a non-Error (string, number, etc.)
    console.error("Unknown thrown value:", err);
  }
}

Re-throwing

Re-throw errors you cannot handle at the current level so they propagate to a higher handler.

javascript
async function loadConfig(path) {
  try {
    const raw = await fs.readFile(path, "utf8");
    return JSON.parse(raw);
  } catch (err) {
    if (err instanceof SyntaxError) {
      // We can handle malformed JSON — wrap and re-throw with context
      throw new Error(`Config file at ${path} contains invalid JSON`, { cause: err });
    }
    // We cannot handle file-not-found or permission errors here — re-throw as-is
    throw err;
  }
}

Async errors — try/catch with await

try/catch works identically with await:

javascript
async function run() {
  try {
    const data = await fetchData();       // rejected Promise → enters catch
    const result = await processData(data);
    return result;
  } catch (err) {
    console.error("Pipeline failed:", err.message);
    return null;
  }
}

Without await, the rejection bypasses the try/catch:

javascript
async function broken() {
  try {
    // BUG: missing await — rejection is not caught here
    fetchData().then(processData);
  } catch (err) {
    console.error("This never runs for fetch errors");
  }
}

Unhandled promise rejections

Unhandled rejections cause a warning in browsers and terminate the process in Node.js (since Node 15). Treat them as bugs — every Promise that can reject should have a .catch() or an await inside a try/catch somewhere upstream.

javascript
// Node.js — global handler (last resort; do not rely on this for normal flow)
process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled rejection at:", promise, "reason:", reason);
  process.exit(1);
});

// Browser
window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled promise rejection:", event.reason);
  event.preventDefault(); // prevent the default console warning
});

Uncaught exceptions (Node)

process.on('uncaughtException') is the synchronous twin of unhandledRejection. Once it fires, the process is in an undefined state — Node's official guidance is to log, flush, and exit rather than continue running. Frameworks like Fastify and Pino assume this contract.

javascript
process.on("uncaughtException", (err, origin) => {
  console.error(`Caught exception: ${err}`);
  console.error(`Exception origin: ${origin}`);
  // Best practice: log to a file/observability sink, then exit
  process.exit(1);
});

// Trigger it
setTimeout(() => {
  throw new Error("Async sync throw");
}, 100);

Output:

text
Caught exception: Error: Async sync throw
Exception origin: uncaughtException

uncaughtExceptionMonitor — log without overriding the default exit

If a tool (APM, error tracker) needs to observe but not change the default exit behaviour, use the Monitor variant:

javascript
process.on("uncaughtExceptionMonitor", (err) => {
  metrics.recordCrash(err);  // observe only
});
// Default crash behaviour still runs after this handler

Output: (none — exits 0 on success)

AggregateError

AggregateError wraps multiple errors. Thrown automatically by Promise.any() when all Promises reject; also useful for custom validators that collect multiple failures.

javascript
// Thrown by Promise.any() when all reject
try {
  await Promise.any([
    Promise.reject(new Error("CDN A failed")),
    Promise.reject(new Error("CDN B failed")),
  ]);
} catch (err) {
  console.log(err instanceof AggregateError); // true
  console.log(err.message);                   // "All promises were rejected"
  err.errors.forEach((e) => console.log(e.message));
}

Output:

text
true
All promises were rejected
CDN A failed
CDN B failed
javascript
// Manual use — collecting validation errors
function validateForm(data) {
  const errors = [];
  if (!data.name) errors.push(new Error("Name is required"));
  if (!data.email?.includes("@")) errors.push(new Error("Invalid email"));
  if (errors.length > 0) {
    throw new AggregateError(errors, "Form validation failed");
  }
}

Node.js Error.captureStackTrace

In Node.js (and any V8 runtime like Chrome, Edge, Bun), Error.captureStackTrace lets you remove internal constructor frames from the stack trace, so the stack starts at the caller rather than inside your error class.

javascript
class DatabaseError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = "DatabaseError";
    // Remove this constructor from the stack trace
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, DatabaseError);
    }
  }
}

Error.stackTraceLimit

V8 captures 10 frames by default. Raise it for deep async chains during debugging; drop it to 0 in performance-sensitive paths (capturing stacks is not free).

javascript
Error.stackTraceLimit = 50;       // capture more
Error.stackTraceLimit = Infinity; // capture everything
Error.stackTraceLimit = 0;        // no stack

V8 stack trace API

V8 exposes a structured stack via Error.prepareStackTrace — useful for source-map aware error reporters.

javascript
Error.prepareStackTrace = (err, stack) =>
  stack.map((frame) => ({
    file: frame.getFileName(),
    line: frame.getLineNumber(),
    fn: frame.getFunctionName(),
  }));

const err = new Error("boom");
console.log(err.stack);  // structured array, not a string

Output:

text
[
  { file: '/app/index.js', line: 7, fn: null },
  { file: 'node:internal/modules/cjs/loader', line: 1364, fn: 'Module._compile' }
]

Restore the default afterwards so the rest of your codebase still sees the formatted string:

javascript
delete Error.prepareStackTrace;

Practical pattern — error factory functions

Factory functions are a lightweight alternative to class hierarchies:

javascript
function createError(name, message, extra = {}) {
  const err = new Error(message);
  err.name = name;
  return Object.assign(err, extra);
}

const err = createError("NotFoundError", "User not found", { userId: 42 });
console.log(err.name);    // "NotFoundError"
console.log(err.userId);  // 42

Practical pattern — result object

Instead of throwing, return a discriminated union { ok, value, error }. Common in TypeScript codebases inspired by Rust's Result<T, E>.

javascript
async function safeParseUser(raw) {
  try {
    const data = JSON.parse(raw);
    if (!data.id || !data.name) {
      return { ok: false, error: new ValidationError("Missing required fields") };
    }
    return { ok: true, value: data };
  } catch (err) {
    return { ok: false, error: new Error("Invalid JSON", { cause: err }) };
  }
}

const result = await safeParseUser(rawInput);
if (!result.ok) {
  console.error("Parse failed:", result.error.message);
} else {
  console.log("User:", result.value);
}

Output (on success):

text
User: { id: 1, name: "Alice" }

Structured logging for errors

console.error(err) works in development but doesn't survive log aggregation — you lose the name, code, cause, and any extra fields you attached. Always serialize errors to a structured object before logging in production. Pino (used by Fastify) does this with its err serializer; you can replicate it manually:

javascript
function serializeError(err) {
  if (!(err instanceof Error)) return { error: String(err) };
  return {
    name: err.name,
    message: err.message,
    stack: err.stack,
    code: err.code,           // Node system errors
    cause: err.cause ? serializeError(err.cause) : undefined,
    ...err,                    // pick up custom fields like statusCode
  };
}

try {
  throw new NetworkError("Service unavailable", {
    statusCode: 503,
    cause: new Error("ETIMEDOUT"),
  });
} catch (err) {
  console.log(JSON.stringify(serializeError(err), null, 2));
}

Output:

text
{
  "name": "NetworkError",
  "message": "Service unavailable",
  "stack": "NetworkError: Service unavailable\n    at ...",
  "statusCode": 503,
  "cause": {
    "name": "Error",
    "message": "ETIMEDOUT",
    "stack": "Error: ETIMEDOUT\n    at ..."
  }
}

JSON.stringify(err) on its own returns "{}"Error properties are non-enumerable. The serializer above walks them explicitly.

React error boundaries

React's error boundaries catch render-phase errors in their child tree and let you swap in a fallback UI. They only catch synchronous render errors — event handlers, async callbacks, and SSR errors still need explicit try/catch.

jsx
import { Component } from "react";

class ErrorBoundary extends Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, errorInfo) {
    // Log to your observability sink
    console.error("Boundary caught:", error, errorInfo.componentStack);
  }

  render() {
    if (this.state.error) {
      return <div role="alert">Something went wrong: {this.state.error.message}</div>;
    }
    return this.props.children;
  }
}

// Usage
<ErrorBoundary>
  <DashboardPage />
</ErrorBoundary>;

Modern frameworks (Next.js, Remix, TanStack Router) ship route-level error boundaries that integrate with their nested layouts — prefer those over hand-rolled ones.

Runtime validation errors (Zod)

When data crosses a trust boundary (HTTP body, env vars, parsed JSON), validate it with Zod and wrap the failure in your domain error type. This gives you a single catch-shape per layer rather than mixing ZodError, SyntaxError, and network errors.

javascript
import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int().positive(),
  email: z.string().email(),
});

class InputError extends Error {
  constructor(message, { cause, fields } = {}) {
    super(message, { cause });
    this.name = "InputError";
    this.fields = fields;
  }
}

function parseUser(input) {
  const result = UserSchema.safeParse(input);
  if (!result.success) {
    throw new InputError("User validation failed", {
      cause: result.error,
      fields: result.error.flatten().fieldErrors,
    });
  }
  return result.data;
}

try {
  parseUser({ id: "not-a-number", email: "bad" });
} catch (err) {
  if (err instanceof InputError) {
    console.log("fields:", err.fields);
  }
}

Output:

text
fields: {
  id: [ 'Expected number, received string' ],
  email: [ 'Invalid email' ]
}

Common gotchas

Throwing non-Error values — any value can be thrown, but non-Error throws have no .stack and are harder to handle:

javascript
// Bad — no stack trace, no standard properties
throw "something went wrong";
throw { code: 404 };

// Good — always throw Error instances
throw new Error("something went wrong");
throw new RangeError("Value must be between 0 and 100");

catch variable shadowing — the err in catch shadows outer variables with the same name:

javascript
let err = "outer";
try {
  throw new Error("inner");
} catch (err) {
  console.log(err.message); // "inner"
}
console.log(err); // "outer" — outer variable is unchanged

finally overrides return — a return inside finally replaces the try or catch return value:

javascript
function tricky() {
  try {
    return "from try";
  } finally {
    return "from finally"; // this wins
  }
}
tricky(); // "from finally"

Lost cause after JSON round-tripJSON.stringify(err) returns "{}" because Error properties are non-enumerable. Use the structured serializer above when shipping errors over the wire.

Swallowing errors with empty catchtry { … } catch {} silences every error including the ones you didn't anticipate. Always log or re-throw; if you really want to ignore, narrow the type first.

javascript
// Bad — anything could be silently dropped
try { await doWork(); } catch {}

// Good — narrow then ignore the specific case
try {
  await doWork();
} catch (err) {
  if (!(err instanceof NotFoundError)) throw err;
}

instanceof across realmsinstanceof Error returns false for errors thrown in a different realm (iframe, Worker, vm context). Use Object.prototype.toString.call(err) === "[object Error]" for cross-realm checks, or wrap the value yourself.

Common pitfalls

  1. Throwing non-Error valuesthrow "string" or throw { code: 1 } produces no stack trace and breaks downstream instanceof checks. Always throw new Error(...).
  2. Forgetting await inside try — without await, the rejected promise escapes the catch. ESLint's no-floating-promises rule catches this.
  3. Catching too broadly at the top — top-level catches that just console.error swallow stack traces that observability tools could otherwise capture. Re-throw or rethrow-as-wrapper.
  4. Re-throwing without cause — wrapping an error and discarding the original loses the root cause. Always pass { cause: err } to the new constructor.
  5. process.exit() inside async code — exits before process.on("exit") handlers finish flushing logs. Prefer process.exitCode = 1; return; so the event loop drains naturally.
  6. Using Error.captureStackTrace in non-V8 runtimes — Safari and older Firefox don't implement it. The conditional check if (Error.captureStackTrace) is enough.
  7. unhandledRejection handler that doesn't exit — Node 15+ treats unhandled rejections as fatal by default. A custom handler that only logs creates a process that's "alive but corrupt." Log then process.exit(1).

Real-world recipes

Domain-specific error hierarchy with HTTP mapping

A common shape for HTTP services: each error class knows what status code it maps to, so the framework's error hook can render it uniformly.

javascript
class HttpError extends Error {
  constructor(message, { statusCode = 500, cause, details } = {}) {
    super(message, { cause });
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.details = details;
  }
}

class BadRequest extends HttpError {
  constructor(message, opts) { super(message, { ...opts, statusCode: 400 }); }
}
class NotFound extends HttpError {
  constructor(message, opts) { super(message, { ...opts, statusCode: 404 }); }
}
class Conflict extends HttpError {
  constructor(message, opts) { super(message, { ...opts, statusCode: 409 }); }
}

// Single error hook handles every domain error
function errorHandler(err, req, reply) {
  if (err instanceof HttpError) {
    return reply.code(err.statusCode).send({
      error: err.name,
      message: err.message,
      details: err.details,
    });
  }
  console.error(err);
  return reply.code(500).send({ error: "InternalError" });
}

Output: (none — exits 0 on success)

Result-returning utility with type discrimination

The { ok, value, error } shape composes well in TypeScript and lets the caller branch with a single if (!result.ok) instead of nested try/catch. Pair with as const to keep TS narrowing tight.

javascript
function tryCatch(fn) {
  try {
    return { ok: true, value: fn() };
  } catch (error) {
    return { ok: false, error };
  }
}

async function tryCatchAsync(promise) {
  try {
    return { ok: true, value: await promise };
  } catch (error) {
    return { ok: false, error };
  }
}

const result = await tryCatchAsync(fetch("/api/data").then((r) => r.json()));
if (!result.ok) {
  console.error("Failed:", result.error);
} else {
  console.log("Got:", result.value);
}

Output: (depends on response — exits 0 on success)

Retry policy that distinguishes retryable errors

Only retry transient failures (network, 5xx, rate limit). Permanent failures (4xx, validation) should bubble immediately.

javascript
const retryable = new Set(["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "EAI_AGAIN"]);

async function withRetry(fn, { retries = 3, baseMs = 100 } = {}) {
  for (let i = 0; i <= retries; i++) {
    try {
      return await fn();
    } catch (err) {
      const isRetryable =
        retryable.has(err.code) ||
        (err.statusCode >= 500 && err.statusCode <= 599);
      if (!isRetryable || i === retries) throw err;
      await new Promise((r) => setTimeout(r, baseMs * 2 ** i));
    }
  }
}

const data = await withRetry(() =>
  fetch("/api/flaky").then((r) => {
    if (!r.ok) {
      const e = new Error("HTTP error");
      e.statusCode = r.status;
      throw e;
    }
    return r.json();
  })
);

Output: (none — exits 0 on success)

Graceful shutdown on uncaught errors

Pair a fatal-error handler with timed cleanup so the process flushes buffered logs and closes sockets before exiting.

javascript
import { setTimeout as sleep } from "node:timers/promises";

let shuttingDown = false;

async function shutdown(reason, err) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.error(`Shutting down: ${reason}`, err);
  try {
    await Promise.race([
      Promise.all([server.close(), db.end()]),
      sleep(5000),  // hard cap
    ]);
  } finally {
    process.exit(1);
  }
}

process.on("uncaughtException", (err) => shutdown("uncaughtException", err));
process.on("unhandledRejection", (reason) => shutdown("unhandledRejection", reason));
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

Output: (none — exits 0 on success)

Error context decorator (Node AsyncLocalStorage)

Tag every error thrown during a request with the request ID so logs can be correlated across services.

javascript
import { AsyncLocalStorage } from "node:async_hooks";

const requestContext = new AsyncLocalStorage();

function tagError(err) {
  const ctx = requestContext.getStore();
  if (ctx && err instanceof Error) {
    err.requestId = ctx.requestId;
    err.userId = ctx.userId;
  }
  return err;
}

// In your framework's error hook
app.setErrorHandler((err, req, reply) => {
  const tagged = tagError(err);
  logger.error(tagged);
  return reply.code(500).send({ error: "internal", requestId: tagged.requestId });
});

// In your request lifecycle
app.addHook("onRequest", (req, reply, done) => {
  requestContext.run({ requestId: req.id, userId: req.user?.id }, () => done());
});

Output: (none — exits 0 on success)