Errors and Bug Fixing

Things go wrong in every program; professionals differ in how they respond. This lesson gives failure a vocabulary (the error types), a toolbox (throw, try, catch), and a repeatable workflow — so debugging stops being luck and becomes a process.

The five ways code fails

Three runtime errors — and when each fires

When JavaScript cannot continue, it throws an object that describes the problem. Three types cover most of daily life. Every one of these lines runs (this is not a syntax lesson) — try each in isolation and read the message:

// 1. ReferenceError — the name was never declared (or is still in its TDZ)
console.log(username);
// ReferenceError: username is not defined

// 2. TypeError — the value exists, but you asked it for something it cannot do
const count = 3;
console.log(count.toUpperCase());
// TypeError: count.toUpperCase is not a function

// 3. RangeError — the value is the right kind, but outside the allowed range
new Array(-5);
// RangeError: Invalid array length

The distinction matters because it tells you where to look: a ReferenceError points at a typo or a scope problem; a TypeError points at a value that arrived in the wrong shape; a RangeError points at input that passed your type checks but is still invalid.

The fourth kind — SyntaxError — fires before anything runs

// One missing bracket anywhere in the file...
const list = [1, 2, 3;
// SyntaxError: Unexpected token ';'

A SyntaxError is thrown at parse time: none of the script runs, not even the lines above the mistake. This is why one broken file can take down an entire page — and why the browser console is the first thing to open when a page appears to do nothing.

The fifth kind — logic errors — throw nothing at all

// Runs fine. Fails silently. This "average" divides by length twice:
const scores = [80, 90, 100];
const average = scores.reduce((sum, s) => sum + s) / scores.length / 2;
console.log(average); // 45 — no error, just a confidently wrong number

Logic errors are the reason tests exist (Phase 5): the machine cannot catch them for you. Only the expected value can.

throw, try and catch

Throwing your own errors — fail loudly, fail early

Any value can be thrown, but throw Error instances only: they carry a message, a name, and a stack — the three things a debugger reads:

function divide(a, b) {
  // Guard clause: reject impossible input immediately, with a message a human can act on
  if (b === 0) {
    throw new RangeError(`divide: b must not be 0 (got b=${b})`);
  }
  return a / b;
}

console.log(divide(10, 4)); // 2.5
console.log(divide(10, 0)); // uncaught RangeError: divide: b must not be 0 (got b=0)

An uncaught error is not a failure of your program — it is your program refusing to continue on false pretences. Silent wrong output is far more expensive than a loud failure.

Catching is for recovery, not for hiding

function loadScore(raw) {
  try {
    const score = JSON.parse(raw);                       // can throw SyntaxError on bad JSON
    if (typeof score !== "number") {
      throw new TypeError("score must be a number");     // our own, deliberate throw
    }
    return score;
  } catch (error) {
    console.error("loadScore failed:", error.message);   // log it — never vanish silently
    return 0;                                            // a fallback the caller can rely on
  }
}

console.log(loadScore('{"not":"a number"}'));
// loadScore failed: score must be a number
// 0
console.log(loadScore("42")); // 42

If you see this instead: nothing. An empty catch block is the most expensive five characters in programming: the program continues in a state nobody planned for, and the real bug surfaces later, far from its cause. Every catch must do one of three things: recover, log, or rethrow.

Custom error subclasses — errors you can filter on

When different failures need different handling, subclass Error and branch on the type instead of on message strings:

class ValidationError extends Error {
  constructor(message) {
    super(message);                // Error's constructor sets .message and .stack
    this.name = "ValidationError"; // otherwise it would print as "Error"
  }
}

function registerUser(name) {
  if (!name || name.length < 2) {
    throw new ValidationError(`name must be at least 2 characters (got "${name}")`);
  }
  return { name };
}

try {
  registerUser("");
} catch (error) {
  if (error instanceof ValidationError) {
    console.log("show the red border:", error.message);
    // show the red border: name must be at least 2 characters (got "")
  } else {
    throw error; // not ours — do not swallow it
  }
}

A repeatable debug workflow

  1. Reproduce. A bug you cannot trigger on demand cannot be fixed on purpose.
  2. Read the error and stack trace first. The top line names the error; the lines below name the path of calls that led there:
TypeError: Cannot read properties of undefined (reading 'role')
    at showBadge (user.js:12:15)     ← where it broke
    at renderHeader (header.js:40:3) ← who called it
    at main (app.js:5:1)

The classic beginner mistake is re-reading the code that should work. Read the trace instead: showBadge received undefined — the question is who passed it, and the answer is in the frame above.

  1. Inspect values at the failure point (breakpoints — lesson 18).
  2. Fix the root cause, not the symptom: a missing check one level up, not a ?. sprinkled at the crash site.
  3. Retest related flows — the same bad value probably reaches other callers.

Defensive coding: validate at the boundary

Inside a function you may trust your own types; at the edges — user input, network responses, JSON files — trust nothing:

function safeUpper(text) {
  if (typeof text !== "string") {
    // Boundary: the caller may be anyone. Fail fast with a precise message.
    throw new TypeError(`safeUpper: expected a string, got ${typeof text}`);
  }
  return text.toUpperCase();
}

console.log(safeUpper("ok")); // OK

Where try/catch cannot help (yet)

A try/catch only sees errors thrown synchronously inside it. An error inside a setTimeout callback, or a failed fetch, escapes it entirely — that failure mode is the subject of Phase 4. For now, know the boundary:

try {
  setTimeout(() => {
    throw new Error("too late — the catch is long gone");
  }, 100);
} catch (error) {
  console.log("caught"); // NEVER printed — different turn of the event loop
}

Practice: break it on purpose

The task

Run runtime/22_errors_lab.js with node. It triggers each error type on purpose, catches them by class, and prints the real message text — predict each line before running. Then build a parseAge(raw) that throws ValidationError for negative or non-numeric input, and prove both failure paths with a test call.

The checklist

  • You can name the error type you expect from a typo, a bad shape, and bad input.
  • You know SyntaxError runs nothing, and logic errors run everything.
  • Every catch you write recovers, logs, or rethrows — never hides.
  • You can read a three-frame stack trace and say where to look first.