JavaScript Error Handling: try/catch, Custom Error Classes and Error Chains with cause
We learn try/catch/finally and throw, the built-in error types, writing your own error class with extends Error, error chains with ES2022's cause, Error.isError (not in Safari yet) and showing errors to users properly.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
A return and a finally meet in the same function:
function test() {
try {
return "try";
} finally {
console.log("finally");
}
}
console.log(test());
What does this print to the console, and in what order? Make a guess before you read on. We learned that return ends a function; does finally still get its turn? We'll give the answer while covering try/catch/finally.
try, catch and finally
Think of the fire alarm in a building. When smoke is detected on a floor, the alarm goes off, work on that floor stops immediately and people go down the fire escape. If someone downstairs knows what to do (the assembly point, the fire department), everything is under control. If nobody does, it's chaos. Errors in JavaScript work the same way. When an error is thrown, the engine abandons the function that's currently running and walks up the call stack looking for the nearest catch that will handle it. If it doesn't find one, the error lands in the console as a red message and the rest of that script doesn't run.
We touched on try/catch briefly in the JSON post. Now let's see the whole thing. Paste this into your browser console and try it:
try {
const data = JSON.parse("{ broken json");
console.log(data); // never runs
} catch (error) {
console.log(error.name); // "SyntaxError"
console.log(error.message); // wording differs between browsers
} finally {
console.log("runs no matter what");
}
console.log("the program carries on");
The moment the code in the try block throws, the remaining lines are skipped and control moves to catch. The variable in the parentheses of catch is the thrown error itself. finally runs at the very end whether there was an error or not; it's ideal for cleanup work such as hiding a loading indicator or re-enabling a button. If you don't need the error object, since ES2019 you can drop the parentheses entirely and write just catch { ... }.
Now back to the opening question. The console prints "finally" first and then "try". The return "try" line prepares the return value, but the function doesn't exit right away; the engine runs finally first and then returns the pending value. finally really does mean "no matter what." So much so that if you write a return of its own inside finally, it overrides the return value from try, and even a thrown error. That's why you shouldn't use return in finally.
throw and the Error Object
We don't just catch errors; when needed, we throw them ourselves. When a function runs into a situation in which it can't do its job, rather than returning a wrong value and quietly carrying on, it should say loudly, "I can't do this." You do that with throw:
function withdraw(balance, amount) {
if (typeof amount !== "number" || Number.isNaN(amount)) {
throw new TypeError("Amount must be a number");
}
if (amount > balance) {
throw new RangeError(`Insufficient balance: you have ${balance} USD, asked for ${amount} USD`);
}
return balance - amount;
}
try {
withdraw(100, 250);
} catch (error) {
console.log(error.name); // "RangeError"
console.log(error.message); // "Insufficient balance: you have 100 USD, asked for 250 USD"
console.log(error.stack); // where the error was created
}
An error object has three important properties. name tells you the type of error and message what happened. stack shows in which function and on which line the error was created and which calls led there. stack isn't defined in the standard, but every browser supports it, and it's your best friend when debugging.
JavaScript actually lets you throw anything: throw "something went wrong" works too. But a string has no stack, you can't tell its type apart, and whoever catches it is left holding nothing but some text. Always throw an Error object.
Built-in Error Types
The engine throws errors itself too, and they come in different types. They all derive from Error. These are the ones you'll see most often. TypeError shows up when an operation doesn't fit a value's type: reading a property of null, or calling something that isn't a function. ReferenceError comes when you access an undeclared variable or a let in its TDZ. RangeError is thrown when a value is outside the allowed range, and SyntaxError when code, or the text passed to JSON.parse, breaks the language rules.
There are rarer ones as well: decodeURIComponent throws a URIError when it meets broken input, and EvalError is an old leftover you'll rarely run into. AggregateError, which arrived with ES2021, carries several errors in one package; we'll see it with promises in a later post. You tell the types apart with instanceof:
function tryRun(fn) {
try {
fn();
} catch (error) {
if (error instanceof TypeError) {
console.log("Type error:", error.message);
} else if (error instanceof RangeError) {
console.log("Range error:", error.message);
} else {
throw error; // unknown error, pass it on
}
}
}
tryRun(() => null.length); // Type error: ...
tryRun(() => new Array(-1)); // Range error: Invalid array length
tryRun(() => decodeURIComponent("%")); // URIError, rethrown
The last line will show a red URIError in the console; that's on purpose. tryRun ran into an error it didn't recognize and, rather than swallowing it, threw it again. This is an important habit: only catch the errors you know what to do with, and leave the rest to the code above.
Your Own Error Class
The built-in types are fine for the engine's errors, but for your application's own errors you usually want more meaningful names: a validation error, a not-found error, a permission error. For that, you write your own class that derives from Error. We'll cover class syntax in detail in the next post; for now we're just using the pattern:
class ValidationError extends Error {
constructor(field, message) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
function validateEmail(email) {
if (!email.includes("@")) {
throw new ValidationError("email", "Please enter a valid email address");
}
return email;
}
try {
validateEmail("ada.example.com");
} catch (error) {
if (error instanceof ValidationError) {
console.log(`${error.field}: ${error.message}`); // "email: Please enter a valid email address"
} else {
throw error;
}
}
Each of the three lines does a job. super(message) runs Error's own setup; message and stack come from there. Without the this.name line, the console would still show the error's name as "Error." field is information we added; it tells us under which form field to write the red message. And the catching side can now easily tell its own error apart from the others with instanceof ValidationError.
Error Chains with cause
In real applications, errors happen in layers. A function that reads a settings file gets a SyntaxError from JSON.parse. If you throw that straight up, the code above runs into a cryptic JSON message instead of "couldn't load settings." If you swallow it, the real reason is lost. The cause option, which arrived with ES2022, lets you do both: you throw a meaningful new error and put the original error inside it.
function loadSettings(text) {
try {
return JSON.parse(text);
} catch (error) {
throw new Error("Could not load settings", { cause: error });
}
}
try {
loadSettings("{ theme: dark }");
} catch (error) {
console.log(error.message); // "Could not load settings"
console.log(error.cause.name); // "SyntaxError"
console.log(error.cause.message); // the original JSON.parse message
}
Think of it as a chain: each link carries a message that makes sense at its own level, and cause holds on to the link below. You show the user the top message and write the whole chain to the error log. To support cause in your own Error class, just add an options parameter to the constructor and call super(message, options).
Error.isError: Is This Really an Error?
We usually check whether a value is an error with instanceof Error. That works most of the time, but it has two weak spots. An error coming from an iframe derives from that iframe's own Error, so the instanceof Error check on the main page returns false. On top of that, instanceof can be fooled; a plain object whose prototype is Error.prototype passes the test too. Error.isError, which arrived with ES2026, checks whether the value really is an error object created by the engine:
const fake = Object.create(Error.prototype);
console.log(fake instanceof Error); // true
console.log(Error.isError(fake)); // false
console.log(Error.isError(new TypeError("x"))); // true
console.log(Error.isError({ message: "x" })); // false
There's an important note here: Error.isError has worked since Chrome and Edge 134 and Firefox 138, but Safari doesn't support it yet. Its Baseline status is "limited." In code that serves all your users, use it with a small fallback for now:
const isError = (value) =>
typeof Error.isError === "function" ? Error.isError(value) : value instanceof Error;
console.log(isError(new RangeError("x"))); // true
Showing Errors to Users and Catching Them Globally
Catching an error is half the job; the other half is explaining it to the user the right way. Users don't want to see TypeError: Cannot read properties of undefined; they want to know what to do. Developers, on the contrary, want every detail. So you show the user a plain message and write the details to the console or an error log. Never print the stack on the screen.
For errors that no catch handles, the browser fires an error event on window. It's the last safety net: it doesn't fix the error, but it keeps you from leaving the user alone with a blank screen. Save this small HTML file, open it in your browser, first check with the age field empty, then click the broken button:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Error messages</title>
<style>.error { color: #b00020; }</style>
</head>
<body>
<label>Age: <input id="age" type="text"></label>
<button id="check">Check</button>
<button id="broken">Broken button</button>
<p id="message"></p>
<script>
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = "ValidationError";
}
}
function parseAge(text) {
const age = Number(text);
if (text.trim() === "" || Number.isNaN(age)) {
throw new ValidationError("Please enter your age in digits.");
}
if (age < 0 || age > 130) {
throw new ValidationError("Age must be between 0 and 130.");
}
return age;
}
const output = document.getElementById("message");
document.getElementById("check").addEventListener("click", () => {
try {
const age = parseAge(document.getElementById("age").value);
output.className = "";
output.textContent = `Thanks, you are ${age} years old.`;
} catch (error) {
if (!(error instanceof ValidationError)) throw error;
output.className = "error";
output.textContent = error.message;
}
});
document.getElementById("broken").addEventListener("click", () => {
notDefinedAnywhere(); // ReferenceError, nobody catches it
});
window.addEventListener("error", (event) => {
console.log("Uncaught error:", event.error);
output.className = "error";
output.textContent = "Something unexpected went wrong. Please reload the page and try again.";
});
</script>
</body>
</html>
Validation errors are expected errors, and they're handled in their own catch with a clear message next to the field. The ReferenceError in the broken button, on the other hand, is unexpected; the global listener catches it. Also notice that we write the message to the DOM with textContent; an error message may contain text that came from the user, and innerHTML would open the door to XSS here.
In older code you'll also see the window.onerror = (message, source, line, column, error) => { ... } assignment doing the same job. Uncaught errors in promises come through a separate event, unhandledrejection; we'll come back to it when we cover promises.
Common Pitfalls
The empty catch. Writing catch {} and swallowing the error is like cutting the alarm's wire. The problem doesn't go away; you just stop hearing it. Unless you genuinely need to ignore a situation, at least log it with console.error.
Wrapping everything in one try and treating every error the same. Wrapping all your code and saying "invalid input" on every error makes a real bug look like a user mistake. Check the type and rethrow what you don't recognize.
Trying to catch an error inside a timer from the outside. try { setTimeout(() => { throw new Error("late") }, 0); } catch {} catches nothing. By the time the callback runs, the try block finished long ago. If the error should be caught, the try/catch has to be inside the callback. We'll see how asynchronous errors are handled in the posts on promises and async/await.
Using errors for normal control flow. If a user not being found is an ordinary situation in your application, returning null is often enough. Errors should be for truly exceptional situations; otherwise you end up wrapping every call in a try.
A Small Experiment
★☆☆ Predict before running: try { console.log("a"); throw new Error("b"); console.log("c"); } catch (e) { console.log(e.message); } finally { console.log("d"); } Which letters are printed, and in what order?
★★☆ Write a function called safeParse(text, fallback). If the text is valid JSON, it returns the result; otherwise it returns fallback and logs the error with console.warn.
★★☆ Write a NotFoundError class that carries an id property. Have findUser(users, id) throw it when the user can't be found. On the calling side, catch NotFoundError and rethrow every other error.
★★★ Build a three-layer chain: readRaw returns a broken JSON string, parseProfile tries to parse it and wraps any error with cause, and loadProfile does the same one level up. Then write a function called printCauseChain(error) that follows the causes with a while loop and prints each message indented further than the previous one. Use the isError helper above for the error check.
In this post we wrote class ValidationError extends Error, but we treated class, constructor, super and extends as a pattern for now. In the next post we'll take classes from the top: constructors, methods, getters and setters, static members, private fields with #, and inheritance.