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);
}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
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?
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 }
Once it passes, try two variations and predict each before running:
- A different bad case. Make
validateQuantityalso reject non-integers (3.5) with aRangeErrorinstead of anInputError— then predict which labelclassifygives that throw. (Hint:RangeErroris not anInputError.) - A finally block. Add a
finallytosafeParsethat 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
Key Takeaways
- Use
try/catch/finallyto handle errors without crashing your program finallyalways executes, making it ideal for cleanup code- JavaScript has built-in error types: TypeError, RangeError, ReferenceError, SyntaxError, and URIError
- Use
throwto 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.
Next lesson
DOM and Events
Learn JavaScript DOM manipulation and event handling. Select elements, modify content, create dynamic pages, and respond to user actions.
24 min