JavaScript Error Handling

An error happens when JavaScript hits something it cannot complete. When that occurs, JavaScript throws an error object and stops that flow unless you handle it.

Errors are part of real development. A user can enter invalid input, an API can fail, or data can come in a shape you did not expect. Good error handling helps your app fail gracefully instead of breaking completely.

Every error in JavaScript is an instance of the built-in Error class. An error object has two key properties:

  • name - the type of error (e.g. "TypeError", "ReferenceError")
  • message - a human-readable description of what went wrong
  • stack - a stack trace showing where the error originated (very useful for debugging)

javascript

try {
  null.toString(); // null has no methods - this throws a TypeError
} catch (e) {
  console.log(e.name);    // TypeError
  console.log(e.message); // Cannot read properties of null (reading 'toString')
  console.log(e instanceof TypeError); // true
  console.log(e instanceof Error);     // true - all error types extend Error
}

The try...catch statement is the main way to handle runtime errors in JavaScript. Code in try runs first, and if something fails, control moves to catch.

javascript

try {
  // Code that might fail
  const data = JSON.parse("this is not valid json");
} catch (e) {
  // Code that runs only when an error occurs
  console.log("Something went wrong:", e.message);
}

// Execution continues here regardless
console.log("Done");

The catch block receives the error object. You can name it anything - e, err, error are all common. Use it to log, display, or recover from the problem:

javascript

function parseUserInput(input) {
  try {
    const result = JSON.parse(input);
    return result;
  } catch (err) {
    console.error("Invalid JSON input:", err.message);
    return null; // return a safe default instead of crashing
  }
}

console.log(parseUserInput('{"product": "Laptop", "price": 999}')); // { product: 'Laptop', price: 999 }
console.log(parseUserInput("bad data"));          // null - error handled
Important: try...catch only catches runtime errors - errors that happen while the code runs. It does not catch syntax errors (those are caught by the JavaScript engine before code runs) and it does not catch errors inside asynchronous callbacks unless you use async/await with try/catch.

My story: I once wrapped an entire fetch call in try/catch and could not understand why my error handler never fired when the API returned a 404. It turns out fetch only throws on network failures - a 404 response is technically a successful HTTP request. I had to add an if (!response.ok) check to catch bad status codes. That experience taught me an important lesson: try/catch catches JavaScript errors, not business logic errors. You still need to check your data and handle cases that are "wrong" but not technically "broken."

The finally block runs every time. It runs whether try succeeds, whether catch runs, and even when there is a return in the flow.

Use it for cleanup work that must always happen - closing a database connection, hiding a loading spinner, releasing a lock:

javascript

function fetchData(url) {
  console.log("Loading...");
  try {
    // Simulate a fetch that might fail
    if (!url) throw new Error("URL is required");
    console.log("Fetched:", url);
  } catch (e) {
    console.error("Fetch failed:", e.message);
  } finally {
    console.log("Loading complete."); // always runs
  }
}

fetchData("https://api.example.com"); // Fetched, then Loading complete
fetchData("");                         // Fetch failed, then Loading complete

finally is optional. You can have try...catch, try...finally, or try...catch...finally. The catch is also optional, but you must have either catch or finally.

Watch out: If finally itself throws an error or has a return statement, it overrides any value returned from try or catch. Keep finally focused on cleanup only.

The throw statement lets you create and throw your own error. You can throw it from deep inside a function and it will bubble up the call stack until something catches it.

You can technically throw anything - a string, a number, an object. But the strong convention is to always throw an Error object (or a subclass of it), because that gives you the name, message, and stack properties:

javascript

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 (e) {
  console.error(e.message); // Cannot divide by zero
}

Use throw inside validation functions to signal that input is bad, rather than returning null or false and hoping the caller checks it:

javascript

function createUser(name, age) {
  if (typeof name !== "string" || name.trim() === "") {
    throw new TypeError("name must be a non-empty string");
  }
  if (typeof age !== "number" || age < 0 || age > 150) {
    throw new RangeError("age must be a number between 0 and 150");
  }
  return { name, age };
}

try {
  createUser("", 25); // throws TypeError
} catch (e) {
  console.log(e.name);    // TypeError
  console.log(e.message); // name must be a non-empty string
}

JavaScript has several built-in error types. Each one extends the base Error class and signals a different kind of problem:

  • Error - the generic base. Use it when no more specific type fits.
  • SyntaxError - invalid JavaScript syntax (e.g., broken JSON in JSON.parse()).
  • ReferenceError - accessing a variable that does not exist.
  • TypeError - wrong type used (e.g., calling a non-function, accessing property on null).
  • RangeError - a numeric value is outside the allowed range (e.g., invalid array length).
  • URIError - malformed URI in decodeURIComponent().
  • EvalError - related to eval(). Rarely seen in practice.

javascript

// ReferenceError - variable not declared
try {
  console.log(undeclaredVar);
} catch (e) {
  console.log(e.name); // ReferenceError
}

// TypeError - wrong type
try {
  null.toString();
} catch (e) {
  console.log(e.name); // TypeError
}

// RangeError - out of range
try {
  new Array(-1);
} catch (e) {
  console.log(e.name); // RangeError
}

// SyntaxError - invalid JSON
try {
  JSON.parse("{bad json}");
} catch (e) {
  console.log(e.name); // SyntaxError
}

In a catch block, you can check the error type to decide how to handle it:

javascript

try {
  riskyOperation();
} catch (e) {
  if (e instanceof TypeError) {
    console.log("Type problem:", e.message);
  } else if (e instanceof RangeError) {
    console.log("Range problem:", e.message);
  } else {
    throw e; // re-throw errors you don't know how to handle
  }
}

You can create your own error classes by extending Error. This lets you define domain-specific errors with meaningful names, making your catch blocks cleaner and your intent clearer.

javascript

class ValidationError extends Error {
  constructor(message) {
    super(message);
    this.name = "ValidationError";
  }
}

class DatabaseError extends Error {
  constructor(message, query) {
    super(message);
    this.name = "DatabaseError";
    this.query = query; // add custom properties
  }
}

// Usage
try {
  throw new ValidationError("Email is required");
} catch (e) {
  console.log(e.name);    // ValidationError
  console.log(e.message); // Email is required
  console.log(e instanceof ValidationError); // true
  console.log(e instanceof Error);           // true
}

Custom errors are especially useful in larger applications. Instead of checking e.message with string comparisons, you check e instanceof YourErrorClass - which is much more reliable:

javascript

function processForm(data) {
  if (!data.price)   throw new ValidationError("Price is required");
  if (!data.product) throw new ValidationError("Product name is required");
  // ... save to DB
}

try {
  processForm({ product: "Laptop" }); // missing price
} catch (e) {
  if (e instanceof ValidationError) {
    console.log("Form error:", e.message); // Form error: Price is required
  } else {
    throw e; // unexpected - re-throw
  }
}

When an error is thrown and not caught locally, JavaScript propagates it up the call stack - from the function where it happened, to the function that called it, and so on, until something catches it or it reaches the top level and crashes the program.

javascript

function level3() {
  throw new Error("Something broke in level 3");
}

function level2() {
  level3(); // no try/catch here - error bubbles up
}

function level1() {
  try {
    level2();
  } catch (e) {
    console.log("Caught in level1:", e.message);
    // Caught in level1: Something broke in level 3
  }
}

level1();

A common pattern is to catch an error, do something (like log it), and then re-throw it so the caller still knows something went wrong. This is useful when you can partially handle an error but not fully:

javascript

function loadConfig(path) {
  try {
    // attempt to read config
    return JSON.parse(readFile(path));
  } catch (e) {
    if (e instanceof SyntaxError) {
      console.error("Config file has invalid JSON:", e.message);
      throw e; // re-throw - the caller needs to know this failed
    }
    // For other errors (file not found etc.), silently return defaults
    return {};
  }
}
Rule of thumb: Only catch errors you know how to handle. If you catch an error just to silence it, you will hide bugs that are hard to find later. When in doubt, re-throw.

My take: The worst error handling pattern I see is the empty catch block - catch(e) {}. It silently swallows problems and turns debugging into a guessing game. I would rather let an error crash loudly than hide it. In my own code, I wrap try/catch only around things that can legitimately fail at runtime (network calls, file reads, JSON parsing) and I always log or re-throw. If you find yourself writing try/catch around every function, something else in your architecture is broken.

  • An error is an object with name, message, and stack properties. All error types extend the base Error class.
  • try...catch: code in try runs normally. If it throws, control jumps to catch. Execution continues after the block either way.
  • finally: always runs regardless of success or failure. Use it for cleanup code that must execute no matter what.
  • throw: create and throw your own errors. Always throw an Error instance (or subclass), not a raw string.
  • Error types: TypeError, ReferenceError, RangeError, SyntaxError, and others. Use instanceof in catch to handle specific types differently.
  • Custom errors: extend Error to create domain-specific errors. Set this.name in the constructor to give it a meaningful name.
  • Error propagation: uncaught errors bubble up the call stack. Catch only what you can handle. Re-throw errors you cannot fully handle.
What's next? Head over to the Promises tutorial to learn how JavaScript handles asynchronous error handling.
  • What is the difference between try...catch and try...catch...finally?
  • When does the finally block run?
  • What types of errors does try...catch not catch?
  • What is the difference between TypeError and ReferenceError?
  • What does throw do and what should you throw?
  • How do you create a custom error class in JavaScript?
  • What is error propagation and how does it work?
  • When should you re-throw an error instead of handling it?
  • How do you handle errors in asynchronous code?
  • What is the difference between e.message and e.stack?

Reviewed by

SimplyJavaScript Editorial Team

Technical editors and JavaScript educators with hands-on experience building frontend projects, writing learning material, and reviewing tutorials for clarity, accuracy, and beginner-friendly guidance.