The Error object hierarchy
JavaScript has several built-in error types โ all extend Error:
Error
โโโ SyntaxError โ code couldn't be parsed
โโโ ReferenceError โ accessing an undeclared variable
โโโ TypeError โ wrong type for operation (null.property)
โโโ RangeError โ value outside valid range (new Array(-1))
โโโ URIError โ bad URI encoding/decoding
โโโ EvalError โ (legacy, rarely seen)
Every Error has name, message, and stack. stack is non-standard but universally implemented.
try / catch / finally semantics
console.log. Async logs (setTimeout/Promise) appear in real execution order.finally always runs โ even if the try block returns, or catch throws. Use it for cleanup (closing connections, hiding spinners). If finally has a return, it overrides any earlier return.
Async error propagation
The key insight: async/await unifies error handling between sync and async code โ await converts rejections into thrown exceptions.
console.log. Async logs (setTimeout/Promise) appear in real execution order.// async/await โ identical semantics, cleaner syntax
async function fetchData(url) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return await res.json();
} catch (err) {
// NetworkError from fetch, HTTP errors we threw, JSON parse errors โ all caught here
console.error('fetch failed:', err);
throw err; // re-throw so caller knows it failed
}
}
async function bad() {
try {
return fetch('/api/data'); // โ missing await!
} catch (err) {
// This catch will NEVER trigger โ the rejection happens after return
}
}Without await, the try/catch wraps only the synchronous call to fetch(), not the async operation. The rejection propagates outside the function entirely. Always await inside try.
Handling parallel async work
// Promise.all โ one failure fails everything (fail-fast)
try {
const [user, posts] = await Promise.all([fetchUser(id), fetchPosts(id)]);
} catch (err) {
// if either fails, we land here โ we don't know which one failed
}
// Promise.allSettled โ never rejects; you inspect each result
const results = await Promise.allSettled([fetchUser(id), fetchPosts(id)]);
for (const result of results) {
if (result.status === 'fulfilled') process(result.value);
else log(result.reason); // per-item error, doesn't abort the others
}
Custom Error classes
Extend Error to add domain-specific context and enable instanceof checks:
class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
) {
super(message);
this.name = this.constructor.name; // 'AppError'
// Fix prototype chain in TypeScript when targeting ES5
Object.setPrototypeOf(this, new.target.prototype);
}
}
class ValidationError extends AppError {
constructor(message: string, public readonly field: string) {
super(message, 'VALIDATION_ERROR', 400);
}
}
class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 'NOT_FOUND', 404);
}
}
// In a handler:
try {
await processRequest(data);
} catch (err) {
if (err instanceof ValidationError) {
res.status(400).json({ error: err.message, field: err.field });
} else if (err instanceof NotFoundError) {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: 'Internal server error' });
logger.error(err); // log the unexpected one
}
}
Unhandled rejections
A rejected promise with no .catch() (or try/catch) is an unhandled rejection. In browsers it fires window.unhandledrejection; in Node.js it fires process.on('unhandledRejection') and will terminate the process in newer versions.
// Global safety net (last resort โ still fix the root cause)
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled rejection at:', promise, 'reason:', reason);
process.exit(1);
});
The Result pattern (TypeScript)
In TypeScript, some teams prefer a discriminated union return type over throw, making errors part of the functionโs type contract:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function safeParseUser(id: string): Promise<Result<User>> {
try {
const user = await db.findUser(id);
return { ok: true, value: user };
} catch (err) {
return { ok: false, error: err as Error };
}
}
const result = await safeParseUser(id);
if (result.ok) {
render(result.value); // TypeScript knows value is User
} else {
showError(result.error.message);
}
try/catch/finally catches thrown values; finally always runs for cleanup. In async code, await converts rejections to thrown exceptions โ but only if you actually await inside the try. Promise.allSettled is the right choice when you want each result independently regardless of failures. I create custom error subclasses to add typed context and enable instanceof discrimination in handlers. Unhandled rejections crash Node processes in modern versions, so every promise chain needs a .catch or an encompassing try/catch.โ