Error Handling

Reviewed & published by Brayan K

Errors are not signs of failure — they are signals. Learn everything from basic try/catch to advanced real-world error architectures used in production apps.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:

Why Error Handling Matters

When errors are ignored:

JavaScript gives you extremely powerful tools to handle, detect, filter, categorize, and recover from errors — synchronously AND asynchronously.

1. What Is an Error in JavaScript?

An error occurs when JavaScript cannot complete an operation. Examples:

By default, errors stop execution. Your job is to catch, interpret, and recover from them.

2. Try...Catch — The Foundation

try {
    // Code that might fail
    riskyOperation();
} catch (error) {
    console.error("Error occurred:", error.message);
} finally {
    console.log("Cleanup runs no matter what.");
}

3. Throwing Your Own Errors

JavaScript allows you to create and throw your own errors:

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

try {
    divide(10, 0);
} catch (error) {
    console.log("Caught:", error.message);
}

Why throw custom errors?

4. Built-In Error Types

Incorrect data type. Example:

null.toUpperCase();
console.log(x); // x is not declared
let a = ; // invalid

Value outside allowed range:

new Array(-2);

Triggered by malformed URI sequences

5. Using Error Names to Handle Smartly

try {
    risky();
} catch (error) {
    if (error instanceof TypeError) {
        console.log("Type problem!");
    } else if (error instanceof ReferenceError) {
        console.log("You referenced something wrong!");
    } else {
        console.log("Unknown error:", error.message);
    }
}

This lets you create fine-grained error logic.

Worked Example — Which Lines Actually Run

Everything above described the machinery. This runs it. The single most useful thing to understand about try/catch is which lines execute and in what order, so every line in this program is numbered. Run it, then read the output next to the code and trace the jumps.

Three things to watch for: nothing after a throw in the try block ever runs; finally runs on every path, error or not; and an error thrown inside a function is caught by whoever called it.

// WORKED EXAMPLE - watch exactly which lines run, and in what order.
// Every console.log below is numbered so you can follow the path the engine
// takes when something goes wrong.

console.log("=== A: a try block that does NOT throw ===");
try {
    console.log("1. inside try");
    console.log("2. still inside try");
} catch (error) {
    console.log("3. this line never runs - nothing threw");
} finally {
    console.log("4. finally always runs");
}

console.log("\n=== B: a try block that DOES throw ===");
try {
    console.log("1. inside try");
    throw new Error("the network is down");   // execution jumps to catch RIGHT HERE
    console.log("2. UNREACHABLE - anything after a throw is skipped");
} catch (error) {
    // The catch parameter is an Error object, not a string.
    console.log("3. caught:", error.message);   // the text you passed to new Error
    console.log("4. its name:", error.name);    // "Error" for a plain Error
} finally {
    console.log("5. finally runs after the catch, too");
}

console.log("\n=== C: errors travel up to whoever called you ===");
// withdraw does not handle its own errors. It throws, and the caller decides.
function withdraw(balance, amount) {
    if (typeof amount !== "number") {
        throw new TypeError("amount must be a number");
    }
    if (amount > balance) {
        throw new RangeError("insufficient funds");
    }
    return balance - amount;
}

// One catch block, three different outcomes, chosen by the TYPE of the error.
function tryWithdraw(balance, amount) {
    try {
        const left = withdraw(balance, amount);
        console.log("ok, £" + left + " left");
    } catch (error) {
        if (error instanceof TypeError) {
            console.log("bad input: " + error.message);
        } else if (error instanceof RangeError) {
            console.log("declined: " + error.message);
        } else {
            throw error;      // not ours to handle - pass it upwards untouched
        }
    } finally {
        console.log("   (transaction closed)");   // runs on every path above
    }
}

tryWithdraw(100, 30);
tryWithdraw(100, "thirty");
tryWithdraw(100, 300);

// ✅ Expected output:
// === A: a try block that does NOT throw ===
// 1. inside try
// 2. still inside try
// 4. finally always runs
//
// === B: a try block that DOES throw ===
// 1. inside try
// 3. caught: the network is down
// 4. its name: Error
// 5. finally runs after the catch, too
//
// === C: errors travel up to whoever called you ===
// ok, £70 left
//    (transaction closed)
// bad input: amount must be a number
//    (transaction closed)
// declined: insufficient funds
//    (transaction closed)
//
// Notice what is MISSING from section B: line 2 never printed. That single
// gap is why you put the throw last, or accept that the rest is dead code.

🎯 Your Turn — A Seat Booking Check

Your turn to write the four keywords this lesson is built on. The logic is already there; the blanks are throw, the error type to use, and the two block keywords that go with try. Pick the error type on meaning: one problem is a wrong kind of value, the other is a number that is out of range.

// 🎯 YOUR TURN - fill in the four blanks marked ___
// A tiny seat-booking check.

function bookSeat(seatsLeft, wanted) {
    if (typeof wanted !== "number") {
        ___ new TypeError("wanted must be a number");   // 👉 replace ___ with throw
    }
    if (wanted > seatsLeft) {
        throw new ___("not enough seats");   // 👉 a number out of range: RangeError
    }
    return seatsLeft - wanted;
}

function attempt(seatsLeft, wanted) {
    try {
        const left = bookSeat(seatsLeft, wanted);
        console.log("booked, " + left + " seats left");
    } ___ (error) {                          // 👉 the block that receives the error
        console.log(error.name + ": " + error.message);
    } ___ {                                  // 👉 the block that runs either way
        console.log("--- request handled ---");
    }
}

attempt(10, 4);
attempt(10, "four");
attempt(10, 40);

// ✅ Expected output once the blanks are filled:
// booked, 6 seats left
// --- request handled ---
// TypeError: wanted must be a number
// --- request handled ---
// RangeError: not enough seats
// --- request handled ---
//
// If "--- request handled ---" appears only once, the last blank is wrong:
// that line has to be in the block that runs on every path, not inside try.

6. Error Handling in Asynchronous Code

async function getUser() {
    try {
        const res = await fetch(url);
        if (!res.ok) throw new Error("HTTP Error " + res.status);

        return await res.json();
    } catch (err) {
        console.error("Fetch failed:", err.message);
        return { error: true, msg: err.message };
    }
}

Why async error handling matters:

7. Re-Throwing Errors

Sometimes you want to catch an error, log it, then pass it upward:

async function loadData() {
    try {
        return await getData();
    } catch (err) {
        console.error("Handled locally:", err.message);
        throw err; // re-throw
    }
}

8. Creating Custom Error Classes

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

function validateAge(age) {
    if (age < 0) throw new ValidationError("Age cannot be negative");
}

Benefits of custom errors:

9. Nested Try...Catch Blocks

try {
    try {
        throw new Error("Inner failure");
    } catch (inner) {
        console.log("Inner caught:", inner.message);
        throw new Error("Outer failure");
    }
} catch (outer) {
    console.log("Outer caught:", outer.message);
}

Nested errors often appear when:

11. Real-World Example — Payment Processing

function chargeCard(card, amount) {
    if (!card.number) {
        throw new ValidationError("Card number missing");
    }
    if (amount <= 0) {
        throw new ValidationError("Amount must be greater than 0");
    }

    // Simulate charge...
    return { success: true };
}

// With layered handling:
try {
    chargeCard(card, 10);
} catch (err) {
    if (err instanceof ValidationError) {
        console.log("User made a mistake:", err.message);
    } else {
        console.log("Internal failure:", err.message);
    }
}

This mimics real e-commerce systems.

12. Real-World Example — Robust API Fetching

Now your UI doesn't crash when the API fails — it gracefully recovers.

13. Real-World Example — Form Validation

function validateForm(user) {
    if (!user.email.includes("@")) {
        throw new ValidationError("Invalid email format");
    }
    if (user.password.length < 8) {
        throw new ValidationError("Password too short");
    }
    return true;
}

// Frontend:
try {
    validateForm(user);
    console.log("Form valid");
} catch (err) {
    showErrorToUser(err.message);
}

14. Advanced Async Error Handling

Asynchronous code is where 90% of real production errors happen.

You MUST master async error strategies to build real-world websites, apps, and SaaS systems.

15. Fetch Errors Aren't Always "Errors"

fetch() only throws errors for:

⚠️ It does NOT throw errors for bad HTTP codes (404, 500, 403, 429)

if (!res.ok) {
    throw new Error("HTTP " + res.status);
}

Without this check, your code will behave like everything is fine when it absolutely isn't.

16. Retry Logic with Exponential Backoff

Real-world apps must recover from errors gracefully:

async function retryFetch(url, retries = 3) {
    for (let i = 0; i < retries; i++) {
        try {
            const res = await fetch(url);
            if (res.ok) return res.json();

        } catch (err) {
            if (i === retries - 1) throw err;
        }

        await new Promise(r => setTimeout(r, 200 * (i + 1)));
    }
}

Retries at: 200ms → 400ms → 600ms. This is used by Google, AWS, Netflix, Stripe, PayPal.

17. Defensive Programming

Defensive programming = writing code that assumes things will go wrong.

function safeJSON(response) {
    try {
        return response.json();
    } catch {
        return { error: "Invalid JSON" };
    }
}
function safeMultiply(a, b) {
    if (typeof a !== "number" || typeof b !== "number") {
        throw new TypeError("Arguments must be numbers");
    }
    return a * b;
}
const city = user?.address?.location?.city ?? "Unknown";

18. Global Error Handling

Most frameworks implement a global error handler:

Browser Global Error Handler:

window.onerror = function(message, source, line, column, error) {
    console.log("Global error:", message);
};
window.onunhandledrejection = function(event) {
    console.log("Unhandled rejection:", event.reason);
};

This prevents silent failures.

19. Production-Grade Error Handler

A complete production-grade Fetch handler:

🎯 Mini-Challenge — A Config Parser That Fails Helpfully

"Invalid config" is a useless error message. "ConfigError on line 2: missing =" gets the problem fixed in ten seconds. The difference is a custom error class carrying one extra field, and that is what you are going to build.

No starter logic below — only the brief and the expected output. Everything you need has appeared earlier in this lesson: class ... extends Error, throw, instanceof and finally.

// 🎯 MINI-CHALLENGE: a config parser that fails helpfully
//
// 1. Create  class ConfigError extends Error  taking (message, line).
//    In the constructor: call super(message), set this.name = "ConfigError"
//    and store this.line = line.
//
// 2. Write parseConfig(text):
//      - split text on "\n" and walk the lines with their index
//      - trim each line; skip completely blank ones
//      - a line with no "=" is invalid: throw a ConfigError("missing =", N)
//        where N is the 1-BASED line number (so index + 1)
//      - otherwise split at the FIRST "=" (line.indexOf("=") helps) and put
//        the key and value into an object
//      - return that object
//
// 3. Loop over the two inputs below. Inside a try/catch/finally:
//      - on success print  Object.keys(config).join(", ")
//      - on a ConfigError print  "ConfigError on line " + err.line + ": " + err.message
//      - re-throw anything that is NOT a ConfigError
//      - in finally, print "done"

const good = "host=localhost\nport=8080\n\ndebug=true";
const bad = "host=localhost\nport 8080";

// your code here

// ✅ Expected output:
// host, port, debug
// done
// ConfigError on line 2: missing =
// done
//
// Watch the line number: the blank third line of "good" must be skipped
// without being counted as an error, and the bad file reports line 2, not
// line 1 and not line 3.

20. Common JavaScript Error Scenarios

You will encounter these daily in real development.

🎯 Practice Projects for Mastery

Project 1 — "Safe Calculator"

Project 2 — "Robust Fetch Wrapper"

Build a function: safeFetch(url, retries = 3)

Project 3 — "Form Validator"

Project 4 — "Global Error Handler Demo"

📋 Quick Reference — Error Handling

MethodPurpose
try...catchCatch errors in a block of code
throwManually trigger an error
finallyRun code regardless of outcome
Error ObjectHas .message and .stack
Custom Errorclass MyError extends Error

Lesson Complete — Error Handling!

You've mastered the art of writing robust code that handles failure gracefully. This separates amateur code from professional software.

Practice quiz

In a try/catch/finally block, when does the finally block run?

  • Only when an error is thrown
  • Only when no error is thrown
  • Always, regardless of the outcome
  • Never, it is optional

Answer: Always, regardless of the outcome. finally always runs, making it ideal for cleanup like closing connections or removing loaders.

Which keyword do you use to manually trigger an error?

  • throw
  • raise
  • error
  • catch

Answer: throw. throw manually triggers an error, for example throw new Error('Cannot divide by zero').

Which error type is thrown by null.toUpperCase()?

  • ReferenceError
  • SyntaxError
  • RangeError
  • TypeError

Answer: TypeError. Calling a method on null is an incorrect data type operation, which throws a TypeError.

Which error type occurs when you use a variable that was never declared?

  • TypeError
  • ReferenceError
  • RangeError
  • URIError

Answer: ReferenceError. Referencing an undeclared variable throws a ReferenceError.

How do you create a custom error class?

  • class MyError extends Error { }
  • function MyError()
  • new CustomError()
  • Error.create('My')

Answer: class MyError extends Error { }. Custom errors extend the built-in Error class, e.g. class ValidationError extends Error.

Does fetch() throw an error for HTTP status codes like 404 or 500?

  • Yes, always
  • Only for 500
  • No, you must check res.ok yourself
  • Only in strict mode

Answer: No, you must check res.ok yourself. fetch only rejects for network-level failures; you must check res.ok for bad HTTP status codes.

What does 're-throwing' an error mean?

  • Catching an error and ignoring it
  • Catching an error, handling it locally, then throwing it again to pass it upward
  • Throwing two errors at once
  • Converting an error to a string

Answer: Catching an error, handling it locally, then throwing it again to pass it upward. Re-throwing lets you log or add context locally, then pass the error up with throw err.

How can you handle errors in async/await code?

  • With a .then() only
  • Errors cannot be caught in async code
  • With window.onerror only
  • By wrapping the awaited calls in try/catch

Answer: By wrapping the awaited calls in try/catch. async/await uses ordinary try/catch around the awaited operations to catch failures.

In an outer/inner nested try/catch, where is an error thrown in the inner try caught first?

  • The outer catch
  • The inner catch
  • Both simultaneously
  • Neither

Answer: The inner catch. The nearest enclosing catch handles it first; the inner catch catches the inner error.

Which is a recommended error-handling best practice from the lesson?

  • Show database stack traces to users
  • Ignore async errors
  • Use specific error messages and don't leak sensitive info
  • Fail silently

Answer: Use specific error messages and don't leak sensitive info. Use clear, specific messages, handle async errors, and never leak sensitive details to users.

Continue this course