Error Handling
Learn how to handle errors gracefully in JavaScript using try, catch, finally, and custom errors.
Error Handling
Things go wrong. Networks fail. Users enter invalid data. APIs return unexpected responses. Code has bugs.
A program that crashes the moment something goes wrong is a bad program. Error handling is how you anticipate failures, deal with them gracefully, and keep your application running.
What is an Error?
When JavaScript encounters a problem it cannot handle — dividing by something that is not a number, accessing a property on null, calling something that is not a function — it throws an Error object and stops execution.
console.log(null.name);
// TypeError: Cannot read properties of null (reading 'name')
// Everything after this line never runsWithout error handling, one unexpected value crashes your entire program. With error handling, you catch the error, deal with it, and keep going.
try / catch
The fundamental tool for error handling. Wrap risky code in a try block. If anything goes wrong, execution jumps to the catch block.
try {
// risky code here
const result = someOperation();
console.log(result);
} catch (error) {
// runs if anything in try throws
console.log("Something went wrong:", error.message);
}try {
const user = null;
console.log(user.name); // throws TypeError
console.log("This never runs");
} catch (error) {
console.log("Caught:", error.message);
// Caught: Cannot read properties of null (reading 'name')
}
console.log("Program continues normally");
// Program continues normallyWithout try/catch — the program crashes at user.name. With it — the error is caught, handled, and the program keeps running.
The Error Object
The catch block receives an Error object with useful properties:
try {
undefinedFunction();
} catch (error) {
console.log(error.name); // "ReferenceError"
console.log(error.message); // "undefinedFunction is not defined"
console.log(error.stack); // full stack trace — useful for debugging
}error.name— the type of errorerror.message— human-readable descriptionerror.stack— where in the code it happened — very useful for debugging
finally — Always Runs
The finally block runs after try and catch — no matter what. Whether the code succeeded, threw an error, or even returned early.
function loadData() {
const spinner = document.getElementById("spinner");
spinner.style.display = "block";
try {
const data = riskyOperation();
return data;
} catch (error) {
console.log("Failed:", error.message);
return null;
} finally {
spinner.style.display = "none"; // always hides — no matter what
}
}finally is perfect for cleanup — closing connections, hiding loading spinners, releasing resources. It runs even if the try block has a return statement.
JavaScript Error Types
JavaScript has several built-in error types. Knowing which type you are dealing with helps you respond correctly.
// ReferenceError — using a variable that does not exist
try {
console.log(undeclaredVariable);
} catch (error) {
console.log(error.name); // ReferenceError
}
// TypeError — wrong type — null/undefined access, calling non-function
try {
null.property;
} catch (error) {
console.log(error.name); // TypeError
}
// SyntaxError — invalid JavaScript — usually caught at parse time
// JSON.parse throws SyntaxError for invalid JSON
try {
JSON.parse("invalid json {");
} catch (error) {
console.log(error.name); // SyntaxError
}
// RangeError — value out of allowed range
try {
new Array(-1);
} catch (error) {
console.log(error.name); // RangeError
}
// URIError — malformed URI
try {
decodeURIComponent("%");
} catch (error) {
console.log(error.name); // URIError
}Throwing Errors Manually
You can throw your own errors using the throw keyword. This is how you signal that something is wrong in your own code.
function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero.");
}
return a / b;
}
try {
console.log(divide(10, 2)); // 5
console.log(divide(10, 0)); // throws
} catch (error) {
console.log(error.message); // Cannot divide by zero.
}You can throw specific error types for more precise handling:
function getUser(id) {
if (typeof id !== "number") {
throw new TypeError("User ID must be a number.");
}
if (id <= 0) {
throw new RangeError("User ID must be a positive number.");
}
return { id, name: "Ali" };
}
try {
getUser("abc");
} catch (error) {
if (error instanceof TypeError) {
console.log("Type problem:", error.message);
} else if (error instanceof RangeError) {
console.log("Range problem:", error.message);
} else {
console.log("Unknown error:", error.message);
}
}
// Type problem: User ID must be a number.instanceof checks which type of error was thrown — letting you handle different errors differently.
Custom Error Classes
For real applications you often need errors with more information than just a message. Create custom error classes by extending the built-in Error.
class ValidationError extends Error {
constructor(message, field) {
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;
}
}
class NetworkError extends Error {
constructor(message, statusCode) {
super(message);
this.name = "NetworkError";
this.statusCode = statusCode;
}
}Now you can throw and catch these specifically:
function validateUser(data) {
if (!data.name) {
throw new ValidationError("Name is required.", "name");
}
if (!data.email.includes("@")) {
throw new ValidationError("Invalid email address.", "email");
}
}
try {
validateUser({ name: "", email: "notanemail" });
} catch (error) {
if (error instanceof ValidationError) {
console.log(`Validation failed on field "${error.field}": ${error.message}`);
} else {
console.log("Unexpected error:", error.message);
}
}
// Validation failed on field "name": Name is required.Custom errors make your code expressive — you can handle each failure scenario precisely.
Error Handling in Async Code
With async/await — use try/catch
async function loadUser(id) {
try {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new NetworkError(
`Failed to load user`,
response.status
);
}
const user = await response.json();
return user;
} catch (error) {
if (error instanceof NetworkError) {
console.log(`Network error (${error.statusCode}): ${error.message}`);
} else {
console.log("Unexpected error:", error.message);
}
return null;
}
}With Promises — use .catch()
fetch("https://api.example.com/users")
.then(response => {
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
return response.json();
})
.then(data => console.log(data))
.catch(error => console.log("Failed:", error.message));Unhandled Promise rejections
If a Promise rejects and nothing catches it, you get an unhandled rejection — a warning in development and a crash in some environments.
// ❌ Unhandled rejection — dangerous
async function loadData() {
const data = await fetch("bad-url"); // rejects but nobody catches it
}
loadData(); // fires and forgets — rejection is unhandled
// ✅ Always handle rejections
async function loadData() {
try {
const data = await fetch("bad-url");
} catch (error) {
console.log("Handled:", error.message);
}
}Always handle Promise rejections — either with try/catch in async functions or .catch() on Promise chains.
Error Boundaries — Global Error Handling
For errors that slip through, set up global handlers to catch anything unexpected.
// Catch synchronous errors that were not caught anywhere
window.addEventListener("error", (event) => {
console.error("Uncaught error:", event.error.message);
// Send to error logging service
logError(event.error);
});
// Catch unhandled Promise rejections
window.addEventListener("unhandledrejection", (event) => {
console.error("Unhandled rejection:", event.reason);
logError(event.reason);
event.preventDefault(); // prevent default browser logging
});These are safety nets — not replacements for proper error handling. Use them to catch anything that slips through and report it to a logging service.
Re-throwing Errors
Sometimes you want to catch an error, do something with it — log it, enrich it — then throw it again for the caller to handle.
async function fetchUserData(id) {
try {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
// Add context and re-throw
throw new Error(`Failed to fetch user ${id}: ${error.message}`);
}
}
async function loadProfile(id) {
try {
const user = await fetchUserData(id);
renderProfile(user);
} catch (error) {
// Gets the enriched error message
showErrorMessage(error.message);
}
}fetchUserData catches the low-level error, adds useful context, and re-throws. loadProfile catches the enriched error and shows it to the user. Each level handles what it knows about.
A Real Example — Robust Form Submission
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
class NetworkError extends Error {
constructor(message, statusCode) {
super(message);
this.name = "NetworkError";
this.statusCode = statusCode;
}
}
function validateSignupData(data) {
if (!data.username || data.username.length < 3) {
throw new ValidationError("Username must be at least 3 characters.", "username");
}
if (!data.email || !data.email.includes("@")) {
throw new ValidationError("Please enter a valid email.", "email");
}
if (!data.password || data.password.length < 8) {
throw new ValidationError("Password must be at least 8 characters.", "password");
}
}
async function submitSignup(data) {
const response = await fetch("https://api.example.com/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (response.status === 409) {
throw new ValidationError("Username or email already taken.", "username");
}
if (!response.ok) {
throw new NetworkError("Signup failed. Please try again.", response.status);
}
return response.json();
}
async function handleSignup(formData) {
const submitBtn = document.getElementById("submit-btn");
const errorEl = document.getElementById("error-message");
submitBtn.disabled = true;
errorEl.textContent = "";
try {
// Step 1 — validate
validateSignupData(formData);
// Step 2 — submit
const result = await submitSignup(formData);
// Step 3 — success
console.log("Account created!", result);
window.location.href = "/dashboard";
} catch (error) {
if (error instanceof ValidationError) {
// Show next to the specific field
const fieldEl = document.getElementById(error.field);
if (fieldEl) {
fieldEl.classList.add("input-error");
fieldEl.insertAdjacentHTML("afterend",
`<span class="error">${error.message}</span>`
);
} else {
errorEl.textContent = error.message;
}
} else if (error instanceof NetworkError) {
errorEl.textContent = error.message;
if (error.statusCode >= 500) {
errorEl.textContent = "Server error. Please try again later.";
}
} else {
errorEl.textContent = "Something unexpected happened. Please try again.";
console.error("Unexpected error:", error);
}
} finally {
submitBtn.disabled = false;
}
}Validation errors show next to the specific field. Network errors show a general message. Server errors get a friendly message. The button is always re-enabled. Each error type gets exactly the right treatment.
Summary
try/catchwraps risky code — errors jump tocatchinstead of crashing the program- The Error object has
name,message, andstackproperties finallyalways runs — use it for cleanup regardless of success or failure- Throw your own errors with
throw new Error("message")to signal failures - Use
instanceofto check which type of error was caught and handle each differently - Custom error classes extend
Error— add extra properties for richer error information - Always handle Promise rejections — unhandled rejections can crash your app
- Re-throw errors with added context when you cannot fully handle them at the current level
- Global handlers —
window.addEventListener("error")and"unhandledrejection"— are safety nets for anything that slips through