Skip to lesson

learningjavascript.org / intermediate / 09-error-handling · lesson 11 of 25

TL;DR

Learn JavaScript error handling with try/catch/finally, custom error classes, and defensive programming techniques for robust code.

Key concepts

  • JavaScript error handling
  • try catch JavaScript
  • JavaScript custom errors
  • throw error JS

Error Handling

Errors are an inevitable part of programming. Users enter unexpected input, networks fail, and code has bugs. The difference between fragile and robust software is how it handles errors. JavaScript provides powerful mechanisms for catching, creating, and managing errors so your programs can fail gracefully instead of crashing unexpectedly.

What You'll Learn

  • How to use try/catch/finally to handle errors
  • The built-in Error types in JavaScript
  • How to create and throw custom errors
  • Error handling patterns and best practices
  • Defensive programming techniques

Why Error Handling Matters

Without error handling, a single problem can crash your entire program. With it, the failure is contained and the rest of your code still runs. Both examples below show that containment in action:

// Bad input that would otherwise crash the program - JSON.parse throws here
try {
  const data = JSON.parse("this is not valid JSON");
} catch (e) {
  console.log("Caught error:", e.message);
}

// The catch handled it, so execution reaches this line instead of stopping
console.log("Program keeps running!");

// Another common error: accessing properties of undefined
let user = undefined;
try {
  console.log(user.name);
} catch (e) {
  console.log("Caught:", e.message);
}

console.log("Still running after the error!");

Try, Catch, and Finally

The try/catch/finally statement is JavaScript's primary error handling mechanism. Code in the try block is monitored for errors. If one occurs, execution jumps to the catch block. The finally block always runs, whether or not an error occurred:

function divideNumbers(a, b) {
  try {
    console.log("Attempting division: " + a + " / " + b);

    if (typeof a !== "number" || typeof b !== "number") {
      throw new Error("Both arguments must be numbers");
    }
    if (b === 0) {
      throw new Error("Cannot divide by zero");
    }

    const result = a / b;
    console.log("Result: " + result);
    return result;

  } catch (error) {
    console.log("Error caught: " + error.message);
    return null;

  } finally {
    console.log("Division operation completed (always runs)");
    console.log("---");
  }
}

divideNumbers(10, 2);
divideNumbers(10, 0);
divideNumbers("ten", 2);

The finally block is useful for cleanup operations like closing connections, hiding loading spinners, or releasing resources. It runs regardless of whether the try block succeeded or the catch block handled an error.

Built-in Error Types

JavaScript has several built-in error types, each designed for specific situations:

// TypeError - wrong type for an operation
try {
  null.toString();
} catch (e) {
  console.log("TypeError:", e.message);
}

// RangeError - value outside allowed range
try {
  const arr = new Array(-1);
} catch (e) {
  console.log("RangeError:", e.message);
}

// ReferenceError - accessing undeclared variable
try {
  console.log(undeclaredVariable);
} catch (e) {
  console.log("ReferenceError:", e.message);
}

// SyntaxError (caught at parse time, but can occur with eval/JSON.parse)
try {
  JSON.parse("{invalid json}");
} catch (e) {
  console.log("SyntaxError:", e.message);
}

// URIError - malformed URI
try {
  decodeURIComponent("%");
} catch (e) {
  console.log("URIError:", e.message);
}

// All errors have these properties
try {
  null.method();
} catch (e) {
  console.log("\nError properties:");
  console.log("  name:", e.name);
  console.log("  message:", e.message);
}

The subtype a bad operation throws is not always the obvious one. You can check it yourself by catching the error and reading its constructor name:

function subtypeOf(fn) {
  try {
    fn();
    return "no error";
  } catch (e) {
    return e.constructor.name;
  }
}

console.log("null.toString()       ->", subtypeOf(() => null.toString()));
console.log("(5).toFixed(200)      ->", subtypeOf(() => (5).toFixed(200)));
console.log("JSON.parse('{ bad }') ->", subtypeOf(() => JSON.parse("{ bad }")));
// null.toString()       -> TypeError
// (5).toFixed(200)      -> RangeError
// JSON.parse('{ bad }') -> SyntaxError

Predict

Which built-in Error subtype does (5).toFixed(200) throw? toFixed accepts a digit count between 0 and 100.

try {
const s = (5).toFixed(200); // 200 digits requested
console.log(s);
} catch (e) {
console.log(e.constructor.name);
}
Continue learning

Throwing Custom Errors

You can throw errors explicitly using the throw keyword. This lets you define your own error conditions:

function validateAge(age) {
  if (typeof age !== "number") {
    throw new TypeError("Age must be a number, got " + typeof age);
  }
  if (!Number.isInteger(age)) {
    throw new TypeError("Age must be a whole number");
  }
  if (age < 0 || age > 150) {
    throw new RangeError("Age must be between 0 and 150, got " + age);
  }
  return true;
}

function processAge(input) {
  try {
    validateAge(input);
    console.log("Valid age: " + input);
  } catch (error) {
    if (error instanceof TypeError) {
      console.log("Type problem: " + error.message);
    } else if (error instanceof RangeError) {
      console.log("Range problem: " + error.message);
    } else {
      console.log("Unexpected error: " + error.message);
    }
  }
}

processAge(25);
processAge("twenty");
processAge(3.5);
processAge(-10);
processAge(200);

Custom Error Classes

For larger applications, creating custom error classes helps you identify and handle specific error types:

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

class NotFoundError extends Error {
  constructor(resource, id) {
    super(resource + " with id " + id + " not found");
    this.name = "NotFoundError";
    this.resource = resource;
    this.id = id;
  }
}

// Using custom errors
function createUser(data) {
  if (!data.name || data.name.length < 2) {
    throw new ValidationError("name", "Name must be at least 2 characters");
  }
  if (!data.email || !data.email.includes("@")) {
    throw new ValidationError("email", "Invalid email address");
  }
  if (data.age && (data.age < 13 || data.age > 120)) {
    throw new ValidationError("age", "Age must be between 13 and 120");
  }
  return { id: Date.now(), ...data, createdAt: new Date().toISOString() };
}

// Handle different error types differently
function handleRegistration(data) {
  try {
    const user = createUser(data);
    console.log("User created:", user.name + " (" + user.email + ")");
  } catch (error) {
    if (error instanceof ValidationError) {
      console.log("Validation failed on '" + error.field + "': " + error.message);
    } else {
      console.log("Unexpected error:", error.message);
    }
  }
}

handleRegistration({ name: "Alice", email: "alice@example.com", age: 28 });
handleRegistration({ name: "A", email: "alice@example.com" });
handleRegistration({ name: "Bob", email: "invalid" });
handleRegistration({ name: "Charlie", email: "c@test.com", age: 5 });

Debug

report() should give the friendly 'Please check your input' message ONLY for input errors, and treat everything else as unexpected. Instead, a NetworkError also gets the friendly message. Predict what it actually prints, then fix it so the NetworkError is reported as unexpected.

const assert = require('assert');

class InputError extends Error {
constructor(message) { super(message); this.name = "InputError"; }
}
class NetworkError extends Error {
constructor(message) { super(message); this.name = "NetworkError"; }
}

function report(error) {
if (error instanceof Error) {
  return "Please check your input: " + error.message;
}
return "Unexpected error: " + error.message;
}

const input = report(new InputError("Name is required"));
const network = report(new NetworkError("Server unreachable"));

assert.strictEqual(input, "Please check your input: Name is required", 'got ' + input);
assert.strictEqual(network, "Unexpected error: Server unreachable", 'got ' + network);

console.log(input);
console.log(network);

Expected output: Please check your input: Name is required Unexpected error: Server unreachable

Continue learning

Error Handling Patterns

The Guard Clause Pattern

Instead of deeply nested if/else blocks, check for errors early and return immediately:

// Without guard clauses (deeply nested)
function processOrderNested(order) {
  if (order) {
    if (order.items) {
      if (order.items.length > 0) {
        const total = order.items.reduce(function(sum, item) {
          return sum + item.price;
        }, 0);
        return "Order total: $" + total.toFixed(2);
      }
    }
  }
  return "Invalid order";
}

// With guard clauses (flat and readable)
function processOrder(order) {
  if (!order) return "Error: No order provided";
  if (!order.items) return "Error: Order has no items array";
  if (order.items.length === 0) return "Error: Order is empty";

  const total = order.items.reduce(function(sum, item) {
    return sum + item.price;
  }, 0);
  return "Order total: $" + total.toFixed(2);
}

console.log(processOrder(null));
console.log(processOrder({}));
console.log(processOrder({ items: [] }));
console.log(processOrder({ items: [{ price: 9.99 }, { price: 14.50 }] }));

Safe Property Access

Use optional chaining and nullish coalescing to safely navigate objects that might have missing data:

const users = [
  { name: "Alice", address: { city: "NYC", zip: "10001" } },
  { name: "Bob", address: null },
  { name: "Charlie" }
];

users.forEach(function(user) {
  // Optional chaining (?.) returns undefined instead of throwing
  const city = user?.address?.city;
  const zip = user?.address?.zip;

  // Nullish coalescing (??) provides defaults for null/undefined
  console.log(user.name + " lives in " + (city ?? "Unknown city") + " " + (zip ?? "No zip"));
});

// Combining both patterns
function getUserDisplayName(user) {
  return user?.profile?.displayName ?? user?.name ?? "Anonymous";
}

console.log(getUserDisplayName({ name: "Alice", profile: { displayName: "AliceW" } }));
console.log(getUserDisplayName({ name: "Bob" }));
console.log(getUserDisplayName({}));
console.log(getUserDisplayName(null));

Recall

Without scrolling up: an async function awaits a call that rejects, and you want to handle the failure in the same function. Which structure catches a rejected await, and how does that relate to the guard-clause pattern you just met in this lesson?

Continue learning

Try It Yourself

Reading about errors is not the same as handling them. This is a build task: a small program that reports its own pass/fail. You are given a custom InputError class and three empty functions to finish — one that validates and throws, one that catches and classifies, and one that wraps a risky call in a safe fallback. Run it as-is and it fails immediately, telling you which function is still missing. Implement each one until every check passes and it prints All checks passed.

The three functions reuse exactly what this lesson taught: throwing a custom error behind a guard clause (Throwing Custom Errors and the Guard Clause Pattern), catching and branching on the error type with instanceof (Custom Error Classes), and wrapping a call that might throw in try/catch so a failure returns a fallback instead of crashing (Try, Catch, and Finally). The starter already has the error class, the stubs, and the checks — you write only the logic inside each function.

Build

Finish the build. Three functions are stubbed out and the checks below them fail until each one behaves correctly. Run it as-is to see which check fails first, decide what that function is missing, then implement the three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.

const assert = require('assert');

// A custom error class, like the ones in Custom Error Classes above.
class InputError extends Error {
constructor(message) {
	super(message);
	this.name = "InputError";
}
}

// TODO 1: validate the quantity and THROW an InputError when it is invalid.
//   validateQuantity(3)  -> 3   (valid: return it)
//   validateQuantity(0)  -> throws InputError("Quantity must be at least 1")
//   validateQuantity(-5) -> throws InputError("Quantity must be at least 1")
function validateQuantity(qty) {
// your code here
}

// TODO 2: run fn, and CLASSIFY what happens into one of three labels.
//   classify(() => 42)                            -> "ok"
//   classify(() => { throw new InputError("x") }) -> "input error"
//   classify(() => { throw new Error("x") })      -> "other error"
function classify(fn) {
// your code here
}

// TODO 3: a SAFE wrapper. Return the parsed object, or the fallback if text is not valid JSON.
//   safeParse('{"n":1}', {})        -> { n: 1 }
//   safeParse('not json', { n: 0 }) -> { n: 0 }
function safeParse(text, fallback) {
// your code here
}

// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(
validateQuantity(3),
3,
"validateQuantity(3) should return the quantity when it is valid",
);
assert.throws(
function () {
	validateQuantity(0);
},
{ name: "InputError", message: /at least 1/ },
"validateQuantity(0) should throw an InputError, not return",
);
assert.strictEqual(
classify(function () {
	return 42;
}),
"ok",
"classify should return 'ok' when fn does not throw",
);
assert.strictEqual(
classify(function () {
	throw new InputError("bad");
}),
"input error",
"classify should return 'input error' when fn throws an InputError",
);
assert.strictEqual(
classify(function () {
	throw new Error("boom");
}),
"other error",
"classify should return 'other error' for any non-InputError throw",
);
assert.deepStrictEqual(
safeParse("not json", { n: 0 }),
{ n: 0 },
"safeParse should return the fallback when the text is not valid JSON",
);

console.log("All checks passed.");
console.log("Valid quantity:", validateQuantity(3));
console.log("Classified:", classify(function () {
throw new InputError("bad");
}));
console.log("Safe parse fallback:", safeParse("not json", { n: 0 }));

Expected output: All checks passed. Valid quantity: 3 Classified: input error Safe parse fallback: { n: 0 }

Continue learning

Once it passes, try two variations and predict each before running:

  1. A different bad case. Make validateQuantity also reject non-integers (3.5) with a RangeError instead of an InputError — then predict which label classify gives that throw. (Hint: RangeError is not an InputError.)
  2. A finally block. Add a finally to safeParse that logs "parse attempted" every time, and confirm it runs on both the success and the fallback path — the always-runs guarantee from Try, Catch, and Finally.

Capstone milestone

Milestone — the Task model's validation. The capstone's Task model (Step 1) rejects bad input by throwing, exactly like validateQuantity in the build above. Confirm you can validate-and-throw with a custom error and catch it by type.

Hint: You don't need the full capstone yet — this confirms the validate-by-throwing skill the Task model is built on. In the capstone, new Task('') throws just like validateQuantity does here.

  • Wrote a function that validates input and throws when it is invalid (rather than returning a flag)
  • Defined a custom error class extending Error with an extra property (a field name or an errors list)
  • Caught the error and branched on its type with instanceof to give a specific message
  • Used a guard clause to reject a bad case early instead of nesting the happy path
Continue learning

Key Takeaways

  • Use try/catch/finally to handle errors without crashing your program
  • finally always executes, making it ideal for cleanup code
  • JavaScript has built-in error types: TypeError, RangeError, ReferenceError, SyntaxError, and URIError
  • Use throw to create your own error conditions and custom error classes for complex applications
  • Guard clauses keep code flat and readable by handling error cases early
  • Optional chaining (?.) and nullish coalescing (??) prevent errors from missing data
  • Always handle errors at the appropriate level and provide useful error messages

Next Steps

Now that you know how to handle errors properly, the next lesson covers DOM manipulation and events, where you will learn to interact with web pages and respond to user actions.

Pro Tip: Never silently swallow errors with an empty catch block. At minimum, log the error so you know something went wrong. Silent failures are the hardest bugs to track down.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.