Errors and Error Handling
Always throw Error objects (or subclasses) — never strings — so you get a .stack and instanceof works. Build custom error classes by extending Error for typed handling. Use the ES2022 cause option to chain errors without losing the original. Know the transpilation gotcha that breaks instanceof on subclassed errors, and the difference between sync try/catch and async error propagation.
Why throw Error instead of a string/object?
throw "boom" gives the catcher a bare string — no stack trace, no name, and instanceof Error is false. An Error captures a stack at construction and integrates with reporters and unhandledrejection. Throwing non-Errors is a code smell.
How do you make a custom error class?
class ValidationError extends Error {
constructor(message, field) {
super(message);
// so it prints as ValidationError, not Error
this.name = "ValidationError";
this.field = field;
}
}
try {
throw new ValidationError("Email invalid", "email");
} catch (e) {
if (e instanceof ValidationError) handleField(e.field);
// rethrow what you don't own
else throw e;
}Set this.name explicitly — it doesn’t default to the class name. Add structured fields (field, code, statusCode) for typed handling instead of string-matching e.message.
What is the cause option (ES2022)?
It chains a low-level error to a higher-level one without discarding context:
try {
await db.query(sql);
} catch (err) {
throw new Error("Failed to load user", { cause: err }); // preserves the original
}The catcher can inspect e.cause for the root cause; reporters print both. Before cause, people stuffed the original into the message or a custom field — cause standardizes it.
What’s the instanceof gotcha with subclassed errors?
When targeting old environments, transpilers (Babel/TS down-level to ES5) couldn’t properly subclass the built-in Error, so new MyError() instanceof MyError could be false. The fix is Object.setPrototypeOf(this, MyError.prototype) in the constructor — or simply target ES2015+ where extends Error works natively (the common modern case). Worth naming because it explains “why does my custom error catch as a plain Error?”
Error.captureStackTrace?
A V8-only API to control where the stack starts, hiding framework frames so the trace points at the user’s call site:
function assert(cond, msg) {
if (!cond) {
const err = new Error(msg);
if (Error.captureStackTrace) Error.captureStackTrace(err, assert); // omit `assert` itself
throw err;
}
}Used by assertion/validation libraries to keep traces clean. Not available in Safari/Firefox — feature-detect.
How does error handling differ sync vs async?
- Sync:
try/catchcatches throws in the same call stack. async/await:try/catchworks becauseawaitturns rejections into throws.- Promises without
await: atry/catchwill not catch a rejection from a promise you didn’t await — it escapes tounhandledrejection(Promises — Advanced). - Callbacks/timers: a throw inside a
setTimeoutcallback can’t be caught by thetry/catcharound thesetTimeoutcall — different tick, different stack.
try { setTimeout(() => { throw new Error("x"); }, 0); } catch (e) {} // does NOT catchWhat is AggregateError?
The error type Promise.any rejects with when all inputs reject — it bundles them in .errors. You can also throw it yourself to represent multiple failures (e.g., a batch where several items failed).
Gotchas
finallycan swallow errors — areturnorthrowinsidefinallyoverrides whatever thetry/catchwas doing. Keepfinallyto cleanup.- Catching and ignoring (
catch (e) {}) hides bugs — at minimum log; rethrow what you don’t handle. catchwithout a binding (catch {}) is valid ES2019 when you don’t need the error — but usually you do.- Errors lose their type across
postMessage/structured clone —Erroris cloneable but custom fields/subclass identity may not survive worker boundaries; serialize intentionally. error.messageis for humans — branch onerror.code/instanceof, not on message text (messages get reworded, localized).- Rejecting with a non-Error in your own promise propagates a value with no stack — construct an
Error.
Related
Further reading
- MDN —
Error: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error - MDN — Error
cause: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause - V8 — Stack trace API: https://v8.dev/docs/stack-trace-api
Interview angle 4
- “How should errors be typed in a codebase?” - as
Errorsubclasses with an explicitnameand structured fields, so callers branch on the type or a code rather than on the message text. Matching on message strings breaks the first time someone improves the wording. - “How do you keep the original error when you wrap one?” - the
causeoption:throw new AppError("checkout failed", { cause: err }). It chains without discarding what is underneath, and reporters render the full chain, which is the difference between “checkout failed” and knowing which query timed out. - “Why did your
try/catchnot catch it?” - because the throw happened in a different task. A callback passed tosetTimeoutruns later on a clean stack, and an un-awaited promise rejects intounhandledrejection, not into thecatcharound the call that created it.- Follow-up: “So how do you catch those?” - top-level
window.onerrorandunhandledrejectionhandlers wired to the reporter. Every real application needs both, because they are the only place a whole class of failures shows up at all.
- Follow-up: “So how do you catch those?” - top-level
- “Subclassing
Errorused to breakinstanceof. Why?” - a transpilation artifact from targeting ES5, where the subclass prototype was lost. Fixed withObject.setPrototypeOf(this, new.target.prototype)in the constructor, or by targeting ES2015 or later, which is the real answer in 2026.