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:
- Bugs — the code doesn't do what you, the author, intended. The fix is to change the code.
- 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.
Type-related bugs
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:
- Reproduce it. Find the smallest input that consistently triggers the bug.
- Form a hypothesis. Based on the symptom, what do you think is happening?
- Check the hypothesis — with
console.logat key points, or your environment's debugger and breakpoints — rather than assuming you're right. - 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.
throwimmediately unwinds the stack until somethingcatches it;finallyalways 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
- Write a function
parseAge(input)that throws aTypeErrorifinputisn't a number, and a customRangeError-style error if it's negative. - Wrap a call to that function in
try/catchand print a friendly message for each kind of error separately, usinginstanceof. - Add a
finallyblock to a function that always logs"done", regardless of whether the function'stryblock succeeds or throws.
Hints
typeof input !== 'number'for the type check;input < 0for the range check.- Two separate
if (error instanceof ...)branches inside onecatch. finallydoesn't need any condition — it always runs aftertry/catchresolve, 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.