Error Handling
Prerequisites
This chapter assumes you are comfortable with functions, control flow, and the difference between synchronous and asynchronous code. You should understand that JavaScript has a single call stack and that promise rejection is the asynchronous analogue of a thrown exception. Familiarity with class syntax is needed for the custom error section.
The shape of a failure
JavaScript failures fall into three categories, and each needs a different response:
- Runtime errors — thrown by the engine (
TypeError,RangeError,SyntaxError,ReferenceError) or by your code viathrow. - Promise rejections — an exception that escaped an async function or was passed to
Promise.reject. They travel through the promise chain rather than the call stack. - Unexpected termination —
uncaughtException/unhandledrejectionevents, or a process-level crash. These are last-resort signals, not handling mechanisms.
The engine throws TypeError when you access a property of null or undefined, RangeError when a value is out of a valid domain, and ReferenceError when an identifier cannot be resolved. Errors thrown by user code are conventionally instances of Error or a subclass.
throw, try, catch, finally
function parsePort(raw) {
const port = Number(raw);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new RangeError(`invalid port: ${JSON.stringify(raw)}`);
}
return port;
}
try {
const port = parsePort(process.argv[2] ?? '8080');
console.log('using port', port);
} catch (err) {
console.error('startup failed:', err.message);
process.exitCode = 1;
} finally {
console.log('parse attempt finished');
}throw can technically throw any value, but never do: throw 'string' produces a rejection with no stack and no .name. Always throw Error instances. finally runs whether the block exits normally, via a return, or via a thrown error — which makes it the wrong place to swallow errors but the right place to release resources (close handles, clear timers). Note that a return inside finally silently discards a pending exception; treat that as a bug.
Rethrowing and the stack
err.stack is captured when the Error is constructed, not when thrown. Two common gotchas:
- Calling
throw errin acatchpreserves the original stack — good. Creating a freshnew Error(...)discards it. To add context while preserving causality, usecause:
try {
await fetch('https://api.example.com/v1/users');
} catch (err) {
throw new Error('failed to load users', { cause: err });
}finallyblocks execute while the stack unwinds, so areturninfinallyoverwrites both the return value and any pending throw.
Custom error types
Subclassing Error gives you instanceof-based dispatch, which is far more robust than string matching on message:
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = 'ValidationError';
this.field = field;
}
}
class NetworkError extends Error {
constructor(message, status) {
super(message);
this.name = 'NetworkError';
this.status = status;
}
}
async function getUser(id) {
const res = await fetch(`https://api.example.com/users/${id}`);
if (res.status === 404) {
throw new NetworkError('user not found', 404);
}
if (!res.ok) {
throw new NetworkError(`status ${res.status}`, res.status);
}
return res.json();
}
try {
const user = await getUser(42);
console.log(user);
} catch (err) {
if (err instanceof NetworkError && err.status === 404) {
console.warn('no such user; showing empty state');
} else {
throw err; // not ours to handle — propagate
}
}One subtlety: when targeting ES5 transpilation, subclasses of Error historically lost the proper prototype and stack behavior. Modern engines and ES2022 extends Error are fine, but if your code passes through old Babel targets, instanceof checks can fail. Verify with a test.
Asynchronous errors
Inside an async function, a thrown error becomes a rejected promise automatically:
async function main() {
try {
await getUser(1);
} catch (err) {
console.error('caught via try/catch:', err.message);
}
}The dangerous cases are the ones that escape:
- A promise with no
catchand noawait— an unhandled rejection. In Node, this crashes the process by default; in browsers it is logged and surfaces viawindow.onunhandledrejection. Promise.allrejects fast on the first failure but leaves other promises running; failures you did not observe are unhandled. UseallSettledwhen you need every outcome, or attach a.catch(() => {})to fire-and-forget work deliberately.- Synchronous
throwinside a plain (non-async) callback passed to an async API cannot be caught by the caller'stry/catch; it must be converted to a rejection inside the API.
Catching at boundaries, not everywhere
An advanced codebase handles errors at architectural boundaries and lets them propagate through the middle:
- Validate and throw at input edges (parsing, API handlers).
- Propagate through internal layers untouched — do not
catch (e) { console.log(e) }and continue; that converts a crash into silent corruption. - Translate only at boundaries where a different representation is needed (HTTP status, user-facing message), attaching the original as
cause. - Log once. Wrapping and re-logging at every layer produces duplicate stacks and obscures the origin.
For global safety nets, register process.on('unhandledRejection') (Node) or window.addEventListener('unhandledrejection', ...) (browser) — but treat a hit on those handlers as a bug in coverage, not an expected path.
Exercise
Why is throw new Error('x', { cause: err }) preferred over throw err after logging, and over throwing a brand-new error with the same message?
Show answer
Throwing the original error loses the high-level context added at this layer; throwing a fresh error with the same message discards the original stack entirely. Wrapping with cause preserves the full original stack and message while adding the new context, so a top-level handler can print the whole chain.
Exercise
A handler does saveToDb(record); res.json({ ok: true }); where saveToDb is async and returns a promise that rejects on a constraint violation. Why does the response still say ok: true, and how do you fix it?
Show answer
The promise is created but never awaited or given a .catch, so the rejection is unhandled and the synchronous code continues immediately. Fix by await-ing the promise inside a try/catch (or attaching .catch if it is intentionally fire-and-forget) and returning an error response on failure.
Spotted a mistake, or code that does not work?