Errors & Exceptions

JavaScript · Lesson 50 of 61 7 min read Updated

Every program meets problems: a user types letters into a number box, a server is down, saved data is broken. When JavaScript cannot carry on, it throws an error. If nothing catches it, the current code stops and an error appears in the console. Good programs expect these moments and respond calmly: they show a helpful message, keep the rest of the page working and record what went wrong.

In this lesson you will read JavaScript's built-in error types, catch errors with try...catch...finally, throw your own, attach the original error with cause, build custom error classes and learn where Error.isError() from ES2026 helps.

Built-in error types

There are two broad kinds of error. A syntax error means the code itself is not valid JavaScript, such as a missing bracket. The browser cannot run the file at all, and no try...catch inside it can help. A runtime error, also called an exception, happens while valid code runs. Those are the ones you can catch.

Each runtime error is an object with a name and a message:

Error typeTypical cause
ReferenceErrorUsing a variable that does not exist
TypeErrorUsing a value the wrong way, such as reading a property of null or calling something that is not a function
RangeErrorA number outside the allowed range
SyntaxErrorInvalid text given to a parser at run time, such as JSON.parse()
AggregateErrorSeveral errors at once, for example from Promise.any()
JavaScriptRun it yourself
const attempts = [
  () => missingVariable + 1,
  () => null.length,
  () => (12.5).toFixed(200),
  () => JSON.parse("{bad json}"),
];

for (const attempt of attempts) {
  try {
    attempt();
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
  }
}
console.log("The program is still running.");
Output
ReferenceError: missingVariable is not defined
TypeError: Cannot read properties of null (reading 'length')
RangeError: toFixed() digits argument must be between 0 and 100
SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)
The program is still running.

The output above comes from Chrome. Other browsers use the same error names but word some messages differently, so your code should check name or the error type, never the exact message text.

try, catch and finally

Put code that might fail inside try. If anything in it throws, JavaScript jumps straight to catch, which receives the error object. Code in finally runs at the end either way, whether the try succeeded, failed, or even returned early. That makes finally the right place for clean-up such as hiding a loading message or re-enabling a button.

Errors also have a stack property: a list of the function calls that led to the error, with file names and line numbers. It is very useful in the console and in error logs, but not for showing to users.

If you do not need the error object, you can leave out the brackets: catch { ... }.

Throwing your own errors

Use throw when your function receives something it cannot work with. Throw an Error object, ideally the type that fits best, with a message that says what is wrong:

JavaScriptRun it yourself
function payFare(wallet, fare) {
  if (typeof fare !== "number" || Number.isNaN(fare)) {
    throw new TypeError(`Fare must be a number, got ${typeof fare}`);
  }
  if (fare > wallet.balance) {
    throw new RangeError(`Need ₹${fare} but only ₹${wallet.balance} left`);
  }
  wallet.balance -= fare;
  return wallet.balance;
}

const wallet = { owner: "Farhan", balance: 120 };

for (const fare of [45, "60", 100]) {
  try {
    const left = payFare(wallet, fare);
    console.log(`Paid ₹${fare}, ₹${left} left`);
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
  } finally {
    console.log("Ticket counter ready for the next passenger");
  }
}
Output
Paid ₹45, ₹75 left
Ticket counter ready for the next passenger
TypeError: Fare must be a number, got string
Ticket counter ready for the next passenger
RangeError: Need ₹100 but only ₹75 left
Ticket counter ready for the next passenger

Checking inputs at the start of a function and throwing early is called failing fast. The error appears right where the problem is, instead of three functions later as a confusing NaN.

JavaScript lets you write throw "Something broke", but do not. A plain string has no name and no stack, so you lose the most useful debugging information.

Custom errors and cause

Sometimes you need to tell different problems apart. A user who typed a short mobile number needs a friendly hint next to that field; a broken file is a different story. A custom error class extends Error (see Inheritance & Prototypes), sets its name and can carry extra data, such as which field was wrong.

When you catch a low-level error and throw a clearer one, pass the original as cause: new Error("message", { cause: error }). The new message makes sense to the rest of your app, and the original details are kept for debugging.

JavaScriptRun it yourself
class ValidationError extends Error {
  constructor(field, message) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}

function readProfile(text) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (error) {
    throw new Error("Could not read the saved profile", { cause: error });
  }
  if (!/^[6-9]\d{9}$/.test(data.mobile ?? "")) {
    throw new ValidationError("mobile", "Enter a 10-digit mobile number");
  }
  if (!(data.age >= 18)) {
    throw new ValidationError("age", "You must be 18 or older");
  }
  return data;
}

const inputs = [
  '{"name":"Meera","mobile":"9876543210","age":24}',
  '{"name":"Rohan","mobile":"12345","age":30}',
  '{"name":"Isha","mobile":"9812345678","age":16}',
  '{"name": broken',
];

for (const text of inputs) {
  try {
    const profile = readProfile(text);
    console.log(`Welcome, ${profile.name}`);
  } catch (error) {
    if (error instanceof ValidationError) {
      console.log(`Please fix "${error.field}": ${error.message}`);
    } else {
      console.log(`${error.message} (cause: ${error.cause?.name})`);
    }
  }
}
Output
Welcome, Meera
Please fix "mobile": Enter a 10-digit mobile number
Please fix "age": You must be 18 or older
Could not read the saved profile (cause: SyntaxError)

Inside the custom class, super(message) must come first so Error can set up the message and stack. The instanceof check then lets the catch block treat expected validation problems differently from real failures. The mobile number check uses a regular expression, which you will learn in Regular Expressions.

Errors in asynchronous code

With async/await, a rejected promise throws at the await, so an ordinary try...catch works (see async & await). Without await, the rejection happens later, after the try block has finished, and escapes:

JavaScript
try {
  fetch("/api/profile"); // no await: a failure here is NOT caught below
} catch (error) {
  showMessage("Could not load profile");
}

As a last line of defence, you can log anything that slips through with two global listeners. They are for recording problems, for example by sending them to your server, not a replacement for proper handling:

JavaScript
window.addEventListener("error", (event) => {
  console.log("Uncaught error:", event.error);
});
window.addEventListener("unhandledrejection", (event) => {
  console.log("Unhandled promise rejection:", event.reason);
});

Common mistakes

  • Empty catch blocks. catch {} hides bugs completely. At least log the error, or let it go.
  • Catching everything. Catch the errors you can actually handle and re-throw the rest with throw error.
  • Throwing strings. Always throw Error objects so you keep name and stack.
  • Checking error.message text. Messages change between browsers and versions. Check the type or name.
  • Forgetting super(message) or this.name in a custom error. The message or the type name will be missing.

Key points

  • Syntax errors stop a file from running at all; runtime errors (exceptions) can be caught.
  • try holds risky code, catch handles a thrown error, and finally always runs for clean-up.
  • Throw Error objects of a fitting type, early, with clear messages.
  • Use new Error(message, { cause }) to wrap a low-level error without losing it.
  • Custom classes that extend Error let you tell expected problems apart with instanceof.
  • Error.isError() (ES2026) reliably recognises real error objects, even across iframes.

Exercise

Write a function parseQuantity(text) for a shop's quantity box. It should throw a TypeError if the text is not a whole number, and a RangeError if the number is less than 1 or more than 10. Test it with "3", "abc" and "25" inside one loop, logging either "Quantity: 3" or the error's name and message, and log "Checked" in a finally block each time.

Show solution
JavaScript
function parseQuantity(text) {
  const qty = Number(text);
  if (!Number.isInteger(qty)) {
    throw new TypeError(`"${text}" is not a whole number`);
  }
  if (qty < 1 || qty > 10) {
    throw new RangeError(`Quantity must be 1 to 10, got ${qty}`);
  }
  return qty;
}

for (const input of ["3", "abc", "25"]) {
  try {
    console.log(`Quantity: ${parseQuantity(input)}`);
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
  } finally {
    console.log("Checked");
  }
}

Number("abc") is NaN, which is not an integer, so the first check throws a TypeError. The finally block runs after every attempt, whether it succeeded or failed.

Quick Quiz

1. When does the finally block run?

2. Why should you throw new Error("...") instead of a plain string?

3. What is the cause option used for?

4. Why can Error.isError() be more reliable than instanceof Error?

Learn JavaScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call