Error handling: try, catch, finally and custom errors

Throw errors that explain themselves, catch them where you can act on them, and give your own failures their own types.

  • Beginner
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Throw meaningful errors
  • Create custom error classes
  • Handle errors in async code
  • Decide where an error should be caught

Before you start

Things go wrong: a request times out, a user types letters into a number field, a file is missing. Good error handling means failures are reported clearly, caught at a point where something sensible can happen, and never silently swallowed. This lesson covers the tools JavaScript gives you, and how to create error types for your own code.

Throwing an error

throw stops the current function and hands an error to the nearest catch up the call stack:

JavaScript
function setLessonCount(count) {
  if (!Number.isInteger(count) || count < 1) {
    throw new RangeError(`Lesson count must be a positive integer, got ${count}`);
  }
  return count;
}

setLessonCount(-3);
// Uncaught RangeError: Lesson count must be a positive integer, got -3

JavaScript lets you throw any value, even a string, but always throw an Error object or a subclass of it. Only Error objects carry a stack trace showing where the problem happened.

What an Error contains

JavaScript
const error = new TypeError("Expected an array");

console.log(error.name);    // → TypeError
console.log(error.message); // → Expected an array
console.log(error.stack);   // → TypeError: Expected an array, followed by file and line details

stack is not part of the language specification, but every current browser and Node.js provide it. Its format differs between engines, so read it, but do not parse it.

Built-in error types

TypeThrown when
ErrorGeneral failure; the base type for all the others
TypeErrorA value is the wrong type, such as calling something that is not a function
RangeErrorA number is outside the allowed range
SyntaxErrorCode or data cannot be parsed, including invalid JSON in JSON.parse
ReferenceErrorA variable that does not exist is used

The engine throws these for you, and you can throw them yourself when they describe the problem well, as setLessonCount does.

try, catch and finally

JavaScript
function parseCourse(text) {
  try {
    const course = JSON.parse(text);
    console.log("Parsed", course.title);
    return course;
  } catch (error) {
    console.error("Invalid course data:", error.message);
    return null;
  } finally {
    console.log("Parsing attempt finished");
  }
}

parseCourse('{"title": "JavaScript Basics"}');
// → Parsed JavaScript Basics
// → Parsing attempt finished

parseCourse("{title}");
// → Invalid course data: (the exact wording depends on the engine)
// → Parsing attempt finished
  • try holds code that might throw. When a line throws, the rest of the block is skipped.
  • catch runs only if something in try threw. It receives the error.
  • finally always runs: after try succeeds, after catch handles an error, and even after a return.
JavaScript
function demo() {
  try {
    return "from try";
  } finally {
    console.log("finally runs first");
  }
}

console.log(demo());
// → finally runs first
// → from try

Use finally for clean-up that must happen whatever the outcome: hiding a spinner, re-enabling a button, clearing a timer.

Never return from inside finally. A return there overrides the try block's return value and silently discards any error that was being thrown.

If you do not need the error object, you can leave out the binding:

JavaScript
function isValidJSON(text) {
  try {
    JSON.parse(text);
    return true;
  } catch {
    return false;
  }
}

Catch only what you can handle

A catch block catches everything thrown inside try, including your own bugs. This one hides a typo:

JavaScript
try {
  const course = JSON.parse(text);
  renderCourse(cuorse); // ReferenceError: cuorse is not defined
} catch {
  showMessage("The course data is invalid."); // wrong message, real bug hidden
}

Two habits prevent this:

  1. Keep try blocks small, wrapping only the call that can fail for a reason you expect.
  2. Check the error type, handle the ones you understand, and rethrow the rest.
JavaScript
function showCourseFromText(text) {
  let course;
  try {
    course = JSON.parse(text);
  } catch (error) {
    if (error instanceof SyntaxError) {
      showMessage("The course data is invalid.");
      return;
    }
    throw error; // not ours to handle
  }
  renderCourse(course);
}

Custom error classes

Built-in types say what kind of thing went wrong in the language. Your application has its own kinds of failure: a validation problem, a missing record, an HTTP error. Giving each its own class lets callers tell them apart with instanceof.

A custom error extends Error. The class syntax is covered fully in the next lesson; this is all you need here:

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

class HttpError extends Error {
  constructor(response) {
    super(`Request failed with status ${response.status}`);
    this.name = "HttpError";
    this.status = response.status;
    this.url = response.url;
  }
}
  • super(message, options) lets Error set up message and stack.
  • Setting name makes the console show ValidationError: ... instead of Error: ....
  • Extra properties such as status carry details callers can use.

Now the dashboard's fetch helper from lesson 33 can throw something more useful than a plain Error:

JavaScript
async function getJSON(url) {
  const response = await fetch(url);
  if (!response.ok) throw new HttpError(response);
  return response.json();
}

try {
  const courses = await getJSON("data/courses.json");
  renderCourses(courses);
} catch (error) {
  if (error instanceof HttpError && error.status === 404) {
    showMessage("The course list has moved or been removed.");
  } else if (error instanceof HttpError) {
    showMessage(`The server had a problem (${error.status}). Please try again.`);
  } else {
    throw error;
  }
}

Wrapping errors with cause

When you catch a low-level error and throw a higher-level one, keep the original as the cause. The cause option was added in ES2022 and is supported in all current browsers and Node.js:

JavaScript
function parseSettings(text) {
  try {
    return JSON.parse(text);
  } catch (error) {
    throw new ValidationError("Settings file is not valid JSON", { cause: error });
  }
}

try {
  parseSettings("{oops");
} catch (error) {
  console.log(error.name);       // → ValidationError
  console.log(error.cause.name); // → SyntaxError
}

This is why the ValidationError constructor passes options on to super. Callers get a clear, domain-level error, and the original detail is still there for debugging. DevTools and Node.js show the cause when they print the error.

Errors in asynchronous code

The rules from lessons 31 and 32 come together here:

StyleHow to handle errors
async function with awaittry...catch around the await
Promise chain.catch() at the end of the chain
Callback APICheck the error argument in the callback
Event listener or timer callbacktry...catch inside the callback itself

A try...catch around code that schedules work cannot catch errors that happen later:

JavaScript
try {
  setTimeout(() => {
    throw new Error("Too late to catch");
  }, 100);
} catch {
  console.log("Never runs");
}

The try block finished long before the timer fired. Handle the error inside the callback instead.

An await without try is fine, as long as someone further up handles the rejected promise. What is not fine is a promise that nobody awaits or catches at all.

Last-resort handlers

Errors that nobody catches reach global handlers. In the browser:

JavaScript
window.addEventListener("error", (event) => {
  console.log("Uncaught error:", event.error);
});

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

These are for logging and reporting, for example sending details to your own error tracking endpoint. They are not a substitute for handling errors where they occur, because by the time they fire, the code that failed has already stopped. Node.js has the equivalent process.on("uncaughtException") and process.on("unhandledRejection") events.

Writing good error messages

An error message is read by a developer under pressure. Make it useful:

  • Say what was expected and what was received: "Lesson count must be a positive integer, got -3", not "Invalid input".
  • Include the identifying detail: the URL, the ID, the field name.
  • Keep messages for people separate. Show users a calm, actionable message in the page; log the technical error for developers.
  • Fail early. Check arguments at the start of a function and throw straight away, rather than letting a bad value travel and fail somewhere confusing.

Summary

  • throw an Error or a subclass, never a string, so you get a stack trace.
  • try holds risky code, catch handles failures, and finally always runs. Never return from finally.
  • Keep try blocks small, check the error type with instanceof, and rethrow what you cannot handle.
  • Create custom errors by extending Error, setting name, and adding useful properties such as status.
  • Wrap lower-level errors with { cause } to keep the original detail.
  • Async errors are caught with try...catch around await or .catch(); errors inside later callbacks must be caught inside the callback.
  • Global error and unhandledrejection handlers are for reporting, not for normal error handling.

Further reading