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 finally block runs no matter what — whether an error was thrown, whether catch rethrew, or whether try returned a value:
function getResource() {
let handle;
try {
handle = acquireResource();
return process(handle);
} finally {
if (handle) releaseResource(handle); // always runs
}
}
finally return value gotcha
If finally has its own return, it overrides whatever try or catch was planning to return:
function f() {
try {
return 1;
} finally {
return 2; // returns 2, not 1
}
}
This is rarely intentional. Watch out for it in cleanup code.
Async Error Handling
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.
Error Propagation Patterns
Guard clauses
Validate inputs at the top of a function so errors are descriptive from the 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.
See Also
- [/guides/js-error-handling/](Error Handling) — basic error concepts for JavaScript
- [/guides/javascript-promises-in-depth/](JavaScript Promises in Depth) — promises and their rejection model
- [/tutorials/js-async-callbacks-promises/](Async: Callbacks and Promises) — transitioning from callbacks to promises