Error Handling Patterns and Custom Errors
JavaScript gives you throw and catch, but what you do with them determines whether your code fails gracefully or crypticly. This guide covers the patterns that actually matter: custom error classes, async error handling, error wrapping with cause, and the gotchas that bite teams in production.
The error class hierarchy
Every built-in error in JavaScript inherits from Error.prototype. The most common types you’ll encounter:
| Error | When it occurs |
|---|---|
SyntaxError | Invalid syntax during parsing |
ReferenceError | Accessing an undefined variable |
TypeError | Value has the wrong type for the operation |
RangeError | Value is outside the acceptable range |
URIError | Malformed URI handling |
All of them share three properties: message (the description), name (the type), and stack (the call trace). Custom errors should too.
Custom error classes
The idiomatic way to create domain-specific errors in modern JavaScript is an ES6 class that extends Error:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
this.code = 'VALIDATION_ERROR';
Error.captureStackTrace(this, ValidationError);
}
}
The three-line constructor does specific work. super(message) sets this.message via the Error constructor. Setting this.name explicitly prevents it from defaulting to "Error". And Error.captureStackTrace(this, ValidationError) removes the constructor call itself from the stack trace — it gives callers a cleaner view of where the error originated.
One thing to note: Error.captureStackTrace is Node.js-only. In browsers, the stack trace is captured automatically and you can skip that line.
The Object.setPrototypeOf edge case
ES6 class inheritance makes instanceof Error work correctly in modern engines. But transpilers like Babel and some older environments can break the prototype chain. When that happens, myError instanceof MyCustomError returns false and myError.stack looks wrong.
If your errors need to travel across different environments, add this after super():
class AppError extends Error {
constructor(message) {
super(message);
Object.setPrototypeOf(this, AppError.prototype);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
Using this.constructor.name instead of a hardcoded string means you can rename the class without updating the name assignment.
The cause Property (ES2022)
Wrapping one error inside another is a common pattern, especially when you’re translating low-level failures into higher-level domain errors. Before ES2022, you had to invent a property name for this. Now there’s a standard:
class ParseError extends Error {
constructor(raw, cause) {
super(`Failed to parse "${raw}"`, { cause });
this.name = 'ParseError';
this.raw = raw;
}
}
function parseConfig(raw) {
try {
return JSON.parse(raw);
} catch (err) {
throw new ParseError(raw, err);
}
}
Callers can now reach err.cause to see what actually went wrong, which matters enormously when you’re debugging a production issue at 2 AM.
try-catch-finally
Syntax and scoping
The catch parameter is block-scoped in modern JavaScript (ES2019+). The error is only visible inside the catch block:
try {
JSON.parse('{ broken }');
} catch (err) {
console.error(err.message); // accessible here
}
// err is not accessible here
The block scoping of catch avoids common variable name collisions; the error variable won’t leak into surrounding code. Next, the finally block runs no matter what, whether an error was thrown, catch rethrew, or try returned a value normally:
function getResource() {
let handle;
try {
handle = acquireResource();
return process(handle);
} finally {
if (handle) releaseResource(handle); // always runs
}
}
A subtle trap with finally: if the finally block contains its own return, it overrides whatever value try or catch intended to return. The finally clause always runs last and its return takes precedence over any earlier return statement:
function f() {
try {
return 1;
} finally {
return 2; // returns 2, not 1
}
}
This is rarely intentional; watch for it if your cleanup logic includes early returns. With those fundamentals covered, let’s look at how asynchronous code changes the error handling equation entirely:
async/await with try-catch
async functions make error handling feel synchronous, which is usually a good thing:
async function loadUser(id) {
try {
const res = await fetch(`/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
console.error('Load failed:', err.message);
throw err; // rethrow to let callers handle it
}
}
The key part is throw err at the end of catch. If you forget to rethrow, the function silently returns undefined and the caller has no idea something went wrong.
Promise .catch() chains
When you’re working with promise chains instead of async functions, .catch() handles rejections at whatever point they occur:
fetch('/api/data')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.catch(err => {
if (err instanceof TypeError) {
console.error('Network or parse error:', err.message);
}
throw err;
});
One mistake to avoid: try { Promise.all([p1, p2]) } without await does not catch rejections. The try-catch only catches synchronous throws. You need await Promise.all([...]) for async error handling to work.
Top-level unhandled rejections
In Node.js, an unhandled promise rejection will crash the process (since Node 15). Always do one of these:
// Option 1: catch handler on every promise chain root
promiseChain().catch(err => {
console.error(err);
process.exit(1);
});
// Option 2: global safety net during development
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection:', reason);
});
In browsers, unhandledrejection fires on the window object, but unlike Node.js, it doesn’t crash the page. Now let’s look at patterns for deciding when to throw and when to return a sentinel value. Guard clauses are a good place to start:
function divide(a, b) {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new TypeError('Both arguments must be numbers');
}
if (b === 0) {
throw new RangeError('Divisor must not be zero');
}
return a / b;
}
This is better than letting typeof a / b produce NaN silently. Fail fast with a clear message.
When to throw vs return null
Not every failure is an exception. If “not found” is a normal, expected outcome, return null or undefined instead of throwing:
// Expected failure — not exceptional
function findUser(id) {
const user = db.users.get(id);
return user ?? null;
}
// Unexpected / unrecoverable — throw
function getConfig() {
if (!fs.existsSync('config.json')) {
throw new Error('config.json is missing — cannot start');
}
return JSON.parse(fs.readFileSync('config.json'));
}
The distinction matters for callers. If I call findUser(id) and get null, I handle it with an if-check. If I get an exception, I need a try-catch. Choose based on what callers actually need to do.
Common Mistakes
Swallowed errors. An empty catch block silently discards the error:
// Bad — error disappears without trace
catch (err) {}
// Better — log and decide whether to rethrow
catch (err) {
console.error(err);
throw err;
}
instanceof across contexts. Errors from iframes, web workers, or other realms don’t pass instanceof Error checks from the main context. Use error.name or error.constructor.name as a fallback.
Error serialization loss. When you send an error over postMessage or serialize it to JSON, only message survives. The stack, name, and custom properties are dropped. Plan for this when errors cross boundaries.
Empty messages. new Error('') is valid. Don’t assume err.message is non-empty when you’re logging.
Complete Example
Putting it together with a custom error class, cause chaining, and async handling:
class DatabaseError extends Error {
constructor(message, code, table, cause) {
super(message, { cause });
this.name = 'DatabaseError';
this.code = code;
this.table = table;
}
}
async function getUserOrders(userId) {
try {
const dbRes = await db.query(
'SELECT * FROM orders WHERE user_id = $1',
[userId]
);
return dbRes.rows;
} catch (err) {
throw new DatabaseError(
`Failed to fetch orders for user ${userId}`,
'QUERY_FAILED',
'orders',
err
);
}
}
getUserOrders(42).catch(err => {
if (err.cause) console.error('Root cause:', err.cause.message);
console.error(`[${err.code}] ${err.message}`);
// Root cause: connection refused
// [QUERY_FAILED] Failed to fetch orders for user 42
});
Notice how the caller gets both the high-level domain context (DatabaseError) and access to the root cause. That’s what good error hygiene looks like in practice.
Keep the boundary clear
Error handling gets easier when each layer owns a different level of detail. Low-level code can throw the specific failure it sees, while higher-level code can translate that into language the caller understands. That separation keeps logs useful and makes the public API easier to read. It also reduces the temptation to catch everything in one place and flatten the information into a generic message. A clear boundary gives you better failures and better debugging output.
Log once, decide once
When an error reaches a boundary where it can be logged or returned, be deliberate about what happens next. Logging the same error several times often creates noise without adding much value. Decide whether the current layer should handle the problem, wrap it, or rethrow it, and do that once. This makes production incidents easier to follow because the same stack is not repeated across every handler that touched it.
Use cause chains well
The cause property is most useful when the higher-level error adds context that the low-level error does not have. That usually means the wrapper should explain what the app was trying to do, while the cause preserves the technical detail. This gives you a useful story in the logs without losing the root message. It also helps during support work, because the first message tells you the business task and the cause tells you why the task failed.
Match the failure mode
Not every failure needs a thrown exception. If a missing record is a normal case, return null or a tagged result instead of using an error path. Reserve throws for situations where the caller really does need to stop and react. That distinction keeps the API honest and prevents exception handling from becoming the default answer to every edge case. Clear failure modes make code easier to use and easier to debug.
See Also
- Error Handling in JavaScript — basic error concepts for JavaScript
- JavaScript Promises in Depth — promises and their rejection model
- Async: Callbacks and Promises — transitioning from callbacks to promises