<< Home

Chapter 14: Errors and Exceptions

Even the best-written programs can fail. Networks disconnect, files go missing, input is invalid, or assumptions simply turn out to be wrong. In JavaScript, such unexpected situations are handled through a structured mechanism known as exceptions. By learning how to detect, throw, and handle errors properly, you can make your programs far more resilient and predictable.

JavaScript provides a consistent model for signalling and recovering from problems. You can raise an error intentionally with the throw statement, intercept it using try and catch blocks, and ensure necessary cleanup through finally. This approach allows you to separate normal logic from failure handling, keeping code both robust and readable.

In this chapter, you will explore how exceptions work, the different Error types available, and how to define your own custom error classes for clearer intent. You will see how control flow behaves when exceptions occur, and how to use defensive coding techniques to prevent small problems from cascading into major failures.

πŸ’‘ Good error handling is about communicating clearly what went wrong and allowing your program to recover gracefully wherever possible.

By the end of this chapter, you will be able to write code that anticipates failure, responds appropriately, and provides helpful feedback instead of leaving users with cryptic messages or silent errors. Robust programs fail predictably, not mysteriously.

throw and Error types

JavaScript uses the throw statement to signal that something has gone wrong. When a value is thrown, normal execution stops immediately and control passes to the nearest matching catch block higher in the call stack. If no handler is found, the program (or the current script context) terminates and displays an error message.

function divide(a, b) {
  if (b === 0) {
    throw new Error("Cannot divide by zero");
  }
  return a / b;
}

console.log(divide(10, 2));  // 5
console.log(divide(10, 0));  // Uncaught Error: Cannot divide by zero

In this example, the throw statement interrupts execution of the divide() function when an invalid argument is detected. The thrown object (in this case an instance of Error) contains information about what went wrong. You can throw any value, but using Error objects ensures consistent formatting and useful diagnostic data.

πŸ’‘ Although you can throw strings, numbers, or other objects, always prefer Error instances. They include a stack trace and integrate better with debugging tools.

The Error constructor

The Error constructor creates an error object with a message property and, in most environments, a stack trace showing where the error occurred.

const err = new Error("Something went wrong");
console.log(err.name);     // "Error"
console.log(err.message);  // "Something went wrong"
console.log(err.stack);    // stack trace

Different kinds of errors inherit from Error, allowing more specific handling of distinct failure types.

Built-in error types

JavaScript defines several standard error types for common situations:

try {
  let x = undeclared;      // ReferenceError
} catch (e) {
  console.log(e.name);     // "ReferenceError"
  console.log(e.message);  // "undeclared is not defined"
}

By identifying the name property, you can distinguish between different error types and handle them appropriately.

⚠️ Not all errors are recoverable. For example, SyntaxError exceptions occur at parse time and cannot be caught by try...catch in the same script, as they prevent execution from starting at all.

Understanding how to throw and identify errors is the foundation for effective exception handling. In the next section, you will see how to catch and manage them cleanly with try, catch, and finally blocks.

try, catch, and finally

The try...catch statement allows your code to anticipate and respond to runtime errors without halting execution. Code that might throw an exception is placed inside a try block, and any resulting error is passed to the catch block, where it can be handled gracefully. An optional finally block runs after either of these, regardless of whether an error occurred, making it ideal for cleanup operations.

try {
  console.log("Starting calculation...");
  const result = divide(10, 0);    // throws an Error
  console.log("Result:", result);  // skipped
} catch (err) {
  console.error("An error occurred:", err.message);
} finally {
  console.log("Calculation complete.");
}

In this example, once the divide() function throws an error, control jumps immediately to the catch block. The finally section then executes regardless of what happened inside the try block, ensuring that essential code (such as closing connections or releasing resources) always runs.

πŸ’‘ Always use finally for cleanup tasks that must happen even when errors occur, such as closing files, resetting state, or releasing locks.

Catching and inspecting errors

The catch block receives a single parameter representing the thrown value. In most cases, this will be an Error object. You can inspect its properties to determine the cause and act accordingly.

try {
  JSON.parse("invalid");
} catch (err) {
  console.log(err.name);     // "SyntaxError"
  console.log(err.message);  // "Unexpected token i in JSON at position 0"
}

If you only need to handle a problem without referencing the specific error object, you can omit the identifier entirely:

try {
  riskyOperation();
} catch {
  console.log("Something went wrong.");
}
⚠️ Avoid catching every error without examining it. Swallowing exceptions silently can hide real issues and make debugging more difficult.

Nested and rethrown errors

You can nest try...catch blocks to handle different levels of failure, or rethrow an error after performing partial handling. This is useful when you need to log information but still want the error to propagate upward.

function processData() {
  try {
    riskyStep();
  } catch (err) {
    console.warn("Minor issue:", err.message);
    throw err;  // rethrow so outer handler can respond
  }
}

try {
  processData();
} catch (err) {
  console.error("Fatal error:", err.message);
}

Rethrowing preserves the original stack trace, which helps in diagnosing where an error originated.

The finally block

The finally block executes after both try and catch blocks finish, regardless of whether an error was thrown or caught. It runs even if you use return statements inside the preceding blocks.

function example() {
  try {
    return "try result";
  } finally {
    console.log("This still runs");
  }
}

console.log(example());  // logs "This still runs" then "try result"

This guarantees that cleanup code executes predictably, even when control flow exits early.

πŸ’‘ Use finally sparingly for essential post-processing tasks. Placing too much logic in it can make code flow harder to follow.

Together, try, catch, and finally give JavaScript a robust mechanism for managing unexpected conditions while keeping programs stable and responsive.

Creating custom errors

While JavaScript provides several built-in Error types, there are times when defining your own can make debugging and handling problems clearer. Custom error classes let you describe failures more precisely, distinguishing between different kinds of issues in your program. This approach is especially helpful in larger codebases or libraries where users need to know exactly what went wrong.

Custom errors are typically created by extending the Error class. Your subclass can define a specific name and include any extra properties that describe the problem in more detail.

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

function validateUser(user) {
  if (!user.name) {
    throw new ValidationError("Missing name", "name");
  }
  if (user.age < 18) {
    throw new ValidationError("User must be 18 or older", "age");
  }
  return true;
}

try {
  validateUser({ age: 15 });
} catch (err) {
  if (err instanceof ValidationError) {
    console.error(`${err.name} in field "${err.field}": ${err.message}`);
  } else {
    throw err;
  }
}

This pattern allows you to differentiate between validation errors, network errors, and other unexpected issues, and handle each one appropriately.

πŸ’‘ Always call super(message) in the constructor of your custom error class. This ensures that the standard error message and stack trace are created correctly.

Adding context to errors

Custom errors can also include extra context such as error codes, status values, or metadata to help identify and report problems more effectively.

class HttpError extends Error {
  constructor(status, url) {
    super(`Request failed with status ${status}`);
    this.name = "HttpError";
    this.status = status;
    this.url = url;
  }
}

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

try {
  await fetchData("/missing.json");
} catch (err) {
  if (err instanceof HttpError) {
    console.error(`Failed to load ${err.url}: ${err.status}`);
  } else {
    console.error("Unknown error:", err);
  }
}

By using descriptive error names and extra fields, you make debugging easier and allow calling code to handle different failure types intelligently.

⚠️ Avoid creating too many distinct custom error types. Group related problems under a few broad categories so that handling logic remains simple and maintainable.

Clear, structured error classes communicate intent far better than generic messages. They help ensure that when something fails, it does so in a way that is easy to understand, diagnose, and recover from.

Control flow with errors

Exceptions are not only for signalling fatal problems because they can also influence the flow of execution. In certain situations, you may intentionally throw and catch errors to exit deeply nested operations or redirect logic when specific conditions arise. Used carefully, this technique can simplify complex control paths that would otherwise require multiple checks or flags.

function findItem(items, target) {
  for (const item of items) {
    if (item === target) {
      throw new Error("Found target");
    }
  }
  console.log("Target not found");
}

try {
  findItem(["a", "b", "c", "d"], "c");
} catch (err) {
  if (err.message === "Found target") {
    console.log("Search complete: item found.");
  } else {
    throw err;
  }
}

Here, throwing an error provides a quick escape from a loop when the target is found. Although this works, it is better viewed as a special-case control mechanism, not a substitute for normal conditionals or returns.

⚠️ Errors should not replace regular program logic. Overusing them for non-exceptional situations makes code harder to read and debug. Reserve them for truly unexpected or invalid states.

Stack unwinding

When an error is thrown, JavaScript immediately stops the current function and searches up the call stack for a matching catch block. This process is called stack unwinding. It continues until either a suitable handler is found or the stack is exhausted.

function level1() {
  level2();
}

function level2() {
  level3();
}

function level3() {
  throw new Error("Something failed in level3");
}

try {
  level1();
} catch (err) {
  console.error("Caught:", err.message);
}

In this example, the error originates inside level3(), but it propagates upward until it reaches the try...catch surrounding level1(). Understanding this process is key to designing reliable functions that either handle or forward errors appropriately.

Propagating errors

Not every function should handle the errors it encounters. Sometimes, it is better to let them propagate so that higher-level code (such as a central controller or user interface layer) can decide what to do. This approach avoids duplicating error-handling logic in many places.

function parseJSON(data) {
  return JSON.parse(data);  // may throw SyntaxError
}

function loadData() {
  const raw = '{ "name": "Robin" }';
  return parseJSON(raw);
}

try {
  console.log(loadData());
} catch (err) {
  console.error("Failed to load data:", err.message);
}

Here, the SyntaxError thrown by JSON.parse() travels up until it reaches the outer catch. Each layer delegates responsibility to the next, keeping functions focused on their main tasks.

πŸ’‘ Design your functions to either handle an error completely or pass it upward. Partial handling (for example, logging without rethrowing) can lead to silent failures and unpredictable behaviour.

Intentional error throwing for validation

It is sometimes useful to throw specific errors as part of input validation or contract enforcement. This makes failures explicit and consistent, helping detect invalid use of functions early.

function setAge(age) {
  if (typeof age !== "number" || age < 0) {
    throw new RangeError("Age must be a non-negative number");
  }
  console.log("Age set to", age);
}

try {
  setAge(-3);
} catch (err) {
  if (err instanceof RangeError) {
    console.warn("Invalid input:", err.message);
  } else {
    throw err;
  }
}

Using targeted exceptions like this keeps validation logic concise and allows calling code to handle bad input predictably.

Handled properly, exceptions provide a structured form of control flow that separates normal operation from failure states. They allow programs to recover gracefully, skip unnecessary steps, and report problems clearly without losing track of what went wrong.

Defensive coding patterns

Defensive programming is the practice of writing code that anticipates and resists failure. Rather than assuming everything will work as expected, defensive code checks inputs, validates assumptions, and handles unexpected conditions gracefully. This approach reduces the likelihood of runtime errors and makes your programs more predictable and maintainable.

In JavaScript, defensive coding often means validating arguments before using them, providing sensible defaults, and catching errors in places where external data or unpredictable conditions are involved. The goal is not to eliminate all errors, which is impossible in real-world systems, but to contain and control them.

Validating inputs

Many runtime errors can be prevented by validating data at the boundaries of your program, which is where user input, network responses, or file contents enter your system.

function calculateTotal(price, quantity) {
  if (typeof price !== "number" || typeof quantity !== "number") {
    throw new TypeError("Both price and quantity must be numbers");
  }
  if (price < 0 || quantity < 0) {
    throw new RangeError("Values must be non-negative");
  }
  return price * quantity;
}

try {
  console.log(calculateTotal(10, 3)); // 30
  console.log(calculateTotal("10", 3)); // throws
} catch (err) {
  console.error(err.message);
}

By checking values early, you make errors explicit and easier to diagnose, rather than letting them cause subtle logic problems later in execution.

πŸ’‘ Validate at the boundaries and ensure functions that receive data from outside your control assume nothing and verify everything.

Using default values and guards

When input may be missing or incomplete, providing safe defaults prevents code from failing unexpectedly. Guard clauses, with early exits based on simple checks, make functions easier to read and reduce nested logic.

function greet(name) {
  if (!name) {
    console.warn("No name provided, using default.");
    name = "Guest";
  }
  console.log(`Hello, ${name}!`);
}

greet("Robin");
greet();

Guard clauses can also stop invalid operations before they start, improving clarity and reducing the chance of cascading errors.

Failing fast and clearly

When a serious problem occurs, fail fast. Stop execution at the point of failure instead of letting the program continue in an uncertain state. This helps ensure that errors are visible during development and testing, not hidden or ignored.

function loadConfig(config) {
  if (!config || typeof config !== "object") {
    throw new Error("Invalid configuration object");
  }
  // continue safely knowing config is valid
}

Failing early gives a clear signal about what went wrong and where. Combined with well-designed error messages, it simplifies debugging and speeds up problem resolution.

⚠️ Avoid suppressing errors just to keep programs running. Silent failures can lead to data corruption, inconsistent state, or harder-to-trace issues later.

Graceful degradation

In production environments, failing completely is not always acceptable. Graceful degradation ensures that even if part of the system fails, the rest continues functioning in a limited but stable way.

function getUserSetting(key) {
  try {
    const value = localStorage.getItem(key);
    return value ? JSON.parse(value) : null;
  } catch {
    console.warn("Could not read setting, using default.");
    return null;
  }
}

Here, an invalid JSON string in localStorage will not crash the application. Instead, a warning is shown and a fallback value is used, allowing the program to continue safely.

Centralising error handling

Large applications benefit from having a single, consistent point for handling unexpected errors. Centralised handlers can log details, alert developers, or show user-friendly messages without duplicating logic across many parts of the program.

window.addEventListener("error", e => {
  console.error("Global error:", e.message);
});

window.addEventListener("unhandledrejection", e => {
  console.error("Unhandled promise rejection:", e.reason);
});

This pattern ensures that even unanticipated problems are captured and logged, improving observability and stability in production.

πŸ’‘ Defensive programming is about realism. Code defensively so your programs handle both the expected and the unexpected without losing control.

By combining clear validation, early failure, and robust handling, you can build software that behaves predictably under pressure and provides clear information when things go wrong. Defensive coding keeps your JavaScript reliable, maintainable, and ready for the unpredictable nature of real-world execution.