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.
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.
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
| Type | When it appears |
|---|---|
Error | Generic base class; also used directly for application errors |
TypeError | Wrong type: calling a non-function, accessing property on null/undefined |
RangeError | Value out of allowed range: new Array(-1), Number.toFixed(200) |
ReferenceError | Accessing a variable that does not exist |
SyntaxError | Invalid JavaScript syntax (parse time); also thrown by JSON.parse() |
URIError | Invalid argument to encodeURI() / decodeURI() |
EvalError | Rarely seen; historically from eval() misuse |
AggregateError | Multiple errors bundled together; thrown by Promise.any() when all reject |
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.
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:
File not found
Common Node error codes:
| Code | Meaning |
|---|---|
ENOENT | File or directory not found |
EACCES | Permission denied |
EEXIST | File already exists |
EISDIR | Expected a file, got a directory |
ENOTDIR | Expected a directory, got a file |
ECONNREFUSED | Connection refused |
ETIMEDOUT | Operation timed out |
EADDRINUSE | Port 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.
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
returninsidetryorcatch
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.
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:
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.
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:
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:
- Always call
super(message, options)— passingoptionspropagatescauseto the base. - Set
this.nameto the class name — without it, the stack trace printsError:instead ofYourError:. - Preserve the prototype chain in classic-script and older transpiled output (see the gotcha below).
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:
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:
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.
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.
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:
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:
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.
// 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.
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:
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:
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.
// 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:
true
All promises were rejected
CDN A failed
CDN B failed
// 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.
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).
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.
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:
[
{ 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:
delete Error.prepareStackTrace;
Practical pattern — error factory functions
Factory functions are a lightweight alternative to class hierarchies:
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>.
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):
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:
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:
{
"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"{}"—Errorproperties 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.
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.
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:
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:
// 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:
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:
function tricky() {
try {
return "from try";
} finally {
return "from finally"; // this wins
}
}
tricky(); // "from finally"
Lost cause after JSON round-trip — JSON.stringify(err) returns "{}" because Error properties are non-enumerable. Use the structured serializer above when shipping errors over the wire.
Swallowing errors with empty catch — try { … } 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.
// 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 realms — instanceof 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
- Throwing non-Error values —
throw "string"orthrow { code: 1 }produces no stack trace and breaks downstreaminstanceofchecks. Alwaysthrow new Error(...). - Forgetting
awaitinsidetry— withoutawait, the rejected promise escapes the catch. ESLint'sno-floating-promisesrule catches this. - Catching too broadly at the top — top-level catches that just
console.errorswallow stack traces that observability tools could otherwise capture. Re-throw or rethrow-as-wrapper. - Re-throwing without
cause— wrapping an error and discarding the original loses the root cause. Always pass{ cause: err }to the new constructor. process.exit()inside async code — exits beforeprocess.on("exit")handlers finish flushing logs. Preferprocess.exitCode = 1; return;so the event loop drains naturally.- Using
Error.captureStackTracein non-V8 runtimes — Safari and older Firefox don't implement it. The conditional checkif (Error.captureStackTrace)is enough. unhandledRejectionhandler 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 thenprocess.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.
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.
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.
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.
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.
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)