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.
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.
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:
Errorβ a generic error for general-purpose use.ReferenceErrorβ accessing an undeclared variable.TypeErrorβ performing an invalid operation on a value, such as calling something that is not a function.RangeErrorβ providing a number outside an allowable range.SyntaxErrorβ invalid JavaScript syntax, usually thrown during parsing.URIErrorβ invalid use of URI-handling functions such asdecodeURIComponent().EvalErrorβ historically used foreval()errors, now rarely encountered.
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.
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.
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.");
}
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.
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.
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.
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.
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.
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.
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.
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.
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.