Error handling
JavaScript admits exceptions as the principal error-handling mechanism — throw a value (conventionally an Error), catch in a try/catch block. The principal builtins: Error, TypeError, RangeError, SyntaxError, ReferenceError, URIError, EvalError. Custom errors extend Error. For async errors, try/catch works around await; unhandled Promise rejections fire unhandledrejection. Browser-specific patterns: defensive UI (validation), user-visible errors (toasts, error boundaries in frameworks), telemetry (window.onerror, unhandledrejection). The combination — try/catch with Error-derived classes, async/await integration, the conventional custom-error pattern, the substantial top-level error reporting (onerror, unhandledrejection), the substantial framework error boundaries — is the substance of JavaScript error handling.
throw and try/catch
function divide(a, b) {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
}
try {
const result = divide(10, 0);
} catch (err) {
console.error(err.message);
} finally {
console.log("done");
}
The principal points:
throwadmits any value but should be anError.catchbinds the thrown value to a parameter (parameter optional in modern JS).finallyruns regardless of throw or no-throw.- Stack frames unwind through enclosing function calls until caught.
// Optional binding (ES2019):
try {
risky();
} catch {
// err not used
}
Error and built-in subclasses
new Error("message");
new TypeError("not a function");
new RangeError("out of range");
new SyntaxError("...");
new ReferenceError("undefined variable");
new URIError("malformed URI");
new EvalError("eval error");
new AggregateError([err1, err2], "all rejected");
// Properties:
const err = new Error("oops");
err.message; // "oops"
err.name; // "Error"
err.stack; // multi-line stack trace (non-standard but ubiquitous)
err.cause; // ES2022 — chained error
Error cause
try {
await doWork();
} catch (err) {
throw new Error("Failed to process request", { cause: err });
}
// Reading:
try {
handler();
} catch (err) {
console.log(err.message);
console.log(err.cause?.message);
}
Custom errors
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
class HttpError extends Error {
constructor(message, status, body) {
super(message);
this.name = "HttpError";
this.status = status;
this.body = body;
}
}
class NetworkError extends Error {
constructor(message, cause) {
super(message, { cause });
this.name = "NetworkError";
}
}
// Usage:
try {
throw new ValidationError("Email is required", "email");
} catch (err) {
if (err instanceof ValidationError) {
showFieldError(err.field, err.message);
} else {
throw err; // re-throw if not handled
}
}
Async errors
async function fetchData() {
try {
const r = await fetch("/api/data");
if (!r.ok) throw new HttpError(`HTTP ${r.status}`, r.status);
return await r.json();
} catch (err) {
if (err instanceof HttpError) {
// handle HTTP
} else if (err instanceof TypeError) {
// network error (fetch rejects with TypeError)
}
throw err;
}
}
The await integrates with try/catch — substantial cleaner than callback chains.
Promise without await
fetch("/api/data")
.then(r => r.json())
.then(data => console.log(data))
.catch(err => console.error(err));
Mixed
const data = await fetch("/api/data")
.then(r => r.ok ? r.json() : Promise.reject(new HttpError(`HTTP ${r.status}`, r.status)));
try/catch around await
async function load() {
let data;
try {
data = await fetchData();
} catch (err) {
console.error(err);
return null; // graceful fallback
}
return process(data);
}
Re-throwing
try {
risky();
} catch (err) {
if (err instanceof KnownError) {
handle(err);
} else {
throw err; // unknown, re-throw
}
}
Wrap and re-throw
try {
await fetchUser(id);
} catch (err) {
throw new Error(`Failed to fetch user ${id}`, { cause: err });
}
Top-level error handlers
For unhandled errors:
window.addEventListener("error", (e) => {
console.error("Uncaught:", e.error);
console.error("File:", e.filename, "Line:", e.lineno);
sendTelemetry({
type: "uncaught",
message: e.message,
file: e.filename,
line: e.lineno,
stack: e.error?.stack
});
});
window.addEventListener("unhandledrejection", (e) => {
console.error("Unhandled rejection:", e.reason);
sendTelemetry({
type: "rejection",
reason: String(e.reason),
stack: e.reason?.stack
});
e.preventDefault(); // prevent default console error
});
The conventional contemporary discipline reports all such errors to a telemetry service (Sentry, Bugsnag, Honeybadger, custom).
Error boundary (React)
class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error, info) {
sendTelemetry({ error, info });
}
render() {
if (this.state.error) {
return <div>Something went wrong.</div>;
}
return this.props.children;
}
}
// Use:
<ErrorBoundary>
<App />
</ErrorBoundary>
Other frameworks have substantial similar patterns (Vue’s errorCaptured, Svelte’s error boundaries, etc.).
Validation patterns
function validateUser(user) {
const errors = [];
if (!user.name) errors.push({ field: "name", message: "Name is required" });
if (user.age < 0) errors.push({ field: "age", message: "Age must be positive" });
if (!user.email?.includes("@")) errors.push({ field: "email", message: "Invalid email" });
if (errors.length > 0) {
const err = new ValidationError("Invalid user");
err.errors = errors;
throw err;
}
}
try {
validateUser(user);
save(user);
} catch (err) {
if (err instanceof ValidationError) {
for (const e of err.errors) {
showFieldError(e.field, e.message);
}
} else {
throw err;
}
}
For substantial validation, the conventional contemporary discipline uses a library:
// Zod:
import { z } from "zod";
const UserSchema = z.object({
name: z.string().min(1),
age: z.number().int().nonnegative(),
email: z.string().email()
});
const result = UserSchema.safeParse(input);
if (!result.success) {
for (const issue of result.error.issues) {
console.log(issue.path, issue.message);
}
}
Result/Option patterns (manual)
JavaScript does not natively admit Result/Option — but the pattern is substantial:
function tryFn(fn) {
try {
return [null, fn()];
} catch (err) {
return [err, null];
}
}
const [err, value] = tryFn(() => JSON.parse(input));
if (err) {
console.error(err);
} else {
console.log(value);
}
// Async:
async function tryAsync(fn) {
try {
return [null, await fn()];
} catch (err) {
return [err, null];
}
}
const [err, data] = await tryAsync(() => fetch("/api").then(r => r.json()));
For substantial typed result handling, the conventional contemporary discipline uses TypeScript with discriminated unions or a Result library (neverthrow, oxide.ts).
Common patterns
Defensive parsing
function parseInt(text, fallback = 0) {
const n = Number(text);
return Number.isFinite(n) ? Math.trunc(n) : fallback;
}
function parseJson(text, fallback = null) {
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
Wrapped async with default
async function safeFetch(url, fallback = null) {
try {
const r = await fetch(url);
if (!r.ok) return fallback;
return await r.json();
} catch {
return fallback;
}
}
Retry with logging
async function retry(fn, attempts = 3) {
for (let i = 0; i <= attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts) {
console.error(`failed after ${attempts} attempts:`, err);
throw err;
}
console.warn(`attempt ${i + 1} failed, retrying:`, err.message);
await delay(2 ** i * 100);
}
}
}
Graceful degradation
async function getProfile(id) {
try {
return await fetchProfile(id);
} catch (err) {
if (err instanceof NetworkError) {
return getCachedProfile(id) ?? defaultProfile();
}
throw err;
}
}
User-visible toast on error
async function withErrorToast(fn) {
try {
return await fn();
} catch (err) {
if (err instanceof ValidationError) {
showToast(err.message, "warning");
} else if (err instanceof HttpError) {
showToast(`Server error: ${err.status}`, "error");
} else {
showToast("Something went wrong", "error");
sendTelemetry(err);
}
throw err;
}
}
await withErrorToast(() => save(data));
Form submission
form.addEventListener("submit", async (e) => {
e.preventDefault();
const submitBtn = form.querySelector("button[type='submit']");
try {
submitBtn.disabled = true;
clearErrors();
const data = Object.fromEntries(new FormData(form));
validate(data); // throws ValidationError
await submit(data); // throws HttpError or NetworkError
showToast("Saved!", "success");
form.reset();
} catch (err) {
if (err instanceof ValidationError) {
for (const issue of err.errors) {
showFieldError(issue.field, issue.message);
}
} else if (err instanceof HttpError && err.status === 409) {
showToast("Conflict — please refresh", "warning");
} else {
showToast("Failed to save", "error");
sendTelemetry(err);
}
} finally {
submitBtn.disabled = false;
}
});
Centralised error reporter
class ErrorReporter {
#endpoint;
#queue = [];
constructor(endpoint) {
this.#endpoint = endpoint;
window.addEventListener("error", e => this.report({
type: "uncaught",
message: e.message,
file: e.filename,
line: e.lineno,
stack: e.error?.stack
}));
window.addEventListener("unhandledrejection", e => this.report({
type: "rejection",
reason: String(e.reason),
stack: e.reason?.stack
}));
}
report(error) {
this.#queue.push({
...error,
timestamp: Date.now(),
url: location.href,
userAgent: navigator.userAgent
});
this.#flush();
}
async #flush() {
if (this.#queue.length === 0) return;
const batch = this.#queue.splice(0);
try {
await fetch(this.#endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(batch),
keepalive: true
});
} catch {
this.#queue.unshift(...batch); // retry next time
}
}
}
const reporter = new ErrorReporter("/api/errors");
Type guard (TypeScript)
function isHttpError(err: unknown): err is HttpError {
return err instanceof HttpError;
}
try {
await fetchData();
} catch (err: unknown) {
if (isHttpError(err)) {
console.log(err.status);
} else if (err instanceof Error) {
console.log(err.message);
} else {
console.log("unknown:", err);
}
}
Promise rejection in event handler
button.addEventListener("click", async () => {
try {
await doWork();
} catch (err) {
showToast(err.message);
}
});
The conventional discipline always wraps async event handlers in try/catch — admit substantial control over user-facing reporting.
Dialog with error
async function confirmAction() {
const ok = await showConfirmDialog("Are you sure?");
if (!ok) return;
try {
await deleteItem();
showToast("Deleted", "success");
} catch (err) {
await showErrorDialog(`Failed: ${err.message}`);
}
}
Timeout-handling
async function withTimeout(promise, ms) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await Promise.race([
promise,
new Promise((_, reject) =>
controller.signal.addEventListener("abort", () =>
reject(new Error("Timeout"))
)
)
]);
} finally {
clearTimeout(timer);
}
}
Catching specific HTTP status
try {
await api("/users/42");
} catch (err) {
if (err instanceof HttpError) {
switch (err.status) {
case 401:
redirectToLogin();
break;
case 403:
showToast("Forbidden");
break;
case 404:
showNotFoundPage();
break;
case 500:
showToast("Server error, please try again");
sendTelemetry(err);
break;
default:
showToast(`Error ${err.status}`);
}
} else {
throw err;
}
}
A note on “throwing strings”
// Avoid — admits substantial less information:
throw "something failed";
// Prefer:
throw new Error("something failed");
The Error constructor admits stack traces; raw strings do not.
A note on console.error
console.error("error:", err); // prints to console
console.warn("warning:", info);
console.info("info:", info);
console.debug("debug:", info);
// With stack:
console.trace("here:", info);
// Group:
console.group("processing");
console.log("step 1");
console.log("step 2");
console.groupEnd();
The conventional production discipline removes or guards console.log (lint rule); leaves console.error/warn for substantial diagnostic.
A note on the conventional discipline
The contemporary error handling advice:
- Throw
Error(or subclass) — never strings or numbers. - Use custom error classes — admit substantial
instanceofdiscrimination. - Use
cause(ES2022) for substantial chained errors. - Use
try/catcharoundawait— substantial cleaner than.catch. - Use
unhandledrejectionanderrorevents for substantial telemetry. - Use a reporter (Sentry, Bugsnag, custom) for substantial production.
- Use validation libraries (Zod, Yup, Joi) for substantial input validation.
- Use error boundaries (React, Vue) for substantial UI fail-safes.
- Re-throw unknown errors — only catch what you handle.
- Always show user-visible feedback on async errors.
- Always log to telemetry on uncaught/rejected.
- Avoid swallowing errors silently — log even if not user-visible.
The combination — try/catch with Error-derived classes, async/await integration, custom error classes for substantial discrimination, cause for chains, top-level handlers for substantial telemetry, framework error boundaries for substantial UI safety, validation libraries for substantial input checking — is the substance of JavaScript error handling. The discipline produces robust, observable, user-friendly applications.