Error Handling
Errors happen. Network requests fail, users enter unexpected data, and code has bugs. Good error handling means your program fails gracefully - it tells the user what went wrong instead of silently breaking or crashing the entire page.
The Error object
JavaScript has a built-in Error class. Every error has message and name properties, and most runtimes also
provide a stack property:
const error = new Error("Something went wrong");
console.log(error.message); // "Something went wrong"
console.log(error.name); // "Error"
console.log(error.stack); // Stack trace showing where the error was created
| Property | What it contains |
|---|---|
message | A human-readable description of what went wrong |
name | The error type (e.g., "TypeError", "RangeError") |
stack | A trace showing the call chain that led to the error |
The non-standard but widely supported stack property is invaluable for debugging - it tells you which function,
file, and line number created the error.
try / catch / finally
The core mechanism for handling errors:
try {
// Code that might throw an error
const data = JSON.parse("not valid json");
} catch (error) {
// Runs only if the try block throws
console.log("Parsing failed:", error.message);
} finally {
// Always runs, whether or not an error occurred
console.log("Done.");
}
Result:
Parsing failed: Unexpected token 'o', "not valid json" is not valid JSON
Done.
How the flow works
- JavaScript executes the
tryblock line by line. - If no error occurs, the
catchblock is skipped entirely. - If an error is thrown, execution jumps immediately to
catch. The remaining lines intrydo not run. - The
finallyblock always runs - whether there was an error or not. Use it for cleanup (closing files, hiding loaders, resetting state).
Avoid return or throw inside finally unless you mean to override a value or error from the try or catch
block.
catch without finally
try {
const result = riskyOperation();
console.log(result);
} catch (error) {
console.log("Error:", error.message);
}
finally without catch
Rare, but valid - useful when you want cleanup but want the error to propagate:
function runWithCleanup() {
try {
return riskyOperation();
} finally {
// Cleanup runs even though there is no catch
console.log("Cleanup complete");
}
}
Built-in error types
JavaScript has several specific error types that inherit from Error:
| Type | When it occurs | Example |
|---|---|---|
TypeError | Wrong type used in an operation | null.toString() |
ReferenceError | Using an undeclared variable | console.log(x) where x is not defined |
SyntaxError | Code cannot be parsed | JSON.parse("{invalid}") |
RangeError | A value is outside the allowed range | new Array(-1) |
URIError | Invalid use of URI functions | decodeURIComponent("%") |
AggregateError | Several errors grouped together | Promise.any() when every promise rejects |
Checking the error type
Use instanceof to handle different errors differently:
try {
const data = JSON.parse(userInput);
processData(data);
} catch (error) {
if (error instanceof SyntaxError) {
console.log("Invalid JSON format");
} else if (error instanceof TypeError) {
console.log("Data has an unexpected structure");
} else {
console.log("Unknown error:", error.message);
}
}
AggregateError
AggregateError means several operations failed together. A common place to see it is Promise.any(): it returns the
first successful promise, but throws AggregateError if every promise rejects.
const sources = [
Promise.reject(new Error("Cache failed")),
Promise.reject(new Error("Network failed")),
];
try {
await Promise.any(sources);
} catch (error) {
if (error instanceof AggregateError) {
console.log("All options failed");
console.log(error.errors.map((err) => err.message).join(", "));
}
}
Result:
All options failed
Cache failed, Network failed
Throwing errors
Use throw to create your own errors:
function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
try {
console.log(divide(10, 0));
} catch (error) {
console.log(error.message); // "Cannot divide by zero"
}
You can throw any value, but always throw Error objects (or subclasses) - they include the stack trace:
// Good - includes stack trace
throw new Error("Something failed");
// Bad - no stack trace, harder to debug
throw "Something failed";
Throwing specific error types
function getUser(id) {
if (typeof id !== "number") {
throw new TypeError("id must be a number");
}
if (id < 1) {
throw new RangeError("id must be positive");
}
// ... fetch user
}
Custom error classes
For application-specific errors, create your own error classes:
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
class NotFoundError extends Error {
constructor(resource, id) {
super(`${resource} with id ${id} not found`);
this.name = "NotFoundError";
this.resource = resource;
this.id = id;
}
}
// Usage
function validateEmail(email) {
if (!email.includes("@")) {
throw new ValidationError("email", "Email must contain @");
}
}
try {
validateEmail("not-an-email");
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Field "${error.field}": ${error.message}`);
}
}
Result:
Field "email": Email must contain @
Custom error classes let you:
- Add extra properties (like
field,resource,statusCode) - Handle different error categories differently with
instanceof - Provide clearer error messages for specific situations
Error cause
Modern JavaScript (ES2022+) lets you chain errors with the cause property:
async function loadUserProfile(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
throw new Error("Failed to load user profile", { cause: error });
}
}
try {
await loadUserProfile(42);
} catch (error) {
console.log(error.message); // "Failed to load user profile"
console.log(error.cause.message); // "HTTP 404" (the original error)
}
cause preserves the original error while wrapping it in a higher-level message. This is useful when a low-level
error (network failure) should be reported as a domain-level error (failed to load profile).
Async error handling
try/catch with async/await
async function fetchData(url) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
console.log("Fetch error:", error.message);
return null; // Return a fallback value
}
}
const data = await fetchData("https://api.example.com/items");
.catch() on promises
If you prefer the promise chain style:
fetch("https://api.example.com/items")
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
console.log("Data:", data);
})
.catch(error => {
console.log("Error:", error.message);
});
Handling multiple async operations
async function loadDashboard() {
try {
const [users, posts, comments] = await Promise.all([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/comments").then(r => r.json()),
]);
return { users, posts, comments };
} catch (error) {
// Any single failure rejects the entire Promise.all
console.log("Dashboard load failed:", error.message);
return null;
}
}
If you need partial results even when some requests fail, use Promise.allSettled:
const results = await Promise.allSettled([
fetch("/api/users").then(r => r.json()),
fetch("/api/posts").then(r => r.json()),
fetch("/api/comments").then(r => r.json()),
]);
for (const result of results) {
if (result.status === "fulfilled") {
console.log("Data:", result.value);
} else {
console.log("Failed:", result.reason.message);
}
}
Unhandled promise rejections
If you forget to handle a rejected promise, the browser logs a warning:
// Bad - unhandled rejection
async function loadData() {
const response = await fetch("/api/missing-endpoint");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json(); // Can also reject if the body is not valid JSON
}
loadData(); // No .catch(), no try/catch - unhandled rejection
Always handle errors in async code. A global safety net can catch anything you miss:
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled promise rejection:", event.reason);
// Log to an error reporting service
});
Catching errors you missed
Browser-level handlers can report errors that slipped through local handling. Treat them as a last safety net, not as
the main way to handle errors. Use local try/catch or .catch() when you know what can fail.
window.addEventListener("error", (event) => {
// event.error is the thrown value; event.message is browser-formatted text
const message = event.error instanceof Error ? event.error.message : event.message;
console.error("Uncaught error:", message);
});
window.addEventListener("unhandledrejection", (event) => {
const message = event.reason instanceof Error ? event.reason.message : String(event.reason);
console.error("Unhandled promise rejection:", message);
event.preventDefault();
});
If code throws new Error("Button failed") and a promise rejects with new Error("Request failed"), the handler
output would be:
Result:
Uncaught error: Button failed
Unhandled promise rejection: Request failed
See the Error Handling reference for Node.js global handlers and more details.
Error handling in the DOM
Showing errors to the user
Never show raw error messages to users. Instead, translate them into helpful UI:
const errorDiv = document.querySelector("#error-message");
const dataContainer = document.querySelector("#data");
async function loadItems() {
errorDiv.style.display = "none";
dataContainer.innerHTML = "<p>Loading...</p>";
try {
const response = await fetch("/api/items");
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
const items = await response.json();
renderItems(items);
} catch (error) {
// Show a user-friendly message
errorDiv.textContent = "Could not load items. Please try again later.";
errorDiv.style.display = "block";
dataContainer.innerHTML = "";
// Log the real error for debugging
console.error("Failed to load items:", error);
}
}
Validating user input
function validateForm(form) {
const errors = [];
const name = form.querySelector("#name").value.trim();
if (name === "") {
errors.push({ field: "name", message: "Name is required" });
}
const email = form.querySelector("#email").value.trim();
if (!email.includes("@")) {
errors.push({ field: "email", message: "Invalid email address" });
}
const age = Number(form.querySelector("#age").value);
if (Number.isNaN(age) || age < 0 || age > 150) {
errors.push({ field: "age", message: "Age must be between 0 and 150" });
}
return errors;
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const errors = validateForm(form);
if (errors.length > 0) {
for (const { field, message } of errors) {
const group = form.querySelector(`#${field}`).closest(".form-group");
group.classList.add("has-error");
group.querySelector(".error-text").textContent = message;
}
return;
}
// Submit the form
submitData(form);
});
Patterns
Guard clauses - fail fast
Check for invalid conditions at the top of a function and return or throw early:
// Without guard clauses - deeply nested
function processOrder(order) {
if (order) {
if (order.items.length > 0) {
if (order.paymentMethod) {
// actual logic buried here
return calculateTotal(order);
} else {
throw new Error("No payment method");
}
} else {
throw new Error("No items in order");
}
} else {
throw new Error("No order provided");
}
}
// With guard clauses - flat and readable
function processOrder(order) {
if (!order) throw new Error("No order provided");
if (order.items.length === 0) throw new Error("No items in order");
if (!order.paymentMethod) throw new Error("No payment method");
return calculateTotal(order);
}
Guard clauses make functions easier to read by handling edge cases first and keeping the main logic at the top level.
Default values as fallbacks
function getConfig(userConfig) {
return {
theme: userConfig?.theme ?? "light",
language: userConfig?.language ?? "en",
pageSize: userConfig?.pageSize ?? 20,
};
}
// Even with undefined input, you get a valid config
const config = getConfig(undefined);
// { theme: "light", language: "en", pageSize: 20 }
Retry pattern
For transient failures (network issues), retry before giving up:
async function fetchWithRetry(url, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.log(`Attempt ${attempt} failed: ${error.message}`);
if (attempt === maxRetries) {
throw new Error(`Failed after ${maxRetries} attempts`, { cause: error });
}
// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
}
}
}
Result pattern - errors as values
Instead of throwing, return an object that indicates success or failure:
function parseAge(input) {
const age = Number(input);
if (Number.isNaN(age)) {
return { ok: false, error: "Not a number" };
}
if (age < 0 || age > 150) {
return { ok: false, error: "Age out of range" };
}
return { ok: true, value: age };
}
const result = parseAge("abc");
if (result.ok) {
console.log("Age:", result.value);
} else {
console.log("Error:", result.error);
}
This pattern avoids exceptions for expected failures (invalid user input is not exceptional - it is normal). Reserve
throw for truly unexpected situations.
Anti-patterns
Empty catch blocks
// Bad - errors are silently swallowed
try {
doSomething();
} catch (error) {
// nothing here
}
// Good - at minimum, log the error
try {
doSomething();
} catch (error) {
console.error("doSomething failed:", error);
}
Catching too broadly
// Bad - catches everything, including programming bugs
try {
const result = calculateTotal(items);
const formatted = formatCurrency(result);
displayOnPage(formatted);
} catch (error) {
console.log("Something went wrong");
}
// Better - catch only where the error can actually occur
let result;
try {
result = calculateTotal(items);
} catch (error) {
console.error("Calculation failed:", error);
result = 0;
}
const formatted = formatCurrency(result);
displayOnPage(formatted);
Using exceptions for control flow
// Bad - using try/catch as an if-statement
function findUserWithException(id) {
const user = users.find(u => u.id === id);
try {
if (!user) throw new Error("not found");
return user;
} catch {
return defaultUser;
}
}
// Good - use normal control flow
function findUser(id) {
const user = users.find(u => u.id === id);
return user ?? defaultUser;
}
Catching and re-throwing without adding value
// Bad - pointless catch
async function initializeDashboardBad() {
try {
return await loadData();
} catch (error) {
throw error; // Does nothing useful
}
}
// Good - add context when re-throwing
async function initializeDashboard() {
try {
return await loadData();
} catch (error) {
throw new Error("Failed to initialize dashboard", { cause: error });
}
}
Summary
try/catch/finallyis the core error handling mechanism -finallyalways runs.- JavaScript has built-in error types (
TypeError,RangeError,SyntaxError,ReferenceError,AggregateError) - useinstanceofto distinguish them. - Always throw
Errorobjects (not strings) to get stack traces. - Custom error classes let you add properties and handle errors by category.
- Error
cause(ES2022) chains errors to preserve the original failure. - Use
try/catchwithasync/awaitfor async errors; usePromise.allSettledwhen you need partial results. - Guard clauses keep functions flat and readable by failing fast.
- The result pattern (returning
{ ok, value, error }) avoids exceptions for expected failures. - Never swallow errors with empty
catchblocks - at minimum, log them.
For advanced patterns including TypeScript error types, error boundaries, and structured logging, see the Error Handling reference.
Next up: Regular Expressions - pattern matching for validation, parsing, and search-and-replace.