JavaScript Errors
An error in JavaScript is what happens when something goes wrong while your code runs — a variable doesn’t exist, a function is called with the wrong type of argument, or data arrives in a shape you didn’t expect. Left unhandled, an error stops your program (or a single request, in Node.js) dead in its tracks. JavaScript gives you a small but powerful toolkit — throw, try/catch/finally, and a family of built-in Error types — to detect problems, react to them gracefully, and keep the rest of your program running. Mastering error handling is what separates a script that only works on the happy path from software that survives contact with real users and real data.
Overview: How Errors Work in JavaScript
Every error in JavaScript is represented by an object — usually an instance of Error or one of its built-in subclasses. When something goes wrong, the JavaScript engine (or your own code) creates one of these objects and raises it with the throw keyword. Throwing immediately stops normal execution and begins unwinding the call stack: the engine walks back up through each function call that led to the throw, looking for a try block with a matching catch. If it finds one, execution resumes inside that catch block with the error object available to inspect. If it reaches the top of the call stack without finding a catch, the error is "uncaught" — in a browser this prints a stack trace to the console; in Node.js it crashes the process unless a global handler (like process.on("uncaughtException")) is registered.
Every Error object carries at least three useful properties: name (the type of error, e.g. "TypeError"), message (a human-readable description), and stack (a multi-line string showing the call chain at the moment the error was created — invaluable for debugging, though its exact format isn’t standardized across engines). Errors are just objects, so you can attach your own custom properties to them, which is how you build rich, structured error information for logging and handling.
JavaScript distinguishes between errors you create deliberately with throw (for example, validating input and throwing a descriptive error when it’s invalid) and errors the engine itself throws when your code does something impossible, such as calling a method on undefined or exceeding the maximum call stack size. Both kinds flow through exactly the same try/catch machinery, which is why understanding the built-in error types matters even if you never write throw yourself.
Syntax
The core shape of error handling in JavaScript is the try/catch/finally statement, paired with throw to raise an error in the first place:
try {
// code that might throw an error
} catch (error) {
// runs only if the try block threw something
} finally {
// always runs, whether or not an error was thrown
}
throw new Error("something went wrong");
try— wraps the code you want to monitor for errors. If any statement inside throws, the rest of the block is skipped immediately.catch (error)— runs only if thetryblock threw. The parameter (commonly namederrororerr) holds the thrown value, which is almost always anErrorinstance. Thecatchparameter is optional in modern JavaScript if you don’t need the error object (catch { ... }).finally— optional; runs no matter what — whether thetrysucceeded, threw, or even returned a value. Ideal for cleanup code.throw— raises any value as an error, though you should always throw anErrorinstance (or subclass) so it carries astacktrace.
Built-in Error Types
JavaScript ships with several specialized Error subclasses. The engine throws these automatically in specific situations, and you can also throw them yourself when they fit the failure you’re describing.
| Type | Thrown when |
|---|---|
Error |
The generic base type; used for any error that doesn’t fit a more specific category. |
TypeError |
A value is not of the expected type, e.g. calling a non-function, or reading a property of null/undefined. |
RangeError |
A value is outside its allowed range, e.g. an invalid array length or too many recursive calls. |
ReferenceError |
Referencing a variable or identifier that doesn’t exist in scope. |
SyntaxError |
Invalid code, usually thrown while parsing, such as malformed JSON passed to JSON.parse. |
URIError |
encodeURI()/decodeURI() is called with malformed input. |
EvalError |
Legacy; reserved for issues with eval(), essentially unused in modern JavaScript. |
Examples
Example 1: Basic try/catch/finally
function divide(a, b) {
if (b === 0) {
throw new Error("Division by zero is not allowed");
}
return a / b;
}
try {
const result = divide(10, 0);
console.log("Result:", result);
} catch (error) {
console.log("Caught an error:", error.message);
} finally {
console.log("Division attempt finished");
}
Output:
Caught an error: Division by zero is not allowed
Division attempt finished
Because b is 0, divide throws before it can return anything. The throw immediately exits divide and jumps to the nearest catch, so "Result:" is never logged. Notice that finally still runs afterward — it always executes, regardless of whether the try block succeeded or threw.
Example 2: A custom error class
class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
function createUser(user) {
if (!user.email) {
throw new ValidationError("Email is required", "email");
}
return { id: 1, ...user };
}
try {
createUser({ name: "Ada" });
} catch (error) {
if (error instanceof ValidationError) {
console.log(`${error.name}: ${error.message} (field: ${error.field})`);
} else {
console.log("Unexpected error:", error);
}
}
Output:
ValidationError: Email is required (field: email)
ValidationError extends the built-in Error, calling super(message) so the base class still sets up message and stack correctly. Adding a custom field property lets the catch block report exactly which input failed, and checking instanceof ValidationError lets you handle this specific failure differently from any other unexpected error.
Example 3: Error handling in async code
async function fetchUserAge(user) {
if (typeof user !== "object" || user === null) {
throw new TypeError("user must be an object");
}
if (!("age" in user)) {
throw new RangeError("user object is missing an age property");
}
return user.age;
}
async function run() {
const users = [{ age: 30 }, "not-a-user", { name: "Grace" }];
for (const user of users) {
try {
const age = await fetchUserAge(user);
console.log("Age:", age);
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
}
}
run();
Output:
Age: 30
TypeError: user must be an object
RangeError: user object is missing an age property
Even though fetchUserAge is async, wrapping the await call in try/catch works exactly like synchronous code: a rejected promise (caused here by a throw inside an async function) is converted back into a normal thrown error at the await point, so the surrounding catch still handles it.
Under the Hood: Error Propagation and the Call Stack
When the engine executes a throw statement, it doesn’t simply jump to a catch block — it performs stack unwinding. For each function frame between the throw and the nearest enclosing try, JavaScript exits that frame immediately, running any finally blocks along the way, without executing the remaining code in that frame. This is why a finally block is guaranteed to run even if the try block returns, throws, or exits a loop via break/continue — the engine treats "run the finally block" as a required step of leaving every frame on the way out, no matter how it’s leaving. Only after every relevant finally has executed does control resume inside the matching catch (or, if there is no catch, unwinding continues further up the stack until the error becomes uncaught and reaches the global handler).
Asynchronous code behaves slightly differently. A throw inside a purely synchronous function propagates immediately, layer by layer, in the same tick. But a throw inside an async function, or a rejected Promise, doesn’t unwind anything synchronously — it stores the failure on the promise until something calls .catch() on it or awaits it inside a try block. If you forget to attach a .catch() or wrap an await in try/catch, the rejection can go completely unhandled, which is why unhandled promise rejections are a distinct category of bug from uncaught synchronous errors.
Common Mistakes
Mistake 1: Swallowing errors silently
An empty catch block hides real failures, making bugs nearly impossible to diagnose later:
function riskyOperation() {
throw new Error("Something went wrong");
}
try {
riskyOperation();
} catch (e) {
// silently ignored
}
console.log("Program continues as if nothing happened");
Output:
Program continues as if nothing happened
The error vanishes with no trace, so nobody ever finds out riskyOperation failed. At minimum, log the error; often you should rethrow it so a higher-level handler can decide what to do:
function riskyOperation() {
throw new Error("Something went wrong");
}
try {
riskyOperation();
} catch (error) {
console.error("riskyOperation failed:", error.message);
}
console.log("Program continues, but the failure was logged");
Output:
riskyOperation failed: Something went wrong
Program continues, but the failure was logged
Mistake 2: Matching errors by message text instead of type
Comparing error.message against a hardcoded string is fragile — the wording can change between library versions, or between locales, silently breaking your logic:
function safeDivide(a, b) {
try {
if (b === 0) throw new Error("division by zero");
return a / b;
} catch (error) {
if (error.message === "division by zero") {
return Infinity;
}
throw error;
}
}
console.log(safeDivide(10, 0));
console.log(safeDivide(10, 2));
Output:
Infinity
5
This works today, but it breaks the moment the message wording changes anywhere in the codebase. A custom error class checked with instanceof is far more robust, since it doesn’t depend on exact text:
class DivisionByZeroError extends Error {
constructor() {
super("division by zero");
this.name = "DivisionByZeroError";
}
}
function safeDivide(a, b) {
try {
if (b === 0) throw new DivisionByZeroError();
return a / b;
} catch (error) {
if (error instanceof DivisionByZeroError) {
return Infinity;
}
throw error;
}
}
console.log(safeDivide(10, 0));
console.log(safeDivide(10, 2));
Output:
Infinity
5
A related, smaller trap worth naming: code like throw "Something broke" is legal JavaScript, but it throws a plain string instead of an Error. A caught value like that has no .stack and no .message, which makes debugging much harder. Always throw a real Error (or subclass), even for quick, informal failures.
Best Practices
- Always
throwErrorinstances (or subclasses), never raw strings or numbers, so every error carries a usefulstacktrace. - Create custom error classes for domain-specific failures so callers can branch with
instanceofinstead of parsing message text. - Keep
tryblocks small — wrap only the statement(s) that can actually throw, not large chunks of unrelated logic. - Use
finallyfor cleanup that must always run, such as releasing a lock or closing a connection, regardless of success or failure. - Don’t catch errors you can’t meaningfully handle — rethrow them, or don’t catch at all, so a higher-level handler gets the chance to react.
- In
asyncfunctions, wrapawaitcalls intry/catch, and never leave a Promise without an attached.catch()or an enclosingtry. - Use the standard
causeoption, e.g.new Error("failed to save", { cause: originalError }), to chain a lower-level error without losing its context. - Log the full error object (or at least
error.stack), not justerror.message, so you can see exactly where the failure originated.
Practice Exercises
- Write a function
safeParseJSON(text)that usesJSON.parseinside atry/catchand returnsnullinstead of throwing whentextis invalid JSON. - Create a custom error class
InsufficientFundsErrorextendingErrorthat storesbalanceandamountproperties. Write awithdraw(balance, amount)function that throws it wheneveramountexceedsbalance. - Write an
asyncfunction that awaits a promise which may reject, catches the rejection, and logs a friendly message built fromerror.nameanderror.messageinstead of letting the rejection crash the program.
Summary
throwraises an error;try/catch/finallylets you intercept it and recover, or clean up, without crashing the program.- Every
Errorhasname,message, andstack;finallyalways runs on the way out of atry, even after areturn. - Built-in types like
TypeError,RangeError, andReferenceErrordescribe engine-detected failures; custom classes extendingErrordescribe your own domain’s failures. - Use
instanceofto branch on error type instead of matching fragile message text. - In async code, a
throwbecomes a promise rejection — handle it withtry/catcharoundawait, or a.catch()on the promise. - Always throw real
Errorobjects, never raw strings, so failures keep a useful stack trace for debugging.
