Skip to main content

πŸ›‘οΈ Try/Catch Blocks & Error Handling

Code fails. Networks drop, users type nonsense into forms, files go missing. The difference between a professional application and a fragile one isn't that the professional code never hits an error β€” it's that it expects errors and handles them gracefully instead of crashing. Today you learn JavaScript's built-in safety net: try, catch, and finally.

Week 2 · Day 5 (Friday: Error Handling and Debugging) · Lecture 1

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what an Error object is and name the built-in error types
  • Wrap risky code in try/catch and read the name, message, and stack of a caught error
  • Use a finally block to run cleanup code no matter what happens
  • throw your own errors and define custom Error subclasses for domain-specific failures
  • Handle errors in asynchronous code with try/catch plus await and with .catch() on promises
  • Apply best-practice patterns: specific catches, no silent swallowing, meaningful messages

Estimated Time: 60 minutes

Practice: Build a safeJSONParse helper and a retry-with-backoff wrapper.

In This Lesson

Why Error Handling Matters

Imagine you're driving and the engine starts making a strange noise. You'd much rather have a dashboard warning light β€” something that tells you what's wrong and lets you pull over safely β€” than have the car simply stop dead in the fast lane. Error handling is that dashboard light for your code: it lets your program notice a problem, respond to it, and keep the rest of the application running.

Without it, a single unexpected value can throw an uncaught exception that halts the current call stack. In the browser that can leave a half-rendered, frozen page; on a Node.js server it can crash the entire process and take every other user's request down with it. Handling errors deliberately is what turns "it works on my machine" into "it works in production."

πŸ“– Errors vs. exceptions

An Error is an object that describes what went wrong. Throwing is the act of raising one, which interrupts normal flow. An exception is a thrown error that is travelling up the call stack looking for a catch. If nothing catches it, it becomes an uncaught exception.

The Error Object & Its Types

When something goes wrong, JavaScript creates an Error object. Every error carries three especially useful properties:

  • name β€” the category, e.g. "TypeError"
  • message β€” a human-readable description of what happened
  • stack β€” the call stack at the moment the error was created (invaluable for debugging)

The language ships with a family of specialized error types, all descending from the base Error:

graph TD A[Error] --> B[SyntaxError] A --> C[ReferenceError] A --> D[TypeError] A --> E[RangeError] A --> F[URIError] A --> G[EvalError] A --> H[Your Custom Errors]
TypeThrown when…Typical example
SyntaxErrorthe engine can't parse the codeJSON.parse("{bad}")
ReferenceErroryou use a name that doesn't existconsole.log(missing)
TypeErrora value is not the type an operation needsnull.foo
RangeErrora number is outside an allowed rangenew Array(-1)
URIErrora URI-handling function gets malformed inputdecodeURIComponent("%")
EvalErrorlegacy β€” rarely seen in modern code(historical)

You can create one yourself and inspect it:

const err = new TypeError("Expected a number");
console.log(err.name);    // "TypeError"
console.log(err.message); // "Expected a number"
console.log(err.stack);   // "TypeError: Expected a number\n    at ..."

// Every specific type is an Error:
console.log(err instanceof TypeError); // true
console.log(err instanceof Error);     // true

The try/catch Statement

The try/catch statement lets you "try" a block of code and "catch" any error it throws, instead of letting that error crash the program. The catch block receives the error object so you can respond intelligently.

try {
    // Code that might throw
    riskyOperation();
} catch (error) {
    // Runs ONLY if something in try threw
    console.error("An error occurred:", error.message);
}
// Execution continues here either way

How it flows

flowchart TD A[Enter try block] --> B{Error thrown?} B -->|No| C[Finish try block] B -->|Yes| D[Jump straight to catch] C --> E[Skip catch] D --> F[Run catch block] E --> G[Continue after try/catch] F --> G

The key insight: the moment a line inside try throws, JavaScript abandons the rest of the try block and jumps to catch. Lines after the failing one never run.

Example 1: catching a ReferenceError

function greetUser(name) {
    try {
        console.log(message); // 'message' was never declared β†’ ReferenceError
        return `Hello, ${name}!`;
    } catch (error) {
        console.error("Caught:", error.name);      // "ReferenceError"
        console.error("Details:", error.message);  // "message is not defined"
        return `Hello, ${name}! (recovered)`;      // graceful fallback
    }
}

console.log(greetUser("Alice"));
// Caught: ReferenceError
// Details: message is not defined
// Hello, Alice! (recovered)

Example 2: catching a TypeError with a safe default

function calculateTotal(items) {
    try {
        // Fails if items isn't an array of { price } objects
        return items.reduce((sum, item) => sum + item.price, 0);
    } catch (error) {
        console.error("Could not total items:", error.message);
        return 0; // a sensible fallback the caller can rely on
    }
}

console.log(calculateTotal([{ price: 10 }, { price: 20 }])); // 30
console.log(calculateTotal(null));          // 0  (no crash)
console.log(calculateTotal("not an array")); // 0  (no crash)

πŸ’‘ Optional catch binding

If you don't need the error object, ES2019 lets you omit the parameter entirely: try { … } catch { … }. Use it sparingly β€” usually you do want to at least log the error.

The finally Block

Sometimes code must run whether or not an error occurred β€” closing a file, hiding a loading spinner, releasing a database connection. That's what finally is for. It runs after try (and after catch, if one fired), on every path out of the block, even a return.

function processFile(filename) {
    let file;
    try {
        file = openFile(filename);
        const data = readFile(file);   // might throw
        return process(data);          // success path
    } catch (error) {
        console.error("Error processing file:", error.message);
        return "Failed";               // failure path
    } finally {
        // Runs on BOTH paths above β€” perfect for cleanup
        if (file) {
            closeFile(file);
            console.log("File closed");
        }
    }
}

⚠️ finally always wins

If finally contains its own return, it overrides any return or throw from the try/catch. That's almost always a bug β€” keep finally for cleanup, not for producing the function's result.

Throwing & Custom Errors

You don't have to wait for JavaScript to throw β€” you can raise your own error the instant you detect an invalid state with the throw keyword. Always throw an Error object (not a bare string) so callers get a name, message, and stack.

function divide(a, b) {
    if (b === 0) {
        // ❌ throw "cannot divide by zero";  // loses the stack trace
        throw new Error(`Cannot divide ${a} by zero`); // βœ…
    }
    return a / b;
}

Custom error classes

For real applications, define your own Error subclasses. They read like documentation and let callers branch on the kind of failure with instanceof.

class ValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = "ValidationError"; // so error.name is meaningful
    }
}

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

function validateUser(user) {
    if (!user)              throw new ValidationError("User object is required");
    if (!user.email)        throw new ValidationError("Email is required");
    if (!user.email.includes("@")) throw new ValidationError("Invalid email format");
    return true;
}

try {
    validateUser({ email: "invalid-email" });
} catch (error) {
    if (error instanceof ValidationError) {
        console.log("Please fix your input:", error.message);
    } else {
        throw error; // not ours β€” let it bubble up
    }
}
// Please fix your input: Invalid email format

βœ… Re-throwing what you can't handle

A catch block should only swallow errors it actually knows how to recover from. For anything else, throw error; again so a higher layer (or your global handler) can deal with it. Silently absorbing unknown errors hides real bugs.

Errors in Asynchronous Code

Here's a trap that catches every beginner: a plain try/catch cannot catch an error that happens later, inside a callback or an un-awaited promise. The try block finishes long before the async work fails. There are two correct ways to handle async errors.

1. try/catch with async/await

Because await pauses the function until the promise settles, a rejected promise throws right at the await line β€” so an ordinary try/catch around it works perfectly.

async function fetchUserData(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);

        // fetch only rejects on network failure β€” a 404/500 is still "ok: false"
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }

        const user = await response.json();
        if (!user.email) throw new ValidationError("User data missing email");
        return user;
    } catch (error) {
        if (error instanceof ValidationError) {
            console.error("Bad data:", error.message);
            return null;
        }
        if (error instanceof TypeError) {
            console.error("Network problem:", error.message);
            return null;
        }
        throw error; // unknown β€” re-throw
    }
}

⚠️ fetch doesn't reject on HTTP errors

A common surprise: fetch() resolves successfully even for 404 or 500 responses. It only rejects when the request can't be made at all (offline, DNS failure, CORS). Always check response.ok yourself and throw if it's false.

2. .catch() on a promise chain

If you're using .then() instead of await, attach a .catch() at the end. One .catch() handles a rejection from any step above it in the chain.

function fetchData(url) {
    return fetch(url)
        .then(response => {
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            return response.json();
        })
        .then(data => {
            if (!data.isValid) throw new ValidationError("Invalid data received");
            return data;
        })
        .catch(error => {
            // Catches a throw from EITHER .then above
            console.error("Request failed:", error.message);
            return { error: error.message }; // recover with a fallback value
        });
}

Global Error Handlers

Even careful code misses something. A top-level safety net lets you log unexpected failures (to a monitoring service like Sentry) and show the user a friendly message instead of a broken page.

// Browser: catch synchronous errors that escaped every try/catch
window.addEventListener("error", (event) => {
    console.error("Uncaught error:", event.message, "at", event.filename);
    reportToService(event.error);
});

// Browser: catch promises that rejected with no .catch()
window.addEventListener("unhandledrejection", (event) => {
    console.error("Unhandled rejection:", event.reason);
    reportToService(event.reason);
    event.preventDefault(); // stop the default console spam
});

On the server, Node.js exposes the equivalent hooks. Note that after an uncaughtException the process is in an unknown state β€” log it and exit, don't try to keep running.

// Node.js
process.on("unhandledRejection", (reason) => {
    console.error("Unhandled rejection:", reason);
});
process.on("uncaughtException", (error) => {
    console.error("Uncaught exception:", error);
    process.exit(1); // restart cleanly via a process manager
});

πŸ’‘ Global handlers are a net, not a plan

Treat these as last-resort logging, not as your primary strategy. Handle errors close to where they happen β€” the global handler is for the ones you genuinely didn't anticipate.

Practice & Quiz

πŸ‹οΈ Exercise 1: A safe JSON parser

Goal: JSON.parse throws a SyntaxError on bad input. Write safeJSONParse(text, fallback) that returns the parsed value on success, or fallback (default null) on failure β€” without ever crashing.

function safeJSONParse(text, fallback = null) {
    // TODO: try to parse; on failure log a warning and return fallback
}

console.log(safeJSONParse('{"name":"John"}')); // { name: "John" }
console.log(safeJSONParse("not json"));         // null
console.log(safeJSONParse("nope", {}));         // {}
πŸ’‘ Hint

Wrap JSON.parse(text) in a try. In the catch, log the error's message and return fallback.

βœ… Solution
function safeJSONParse(text, fallback = null) {
    try {
        return JSON.parse(text);
    } catch (error) {
        console.warn("Invalid JSON, using fallback:", error.message);
        return fallback;
    }
}

πŸ‹οΈ Exercise 2: Retry with backoff

Goal: Write retry(operation, maxAttempts) that runs an async operation, retries on failure up to maxAttempts times with a short delay between tries, and throws the last error if every attempt fails.

πŸ’‘ Hint

Loop from 1 to maxAttempts. Inside a try, return await operation(). In the catch, remember the error; if it wasn't the last attempt, await a small delay and continue. After the loop, throw the saved error.

βœ… Solution
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

async function retry(operation, maxAttempts = 3) {
    let lastError;
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            return await operation();       // success β€” done
        } catch (error) {
            lastError = error;
            console.warn(`Attempt ${attempt} failed: ${error.message}`);
            if (attempt < maxAttempts) {
                await delay(200 * attempt);  // exponential-ish backoff
            }
        }
    }
    throw lastError; // all attempts exhausted
}

🎯 Quick Quiz

Question 1: When does a finally block run?

Question 2: Why should you throw new Error("...") instead of throw "..."?

Question 3: A plain synchronous try/catch wrapped around fetch(url).then(...) will…

Best Practices & Pitfalls

βœ… Do

  • Catch the specific errors you can recover from; re-throw the rest
  • Always log or handle a caught error β€” never leave the catch block empty
  • Throw Error objects (or subclasses), never bare strings
  • Write descriptive messages: `Cannot divide ${a} by zero`, not "Error"
  • Use finally for cleanup that must run on every path
  • Check response.ok after every fetch

❌ Don't

  • Swallow errors silently β€” catch (e) {} hides real bugs
  • Wrap a giant block in one try so you can't tell which line failed
  • Use exceptions for ordinary control flow (return null for "not found" instead)
  • Expect a synchronous try/catch to catch a callback's error
  • return from a finally block β€” it overrides your real result

⚠️ The empty catch trap

// 🚫 Looks harmless, hides everything
try { riskyOperation(); } catch (e) {}

// βœ… At minimum, log it
try {
    riskyOperation();
} catch (error) {
    console.error("riskyOperation failed:", error);
    throw error; // or handle it β€” but never ignore it
}

Summary

πŸŽ‰ Key Takeaways

  • try/catch lets code recover from a thrown error instead of crashing
  • Every error has a name, message, and stack; the built-in types (TypeError, ReferenceError…) tell you the category
  • finally runs on every exit path β€” use it for cleanup
  • throw new Error() raises your own failures; custom Error subclasses let callers branch with instanceof
  • Async errors need await inside try/catch or a .catch() on the chain β€” and remember fetch doesn't reject on HTTP errors
  • Never swallow errors silently; catch specifically and re-throw the rest

πŸ“š Additional Resources

πŸš€ What's Next?

You can now handle errors gracefully β€” but how do you find the bugs that cause them in the first place? Next up: Debugging with Chrome DevTools, where you'll set breakpoints, inspect the call stack, and watch your code run one line at a time.

πŸŽ‰ Well handled!

Your programs no longer fall over at the first surprise. Robust software isn't error-free β€” it's error-ready.