JavaScript Error Handling

An error occurs when JavaScript encounters a problem that prevents the current operation from completing normally. JavaScript can throw an error, and if it is not handled, the current execution flow is interrupted and the error becomes uncaught.

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.

Built-in JavaScript error objects are instances of the Error class or one of its subclasses. 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
}

A synchronous error thrown while a try block is executing can be caught by its catch. A rejected Promise is not automatically caught by a surrounding try...catch; handle it with .catch() or use await inside the try block.

javascript

function loadSettings() {
  return Promise.reject(new Error("Settings unavailable"));
}

// Handle a rejected Promise with .catch()
loadSettings()
  .then(settings => console.log(settings))
  .catch(error => {
    console.error("Could not load settings:", error.message);
  });

// The equivalent async/await form
async function showSettings() {
  try {
    const settings = await loadSettings();
    console.log(settings);
  } catch (error) {
    console.error("Could not load settings:", error.message);
  }
}

showSettings();

A try...catch only catches errors thrown during execution of its try block. A Promise rejection must be handled with await inside that block or with .catch() on the Promise.

The fetch() API adds another detail: network failures reject the Promise, but a response such as 404 or 500 normally resolves. Check response.ok or response.status; throwing an error when the status is unsuccessful deliberately converts the HTTP failure into a rejected Promise that catch can handle.

javascript

// HTML: <p id="status"></p>

async function loadUser() {
  try {
    const response = await fetch("/api/user");

    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error("User request failed:", error.message);
    throw error; // let the UI decide how to display the failure
  }
}

loadUser().catch(() => {
  document.querySelector("#status").textContent = "Could not load your profile.";
});
Async checklist: handle every Promise chain, use await inside try...catch, check response.ok for fetch(), show a useful user-facing message, and re-throw when a higher layer should decide what happens next.

The try...catch statement is the main way to handle runtime errors in JavaScript. The try block contains code that may throw. If an error is thrown while it is executing, control moves to catch, which receives the thrown value. JavaScript conventionally throws Error objects or subclasses. If the error is handled, execution continues after the try...catch statement.

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 catches errors thrown while the try block is running. It does not catch syntax errors in the surrounding script (those prevent the script from starting), and it does not catch errors thrown later inside an independent asynchronous callback. For Promise-based code, use await inside try...catch or handle the Promise with .catch().

A common mistake is assuming that try...catch automatically handles every fetch() failure. fetch() rejects for network failures, but HTTP responses such as 404 or 500 normally resolve successfully. Check response.ok or response.status when HTTP status errors should be treated as failures.

The finally block runs after the try/catch processing, whether the operation succeeds or an error is caught. It also runs when control leaves the flow with a return.

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 stops the current execution flow and passes the thrown value to the nearest matching catch. If no local handler matches, the error propagates to the caller.

JavaScript technically allows any value to be thrown, including a string, number, or object. The recommended convention is to throw an Error instance or subclass, because it provides useful name, message, and stack information:

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. In everyday code, TypeError, ReferenceError, SyntaxError, and RangeError are especially useful to recognize:

  • 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
  }
}

Advanced: Custom error classes are especially useful in larger applications. Extend Error to 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
  }
}

If an error is thrown and not caught in the current function, it propagates to the caller. This continues up the call stack until a matching catch handles it or the error becomes uncaught.

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: Catch an error when you can handle it meaningfully. If you cannot handle it at that level, let it propagate or re-throw it. Avoid catching errors only to silence them.
  • 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. JavaScript allows any thrown value, but the recommended convention is 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.
  • Async errors: handle rejected Promises with .catch() or await inside try...catch. Check response.ok because fetch() does not reject for HTTP 4xx or 5xx responses.
What's next? Head over to the Promises tutorial to learn how JavaScript handles asynchronous error handling.

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.