DocsHub
Async JavaScript

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 runs

Without 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 normally

Without 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 error
  • error.message — human-readable description
  • error.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/catch wraps risky code — errors jump to catch instead of crashing the program
  • The Error object has name, message, and stack properties
  • finally always runs — use it for cleanup regardless of success or failure
  • Throw your own errors with throw new Error("message") to signal failures
  • Use instanceof to 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

On this page