Back to Tutorials
🛡️ Intermediate

Error Handling

7 min read | Error Handling

Error Handling

Errors are not the enemy — invisible errors are. A program that fails loudly at the right place with a clear message is easy to fix; one that swallows failures corrupts data quietly for weeks. This tutorial covers throwing, catching, custom error types, and the async cases where beginners' try/catch blocks silently do nothing.

Step 1: try / catch / finally

try {
  const data = JSON.parse(invalidJson);   // might throw
  process(data);                          // skipped if parse threw
} catch (error) {
  console.error("Parse error:", error.message);
} finally {
  console.log("Cleanup runs on BOTH paths");
}

Why it works: when a statement inside try throws, execution jumps immediately to catch — the remaining try lines never run. finally runs afterwards in every case: success, caught error, even an early return. That guarantee makes it the home for cleanup like closing connections or hiding spinners. Every error object carries three key properties: name (its type), message (human-readable description), and stack (where it happened).

Step 2: Throwing Your Own Errors

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

Why throw early: validating inputs at the top of a function converts a mysterious downstream failure (Infinity spreading through your math) into an immediate, located, explained one. Always throw Error objects — throw "oops" technically works but has no stack trace and breaks instanceof checks, crippling both debugging and error tracking.

Step 3: Built-in and Custom Error Types

// Built-ins you will meet daily
new TypeError("Type mismatch");        // wrong kind of value
new ReferenceError("x is not defined"); // unknown variable
new RangeError("Value out of range");
new SyntaxError("Invalid syntax");     // usually at parse time
// Custom types carry domain meaning
class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}
throw new ValidationError("Email is invalid", "email");

Why custom classes: a catch block can then route instead of guess:

try {
  saveUser(input);
} catch (error) {
  if (error instanceof ValidationError) {
    showFieldError(error.field, error.message); // user's fault — show form hint
  } else {
    throw error;                                 // our fault — let it surface
  }
}

That last line is the golden rule: catch what you can handle, rethrow what you cannot. A catch block that absorbs every error hides real bugs behind friendly messages.

Step 4: Async Errors — Where try/catch Betrays You

// Promise chains: catch() is the error channel
fetchData()
  .then(data => process(data))
  .catch(error => console.error(error));
// async/await: try/catch works again — because of await
async function getData() {
  try {
    const data = await fetchData();
    return data;
  } catch (error) {
    console.error("Failed to fetch:", error);
    throw error; // rethrow so callers know too
  }
}

Why await is the magic ingredient: try/catch only sees exceptions thrown synchronously inside its block. A rejected promise is not a thrown exception — unless you await it, at which point the rejection is re-thrown at the await line. This is the single most common async bug: a try/catch around fetchData() without await catches nothing, and the rejection escapes as an unhandled rejection (which crashes modern Node.js). Similarly, an error inside setTimeout's callback happens long after your try block finished — handle it inside the callback itself.

Step 5: Handling Failures Gracefully

async function safeFetch(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(HTTP error! status: ${response.status});
    }
    return await response.json();
  } catch (error) {
    console.error("Fetch failed:", error);
    return null;   // explicit, documented fallback
  }
}

Why check response.ok: fetch rejects only on network failure — a 404 or 500 response fulfills the promise. Converting bad statuses into thrown errors gives every failure the same path. Returning null as a fallback is a legitimate design, but make it deliberate and documented, and let callers distinguish "no data" from "data".

Common Beginner Errors and Fixes

  • try/catch without await: try { fetchData() } catch {} catches nothing. Fix: await inside the try, or use .catch() on the chain.
  • Empty catch blocks: catch (e) {} makes failures invisible. Fix: at minimum console.error(e); ideally report and rethrow the unexpected ones.
  • Throwing strings: no stack, no type, poor grouping in error trackers. Fix: throw new Error("message") — always.
  • Catching too broadly: wrapping 50 lines in one try treats typos like network blips. Fix: keep try blocks tight around the risky statement.
  • Forgetting errors in finally: a throw inside finally replaces the original error. Fix: keep finally trivial — flags, logs, cleanup.

Practice Exercise

Build a robust settings loader:

  • Write parseSettings(json) that JSON.parses its input, and throws a custom SettingsError (subclass of Error, with a name) when the parsed value has no theme property.
  • Write loadSettings(raw) that calls parseSettings inside try/catch: on SyntaxError return the default { theme: "light" }; on SettingsError log the message and return the default; on anything else rethrow.
  • Test with three inputs: valid JSON with a theme, "not json", and "{}" — confirm each takes a different path.
  • Bonus: make loadSettings async, have it await a fake fetch that randomly rejects, and verify your catch still fires (it will only if you awaited).
  • If all four paths behave as predicted, you understand error routing — which is the real skill, beyond just catching.