JavaScript Error Handling
Robust JavaScript handles errors on purpose — at the right boundary, with enough context, and without swallowing them silently. This chapter covers try/catch/finally, throwing your own errors, the built-in error types, custom error classes, async error patterns, and the global “uncaught” handlers that catch anything you missed.
try/catch/finally
try {
doRisky();
} catch (err) {
console.error("failed:", err.message);
} finally {
cleanup();
}
try— the protected blockcatch (err)— runs only if something throwsfinally— runs always (success, throw, or evenreturn)
You can omit catch (with just try/finally) or omit the binding (catch {}) in ES2019+.
try {
JSON.parse(input);
} catch { // don't care about the error
return null;
}
What Counts as Throwable
throw accepts any value, but you should always throw real Error objects (or subclasses) so you get a stack trace:
throw new Error("bad input"); // ✅ standard
throw new TypeError("expected a number"); // ✅ built-in subclass
throw "oops"; // ⚠ string — no stack, no .name
throw { code: 42 }; // ⚠ plain object — no stack
Tip: Always throw new Error(...) — never throw raw strings or objects. Stack traces and consistent .message are gold during debugging.
Built-In Error Types
| Type | When you’ll see it |
|---|---|
Error |
generic |
TypeError |
wrong type (null.foo, "x"()) |
ReferenceError |
undeclared name |
RangeError |
out of valid range (new Array(-1)) |
SyntaxError |
parse / JSON.parse failure |
URIError |
bad URI in decodeURI etc. |
EvalError |
(rare) |
AggregateError |
from Promise.any when all reject |
The Error constructor takes a message and optionally { cause } (ES2022) to chain a lower-level error:
try {
await fetch("/api");
} catch (origErr) {
throw new Error("Failed to load config", { cause: origErr });
}
The cause is preserved on .cause and printed in modern stack traces.
Custom Error Classes
Standard pattern when your app needs typed errors:
class ValidationError extends Error {
constructor(message, { field } = {}) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
class NotFoundError extends Error {
constructor(resource) {
super(`${resource} not found`);
this.name = "NotFoundError";
}
}
throw new ValidationError("Email is required", { field: "email" });
Catch by type with instanceof:
try {
saveUser(input);
} catch (err) {
if (err instanceof ValidationError) {
flashError(`${err.field}: ${err.message}`);
} else if (err instanceof NotFoundError) {
show404();
} else {
log(err); // unknown — bubble up or report
throw err;
}
}
Async Errors
Errors in async functions become promise rejections; try/catch works as expected:
async function loadUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new NotFoundError("user");
return await res.json();
} catch (err) {
log(err);
throw err; // re-throw so callers can react too
}
}
For chained .then, a single trailing .catch covers all earlier rejections:
fetchUser(1)
.then(u => fetchPosts(u.id))
.then(render)
.catch(showError); // catches all three
Where to Handle — The Boundary Principle
Don’t try/catch everywhere. Handle errors where you have enough context to do something useful:
- A leaf function (e.g.
parseConfig) should usually let exceptions propagate. - The orchestrating function (e.g.
bootApp) decides whether to recover, retry, or show a message.
// ❌ Defensive try/catch everywhere — error info is lost
function parse(text) {
try { return JSON.parse(text); } catch { return {}; }
}
function loadConfig() {
try { return parse(fs.readFileSync("./c.json")); } catch { return {}; }
}
// caller has no idea anything went wrong
// ✅ Let it bubble; one handler at the entry point
function parse(text) { return JSON.parse(text); }
function loadConfig() { return parse(fs.readFileSync("./c.json")); }
try { app.start(loadConfig()); }
catch (err) { console.error("startup failed:", err); process.exit(1); }
Re-throwing — Add Context
async function fetchProfile(id) {
try {
return await api(`/users/${id}/profile`);
} catch (err) {
throw new Error(`Failed to load profile for ${id}`, { cause: err });
}
}
A stack trace alone often isn’t enough — the wrapped message tells future-you what the program was trying to do.
finally Pitfalls
function f() {
try { return 1; }
finally { return 2; } // ⚠ overrides — function returns 2
}
async function g() {
try { throw new Error("e"); }
finally { return "fallback"; } // ⚠ swallows the error
}
Warning: return (or throw) inside finally overrides whatever the try/catch produced. Almost always a bug. Use finally only for cleanup that has no return value.
Global Catch-All Handlers
You should still set these as a last line of defence — for unexpected errors and unhandled rejections:
Browser
window.addEventListener("error", (e) => {
// Uncaught sync errors
console.error("uncaught:", e.error);
send("/log", { type: "error", message: e.message, stack: e.error?.stack });
});
window.addEventListener("unhandledrejection", (e) => {
console.error("unhandled rejection:", e.reason);
e.preventDefault(); // suppress browser console noise
});
Node.js
process.on("uncaughtException", (err) => {
console.error("uncaughtException", err);
// Best practice: log and exit; the process state is suspect
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
console.error("unhandledRejection", reason);
});
Tip: Treat global handlers as a reporting mechanism, not a recovery one. Unknown errors mean unknown state — log and exit (or refresh the page) is safer than trying to continue.
Error in Loops — Don’t Stop Everything
When one item fails in a batch, you often want to keep going:
const results = await Promise.allSettled(items.map(processOne));
const failures = results
.filter(r => r.status === "rejected")
.map(r => r.reason);
const successes = results
.filter(r => r.status === "fulfilled")
.map(r => r.value);
if (failures.length) report(failures);
return successes;
Promise.allSettled is purpose-built for “do them all, tell me which failed”.
A Realistic Example — Validation Then API
class ValidationError extends Error {
constructor(msg, field) {
super(msg);
this.name = "ValidationError";
this.field = field;
}
}
function validateForm({ email, age }) {
if (!email) throw new ValidationError("Email is required", "email");
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
throw new ValidationError("Invalid email", "email");
if (age != null && (age < 0 || age > 150))
throw new ValidationError("Age out of range", "age");
}
async function submitForm(form) {
try {
validateForm(form);
await api("/users", { method: "POST", body: form });
showToast("Saved");
} catch (err) {
if (err instanceof ValidationError) {
highlightField(err.field, err.message);
} else {
console.error(err);
showToast("Save failed — please retry", "error");
}
}
}
Local validation throws specific errors; submitForm is the single decision point.
Common Mistakes
// ❌ Throwing strings
throw "broken"; // no stack, no .name
// ❌ Empty catch — silent failure
try { dangerous(); } catch {}
// ❌ Catching too broadly without re-throw
try { businessLogic(); }
catch (e) { /* logs only */ console.error(e); }
// app continues in unknown state
// ❌ try around an async call without await — the function returns the promise
try {
asyncWork(); // forgot await
} catch (err) { /* never reached */ }
// ❌ Throw inside event listener — bubbles to "error" event, not your try
btn.addEventListener("click", () => {
try { /* outer */ }
catch {}
setTimeout(() => { throw new Error("never caught here"); }, 0);
});
Best Practices Checklist
- ✅ Always throw
new Error(...)(or a subclass), never a string or plain object - ✅ Catch at the boundary where you have enough context — not inside every helper
- ✅ Use typed errors +
instanceoffor branching reactions - ✅ Chain with
{ cause }(ES2022) when re-throwing - ✅ Use
try/catcharoundawait— or a single trailing.catchfor promise chains - ✅ Use
Promise.allSettledwhen you want partial successes - ✅ Register
error/unhandledrejectionglobals for reporting - ❌ Don’t swallow errors with empty
catch {} - ❌ Don’t
return/throwinsidefinally - ❌ Don’t rely on
try/catchacross async boundaries withoutawait
What’s Next?
You can handle anything that goes wrong. Time to shift to organisation — building structured, reusable units with classes. Constructors, methods, static members, private fields, getters and setters.