Errors are part of every real program. JavaScript gives you try, catch, and finally for synchronous code, and the same syntax inside async functions for asynchronous code. Beyond the syntax, you want custom error types, a global handler, and patterns that keep error paths readable. This lesson covers all of that.
try, catch, finally
Wrap the code that can fail in try. The catch block receives the error. The finally block runs whether or not an exception fired, which is the right place to release resources.
function readConfig(path) {
let raw;
try {
raw = localStorage.getItem(path);
if (!raw) throw new Error(`config not found at ${path}`);
return JSON.parse(raw);
} catch (err) {
console.error("readConfig failed:", err.message);
return null;
} finally {
console.log("readConfig attempt complete");
}
}
Custom error classes
Subclass Error to make domain-specific failures easy to catch and inspect. Always set the name field (it does not inherit from "Error" automatically in older engines, so do it explicitly). Pass extra context via additional fields.
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
class NetworkError extends Error {
constructor(url, status) {
super(`request to ${url} failed with status ${status}`);
this.name = "NetworkError";
this.url = url;
this.status = status;
}
}
function validateUser(user) {
if (!user.email) throw new ValidationError("email", "required");
if (user.age < 0) throw new ValidationError("age", "must be non-negative");
}
Errors in async code
Inside an async function, throw or await a rejected promise and the function's returned promise becomes rejected. Catch with try/catch around the await. Use Promise.allSettled when you want to handle each outcome individually.
async function loadUser(id) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new NetworkError(`/api/users/${id}`, response.status);
return await response.json();
} catch (err) {
if (err instanceof NetworkError) {
console.error("network issue:", err.status);
} else {
console.error("unexpected:", err);
}
return null;
}
}
A global error handler
window.onerror and window.addEventListener("error", ...) catch uncaught exceptions. For unhandled promise rejections, listen for "unhandledrejection". Use these to log to a server, show a friendly message, or trigger a reload.
window.addEventListener("error", (event) => {
console.error("uncaught:", event.error);
// send to a logging endpoint
});
window.addEventListener("unhandledrejection", (event) => {
console.error("unhandled rejection:", event.reason);
event.preventDefault(); // suppress the console error
});
Re-throw when you cannot handle
When a catch block sees an error it cannot fix, re-throw it so the caller can decide. Catching and silently dropping is a common source of bugs.
function saveUser(user) {
try {
validateUser(user);
localStorage.setItem(`user:${user.id}`, JSON.stringify(user));
} catch (err) {
if (err instanceof ValidationError) {
showFieldError(err.field, err.message);
return;
}
throw err; // unknown error, let the caller decide
}
}
Structured logging
Plain console.log is fine for development but for production you want structured logs: a JSON object with timestamp, level, message, context, and the error itself. Send those to a log aggregation service like Datadog, Sentry, or Elastic. A logger should accept an Error and include its name, message, stack, and any custom fields automatically. With structured logs you can search, filter, and alert on real production failures.
Summary
Use try/catch/finally for predictable cleanup. Subclass Error to make domain failures catchable. Catch by type with instanceof. Listen for global error and unhandledrejection events to catch what slips through. Re-throw when you cannot handle. The next lesson covers generators and iterators, the feature that powers custom loops and data pipelines.
Error cause and stack traces
ES2022 added the cause option to the Error constructor. Use it to wrap a lower-level error with a higher-level one while preserving the original. The original error becomes available via err.cause and the stack trace points to the wrapper. This is the cleanest way to add context without losing the underlying failure.
async function loadUserProfile(id) {
try {
return await fetch(`/api/users/${id}/profile`);
} catch (err) {
throw new Error(`failed to load profile for ${id}`, { cause: err });
}
}
try {
await loadUserProfile("123");
} catch (err) {
console.error(err.message); // failed to load profile for 123
console.error(err.cause); // the original network error
}
Best practices for error handling
Throw early, catch late. Validate inputs at the boundary of your function and throw a domain-specific error if they are wrong. Catch only at the level where you can recover or where you need to log or report. Never swallow an error silently; if you must catch and ignore it, leave a comment explaining why. Use finally to release resources, close connections, and reset state. Avoid using errors for normal control flow: exceptions are expensive and obscure the happy path.
Comparison with Python
Python's try/except/finally plays the same role as JavaScript's try/catch/finally. Python uses except SomeError as e: instead of catch (err) and instanceof checks. Python's raise from err adds a cause in the same way JavaScript's new Error(msg, { cause: err }) does. Python's built-in exception hierarchy (ValueError, TypeError, KeyError) is richer than JavaScript's (Error and a handful of subclasses), so domain errors in Python often extend the built-ins. Python's contextlib.suppress plays the role of silently ignoring known exceptions; JavaScript does not have a direct equivalent and you catch-and-ignore by hand. Both languages support custom error classes and global handlers; Python's hook is sys.excepthook, JavaScript's is window.onerror.