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:
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
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
| Type | Thrown when |
|---|---|
Error | General failure; the base type for all the others |
TypeError | A value is the wrong type, such as calling something that is not a function |
RangeError | A number is outside the allowed range |
SyntaxError | Code or data cannot be parsed, including invalid JSON in JSON.parse |
ReferenceError | A 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
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
tryholds code that might throw. When a line throws, the rest of the block is skipped.catchruns only if something intrythrew. It receives the error.finallyalways runs: aftertrysucceeds, aftercatchhandles an error, and even after areturn.
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:
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:
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:
- Keep
tryblocks small, wrapping only the call that can fail for a reason you expect. - Check the error type, handle the ones you understand, and rethrow the rest.
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:
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)letsErrorset upmessageandstack.- Setting
namemakes the console showValidationError: ...instead ofError: .... - Extra properties such as
statuscarry details callers can use.
Now the dashboard's fetch helper from lesson 33 can throw something more useful than a plain Error:
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:
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:
| Style | How to handle errors |
|---|---|
async function with await | try...catch around the await |
| Promise chain | .catch() at the end of the chain |
| Callback API | Check the error argument in the callback |
| Event listener or timer callback | try...catch inside the callback itself |
A try...catch around code that schedules work cannot catch errors that happen later:
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:
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
throwanErroror a subclass, never a string, so you get a stack trace.tryholds risky code,catchhandles failures, andfinallyalways runs. Neverreturnfromfinally.- Keep
tryblocks small, check the error type withinstanceof, and rethrow what you cannot handle. - Create custom errors by extending
Error, settingname, and adding useful properties such asstatus. - Wrap lower-level errors with
{ cause }to keep the original detail. - Async errors are caught with
try...catcharoundawaitor.catch(); errors inside later callbacks must be caught inside the callback. - Global
errorandunhandledrejectionhandlers are for reporting, not for normal error handling.