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 the try block threw. The parameter (commonly named error or err) holds the thrown value, which is almost always an Error instance. The catch parameter is optional in modern JavaScript if you don’t need the error object (catch { ... }).
  • finally — optional; runs no matter what — whether the try succeeded, threw, or even returned a value. Ideal for cleanup code.
  • throw — raises any value as an error, though you should always throw an Error instance (or subclass) so it carries a stack trace.

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 throw Error instances (or subclasses), never raw strings or numbers, so every error carries a useful stack trace.
  • Create custom error classes for domain-specific failures so callers can branch with instanceof instead of parsing message text.
  • Keep try blocks small — wrap only the statement(s) that can actually throw, not large chunks of unrelated logic.
  • Use finally for 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 async functions, wrap await calls in try/catch, and never leave a Promise without an attached .catch() or an enclosing try.
  • Use the standard cause option, 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 just error.message, so you can see exactly where the failure originated.

Practice Exercises

  1. Write a function safeParseJSON(text) that uses JSON.parse inside a try/catch and returns null instead of throwing when text is invalid JSON.
  2. Create a custom error class InsufficientFundsError extending Error that stores balance and amount properties. Write a withdraw(balance, amount) function that throws it whenever amount exceeds balance.
  3. Write an async function that awaits a promise which may reject, catches the rejection, and logs a friendly message built from error.name and error.message instead of letting the rejection crash the program.

Summary

  • throw raises an error; try/catch/finally lets you intercept it and recover, or clean up, without crashing the program.
  • Every Error has name, message, and stack; finally always runs on the way out of a try, even after a return.
  • Built-in types like TypeError, RangeError, and ReferenceError describe engine-detected failures; custom classes extending Error describe your own domain’s failures.
  • Use instanceof to branch on error type instead of matching fragile message text.
  • In async code, a throw becomes a promise rejection — handle it with try/catch around await, or a .catch() on the promise.
  • Always throw real Error objects, never raw strings, so failures keep a useful stack trace for debugging.