18 — Error Handling Architecture: Custom Errors, Async & Stack Traces
The Error Object Internals
javascript
// ── The Error object has: name, message, stack (and optionally cause) ──
const err = new Error("something went wrong");
console.log(err.name); // "Error"
console.log(err.message); // "something went wrong"
console.log(err.stack); // "Error: something went wrong\n at file.js:1:13\n at ..."
// stack is a string (non-standard format, varies by engine)
// In V8: includes the call stack frames
// In modern engines: stack is a getter (lazily computed, can be modified)
// ── Error subclasses (built-in) ──
// TypeError, RangeError, SyntaxError, ReferenceError, URIError, EvalError
try { null.x } catch (e) { console.log(e instanceof TypeError); } // true
try { [].length = -1 } catch (e) { console.log(e instanceof RangeError); } // true
try { eval("if (true) {") } catch (e) { console.log(e instanceof SyntaxError); } // true
try { undeclaredVar } catch (e) { console.log(e instanceof ReferenceError); } // true
// ── Error.cause (ES2022) — chain errors for better debugging ──
try {
JSON.parse(invalidJson);
} catch (originalError) {
throw new Error("Failed to parse config file", { cause: originalError });
// cause preserves the original error (stack trace chain)
}
// Caller can access: err.cause → the original SyntaxError
Custom Error Hierarchy
javascript
// ── Custom error classes for domain-specific errors ──
class AppError extends Error {
constructor(message, { code, statusCode, cause } = {}) {
super(message, { cause });
this.name = this.constructor.name; // set name to the subclass name
this.code = code; // machine-readable error code
this.statusCode = statusCode; // HTTP status code (for APIs)
Error.captureStackTrace?.(this, this.constructor); // V8: clean stack (skip this frame)
}
}
class ValidationError extends AppError {
constructor(message, { field, cause } = {}) {
super(message, { code: "VALIDATION_ERROR", statusCode: 400, cause });
this.field = field; // which field failed validation
}
}
class NotFoundError extends AppError {
constructor(resource, id) {
super(`${resource} not found: ${id}`, {
code: "NOT_FOUND",
statusCode: 404,
});
this.resource = resource;
this.id = id;
}
}
class DatabaseError extends AppError {
constructor(message, { query, cause } = {}) {
super(message, { code: "DATABASE_ERROR", statusCode: 500, cause });
this.query = query;
}
}
// ── Usage with instanceof (hierarchical error handling) ──
function handleApiError(error) {
if (error instanceof ValidationError) {
return { status: 400, error: { code: error.code, message: error.message, field: error.field } };
}
if (error instanceof NotFoundError) {
return { status: 404, error: { code: error.code, message: error.message } };
}
if (error instanceof DatabaseError) {
console.error("DB error:", error.cause); // log the original cause
return { status: 500, error: { code: "INTERNAL_ERROR", message: "database error" } };
}
// Fallback for unknown errors
console.error("unexpected error:", error);
return { status: 500, error: { code: "INTERNAL_ERROR", message: "unexpected error" } };
}
// ── Throwing and catching ──
try {
throw new ValidationError("invalid email", { field: "email" });
} catch (error) {
if (error instanceof ValidationError) {
console.log("validation failed:", error.field); // "email"
}
}
Async Error Handling
javascript
// ── try/catch with await (catches async errors) ──
async function fetchData(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
if (error instanceof TypeError) {
// Network error (fetch failed entirely — CORS, DNS, offline)
throw new AppError("Network request failed", { code: "NETWORK_ERROR", cause: error });
}
throw error; // re-throw HTTP errors and parse errors
}
}
// ── Promise.catch for error handling (alternative to try/catch) ──
fetchData(url)
.then(data => console.log(data))
.catch(error => console.error("failed:", error));
// ── Multiple error types in one catch ──
async function processData() {
try {
const data = await fetchData("/api/data");
const validated = validate(data); // might throw ValidationError
const saved = await save(validated); // might throw DatabaseError
return saved;
} catch (error) {
if (error instanceof ValidationError) {
// Validation error — 400
throw error;
}
if (error instanceof DatabaseError) {
// DB error — 500
throw error;
}
if (error instanceof TypeError) {
// Network error — 503
throw new AppError("service unavailable", { code: "NETWORK_ERROR", statusCode: 503, cause: error });
}
// Unknown error — re-throw
throw error;
}
}
// ── `finally` with async (cleanup that always runs) ──
async function withDatabase() {
const client = await connectDB();
try {
return await client.query("SELECT * FROM users");
} finally {
await client.disconnect(); // always runs (even if query throws)
}
}
Anti-Pattern: Swallowing Errors
javascript
// ❌ NAIVE — empty catch swallows errors (makes debugging impossible)
try {
JSON.parse(data);
} catch (e) {
// silently ignored — no log, no re-throw, no handling
}
// If the parse fails, the caller has no idea something went wrong.
// ❌ ALSO BAD — catch with console.log but no re-throw
try {
JSON.parse(data);
} catch (e) {
console.log("error:", e); // logs but continues with undefined data
}
// The function continues with `data` being undefined — downstream code breaks.
// ✅ CORRECT — handle, re-throw, or provide a fallback
try {
return JSON.parse(data);
} catch (e) {
// Option 1: re-throw with context
throw new Error("Failed to parse data", { cause: e });
// Option 2: return a safe default
// return {};
// Option 3: log and re-throw
// console.error("parse failed:", e); throw e;
}
Global Error Handlers
javascript
// ── Browser: window.onerror (catches uncaught synchronous errors) ──
window.addEventListener("error", (event) => {
// event.error: the Error object
// event.message, event.filename, event.lineno, event.colno
console.error("Uncaught error:", event.error);
// Send to error reporting service (Sentry, etc.)
reportError(event.error);
event.preventDefault(); // prevent the default error console output
});
// ── Browser: unhandledrejection (catches uncaught Promise rejections) ──
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled rejection:", event.reason);
reportError(event.reason);
event.preventDefault(); // prevent console warning
});
// ── Node.js: process error events ──
// process.on("uncaughtException", (error) => {
// console.error("uncaught:", error);
// // ⚠️ Don't continue running — the app is in an inconsistent state.
// // Best practice: log, cleanup, and exit.
// process.exit(1);
// });
// process.on("unhandledRejection", (reason) => {
// console.error("unhandled rejection:", reason);
// process.exit(1);
// });
// ── Error boundary pattern (React-style) ──
class ErrorBoundary {
constructor() {
this.handlers = [];
}
catch(fn) {
try {
return fn();
} catch (error) {
for (const handler of this.handlers) {
if (handler.canHandle(error)) return handler.handle(error);
}
throw error;
}
}
addHandler(handler) { this.handlers.push(handler); }
}
💡 Tips & Tricks
javascript
// ── Error.cause for error chains (ES2022) ──
try {
await fetch(url);
} catch (networkError) {
throw new Error("API call failed", { cause: networkError });
// The original error is preserved in .cause — full debug trail
}
// ── Error.captureStackTrace (V8/Node) for clean stack traces ──
class MyError extends Error {
constructor(message) {
super(message);
Error.captureStackTrace(this, MyError); // skips the MyError constructor frame
}
}
// ── `throw` can throw anything (not just Error objects) ──
// throw "string error"; // works but bad practice (no stack trace)
// throw { code: 42 }; // works but bad practice
// ALWAYS throw an Error (or subclass) — it has a stack trace.
// If you catch non-Error throws: try { ... } catch (e) { if (e instanceof Error) ... }
// ── AggregateError (ES2021) — multiple errors in one ──
const errors = [
new Error("error 1"),
new Error("error 2"),
];
throw new AggregateError(errors, "multiple failures");
// catch: err.errors → [Error("error 1"), Error("error 2")]
// ── Structured error for API responses ──
function toApiError(error) {
return {
error: {
code: error.code ?? "UNKNOWN",
message: error.message ?? "An error occurred",
field: error.field,
...(process.env.NODE_ENV === "development" && { stack: error.stack }),
},
};
}
⚠️ Edge Cases & Gotchas
javascript
// ── `throw` can throw any value (not just Error) ──
// throw "string"; // catch(e) → e is "string" (no .stack, no .message)
// Always throw new Error() or a subclass — for stack traces and consistency.
// In catch: if (e instanceof Error) ... else // handle non-Error throws
// ── `finally` runs even after return (and can override the return value) ──
function f() {
try { return "try"; }
finally { return "finally"; } // overrides "try" — returns "finally"
}
// Don't return from finally — it silently swallows errors and return values.
// ── Async errors don't propagate to the caller's try/catch without await ──
async function f() { throw new Error("async error"); }
try {
f(); // doesn't throw — returns a rejected Promise (not awaited)
} catch (e) {
console.log("caught"); // NOT reached — the rejection is unhandled
}
// Fix: await f() or f().catch(...)
// ── `catch` without a parameter (ES2019) ──
try { JSON.parse(data) } catch { console.log("parse failed") } // no (e) needed
// ── Error.stack is non-standard (format varies by engine) ──
// V8 (Chrome/Node): multi-line string with function names and positions
// SpiderMonkey (Firefox): different format
// Don't parse .stack programmatically — use Error.prepareStackTrace (V8) or source maps.
// ── `Error.captureStackTrace` is V8-only (not in Firefox/Safari) ──
// Use optional chaining: Error.captureStackTrace?.(this, constructor)
// ── `instanceof Error` is false for non-Error throws ──
try { throw "string" } catch (e) { console.log(e instanceof Error); } // false
🧠 Quick Quiz
What's the output?
javascript
async function test() {
try {
throw new Error("A");
} catch (e) {
console.log("B", e.message);
throw new Error("C");
} finally {
console.log("D");
}
}
test().catch(e => console.log("E", e.message));
Answer
B A
D
E C
test()is called. Inside:throw new Error("A")→ caught bycatch.B A— catch logs"B"and the error message"A".throw new Error("C")— re-throws a new error.finallyruns (always): logs"D".- The function returns a rejected Promise (Error("C") was thrown).
.catch(e => ...)catches the rejection: logs"E C".
The lesson: finally always runs (even after a throw in catch). The throw new Error("C") in catch propagates as a rejected Promise, caught by .catch() in the caller.