Skip to main content
Adapted, not copied

Original notes for JavaScript360.org — topic order inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke. See the full attribution note.

Chapter 8: Bugs & Errors

Estimated time: ~35 minutes · Chapter type: Concept

What you'll learn

  • A repeatable process for tracking down bugs instead of guessing
  • How to raise and handle errors deliberately with throw/try/catch
  • The difference between a bug you should fix and a condition you should recover from

Two kinds of "wrong"

Something going wrong in a running program falls into two rough categories:

  1. Bugs — the code doesn't do what you, the author, intended. The fix is to change the code.
  2. Expected failure conditions — the code is correct, but the situation is bad: a network request timed out, a file doesn't exist, a user typed invalid input. The fix isn't to change the logic — it's to handle the failure gracefully.

Confusing the two leads to bad code in both directions: wrapping every line in defensive try/catch to paper over actual bugs, or letting genuinely expected failures (like "the server didn't respond") crash the whole program because nobody planned for them.

Strict mode

Adding 'use strict'; at the top of a file (or a function) turns a number of silent JavaScript mistakes into loud errors — for example, assigning to an undeclared variable, which otherwise quietly creates a global:

'use strict';

function oops() {
total = 10; // ReferenceError: total is not defined — caught immediately
}

Modern JavaScript modules (Chapter 10) and classes are strict by default, so you'll increasingly get this protection automatically without adding the pragma yourself — but it's worth knowing what it does and why older code sometimes includes it explicitly.

Because JavaScript doesn't check types before running your code (Chapter 1), a wrong type often doesn't fail where the mistake actually happened — it fails somewhere downstream, with a confusing error, or worse, it fails silently and produces a wrong answer with no error at all:

function totalPrice(items) {
return items.reduce((sum, item) => sum + item.price, 0);
}

totalPrice([{ price: 10 }, { price: '5' }]); // "155" — string concatenation snuck in silently!

This is one of the strongest arguments for tools like TypeScript in larger codebases — they catch exactly this class of mistake before the code ever runs. In plain JavaScript, disciplined input validation and tests are your main defenses.

Testing

A test is a small script that runs part of your program with known input and checks the output matches what you expect, so a mistake gets caught automatically the moment it's introduced — not weeks later in production.

function add(a, b) {
return a + b;
}

function testAdd() {
console.assert(add(2, 3) === 5, 'add(2, 3) should be 5');
console.assert(add(-1, 1) === 0, 'add(-1, 1) should be 0');
}
testAdd();

Real projects use a test framework (Jest, Vitest, Node's built-in node:test) instead of hand-rolled assertions, but the underlying idea — "run the function, compare to an expected value" — never changes.

Debugging: a process, not a guessing game

When something's wrong, resist the urge to randomly change lines and re-run. A more reliable process:

  1. Reproduce it. Find the smallest input that consistently triggers the bug.
  2. Form a hypothesis. Based on the symptom, what do you think is happening?
  3. Check the hypothesis — with console.log at key points, or your environment's debugger and breakpoints — rather than assuming you're right.
  4. Fix, then verify the original reproduction case now works, ideally by turning it into a permanent test.
function average(nums) {
let total = 0;
for (const n of nums) {
console.log('adding', n, 'running total', total); // temporary debug output
total += n;
}
return total / nums.length;
}

Browser and Node.js DevTools also let you set actual breakpoints — the code pauses at a chosen line and lets you inspect every binding in scope, which is usually faster than sprinkling console.log everywhere once a bug is tricky.

Exceptions

Instead of returning a special "error" value and hoping every caller remembers to check for it, JavaScript lets you throw — which immediately stops normal execution and unwinds the call stack until something catches it:

function withdraw(balance, amount) {
if (amount > balance) {
throw new Error(`Cannot withdraw ${amount}, balance is only ${balance}`);
}
return balance - amount;
}

Error (and its built-in relatives like TypeError, RangeError) is the conventional thing to throw — it carries a message and a stack trace. You can also define your own error types for domain-specific failures, which lets calling code distinguish why something failed:

class InsufficientFundsError extends Error {
constructor(balance, amount) {
super(`Cannot withdraw ${amount}, balance is only ${balance}`);
this.name = 'InsufficientFundsError';
this.balance = balance;
this.amount = amount;
}
}

try / catch / finally

try runs a block of code; if it throws, execution jumps straight to catch instead of crashing the whole program:

try {
const newBalance = withdraw(100, 150);
console.log('New balance:', newBalance);
} catch (error) {
console.error('Withdrawal failed:', error.message);
}

finally runs regardless of whether the try block succeeded, threw, or even returned early — it's the right place for cleanup that must always happen, like closing a file handle or releasing a lock:

function processFile(path) {
const handle = openFile(path); // pretend function
try {
return readAndParse(handle);
} finally {
handle.close(); // always runs, success or failure
}
}

Error propagation and selective catching

If you don't catch an error, it keeps unwinding up the call stack until something does — or until it reaches the top and crashes the program (or, in a browser, gets logged to the console and the current event handler stops). This is usually correct behavior: a function three levels deep often doesn't know the right way to handle a failure, only how to report it, so letting it propagate to a caller that does know is the right call.

Be deliberate about which errors you catch. A catch block with no filtering swallows everything — including bugs you'd actually want to know about:

try {
riskyOperation();
} catch (error) {
if (error instanceof InsufficientFundsError) {
showUserMessage('Please add funds and try again.');
} else {
throw error; // not something we know how to handle — let it keep propagating
}
}

instanceof (Chapter 6) lets you distinguish error types and only swallow the ones you have an actual recovery plan for, re-throwing anything else rather than silently hiding it.

Assertions

An assertion documents an assumption your code relies on, and fails loudly (rather than producing a wrong answer quietly) if that assumption is ever violated:

function processAge(age) {
console.assert(age >= 0, 'age should never be negative');
// ... rest of the function assumes age >= 0
}

Think of assertions as "this should be logically impossible if the rest of the program is correct" — they're a debugging and documentation tool, distinct from throw, which is for conditions you genuinely expect to happen sometimes (bad user input, a failed network call) and must handle gracefully.

Key takeaways

  • Distinguish bugs (fix the code) from expected failure conditions (handle them gracefully) — they call for different responses.
  • Debug with a process: reproduce, hypothesize, check, fix, verify — not random changes.
  • throw immediately unwinds the stack until something catches it; finally always runs, for cleanup.
  • Only catch errors you have a real plan for; let everything else propagate so it isn't silently hidden.

Try it yourself

  1. Write a function parseAge(input) that throws a TypeError if input isn't a number, and a custom RangeError-style error if it's negative.
  2. Wrap a call to that function in try/catch and print a friendly message for each kind of error separately, using instanceof.
  3. Add a finally block to a function that always logs "done", regardless of whether the function's try block succeeds or throws.
Hints
  1. typeof input !== 'number' for the type check; input < 0 for the range check.
  2. Two separate if (error instanceof ...) branches inside one catch.
  3. finally doesn't need any condition — it always runs after try/catch resolve, one way or another.

Summary

Errors aren't something to avoid mentioning — they're a first-class part of a well-designed program's control flow, and throw/try/catch give you precise control over where a failure gets handled. Next, we look at a tool built specifically for finding patterns inside strings — invaluable for validating input and parsing text, both of which come up constantly once you start dealing with real-world, occasionally malformed data.

 

🌟 Join the JavaScript360 community

Connect with fellow JS developers, and get free interview prep, React challenges, and DSA guides.

Join on WhatsApp →