01/12Three kinds of error

LEARN TO PROGRAM · CHAPTER 08 · INTERMEDIATE

Errors, testing and debugging

Every programmer writes bugs. The good ones aren't the ones who never make mistakes; they are the ones who find them quickly, and who build nets that catch them before anyone else does.

This chapter follows one small bug through a lab of twelve stations: what errors are, how a program reports them, how to hunt one down on purpose, and how tests stop it coming back. Scroll, and the 3D follows.

Every code block runs in your browser. Edit it, break it, press Run.

01 · THREE KINDS OF ERROR

Three kinds of error

Errors come in three kinds, and they show up at different times. On the stage, three machines: one that never starts, one that starts and then stops dead, and one that runs perfectly and gives the wrong answer.

A syntax error means the text isn't valid code: a bracket missing, a typo in a keyword. The parser can't build a tree, so nothing runs at all, not even the first line.

syntax.js

A runtime error happens while the program is running, when it asks for something impossible: a property of undefined, a function that doesn't exist. The lines before it ran; the lines after never will.

runtime.js

A logic error is the quiet one. The program runs to the end and prints a confident, wrong answer. No message, no line number. This is the bug the rest of this chapter hunts:

average.js

The average of 4, 5 and 6 is 5. Can you see why it isn't? Don't fix it yet: the chapter will catch it four different ways.

02 · EXCEPTIONS

Exceptions: throw, and catch

When a function hits a problem it can't handle itself, it can throw an exception: an object that describes what went wrong. Throwing stops the function at once. The exception travels down the call stack, abandoning each function in turn (this is called unwinding), until it reaches code that said it can deal with it: a try block with a catch.

On the stage, main calls loadScores, which calls parseLine, which calls toNumber("x"). The exception drops through three frames and lands in main's catch, the green ring. The program carries on.

exceptions.js

Press Step through: watch the call stack fill up, then jump straight from toNumber to the catch. Then move the try inside loadScores and see what changes.

finally runs whether the code succeeded, threw, or returned early: it is where clean-up goes (closing a file, releasing a lock). Without any catch, an exception goes all the way down, and the program, or in a browser this one task, stops with "Uncaught".

Errors can carry more than a message. Make your own kind by extending Error, catch only what you understand, and pass the rest on. When you add context, keep the original as the cause, so nothing is lost:

custom-errors.js

Asynchronous code throws too. With await, a failed promise becomes an ordinary exception you can catch with the same try. (Chapter 9 is about async.)

async-errors.js

03 · ERRORS AS VALUES

Errors as values

Throwing is one way to report a problem. The other is to return it, as an ordinary value. JavaScript does both. Some built-ins answer "no" with a special value: indexOf returns -1, Number("x7") returns NaN, a missing key gives undefined. They never crash, which is exactly the danger: NaN quietly spreads through every sum it touches.

The stage shows the deliberate version. parse never throws. Every input comes out as a result with either a value or an error in it, and the code that called it has to look before it uses it: green to one bin, red to the other.

error-values.js

Some languages are built on this idea. Go functions return the error as a second value, and checking it is a habit the whole language leans on. Rust returns a Result that is either Ok or Err, and the compiler refuses to let you use the value without deciding what to do with the error.

Go · errors are values
n, err := strconv.Atoi("x7")
if err != nil {
    // the error is an ordinary value: you must look at it
    return fmt.Errorf("reading score: %w", err)
}
fmt.Println(n)
Rust · Result and the ? operator
fn read_score(text: &str) -> Result<i32, ParseIntError> {
    let n = text.parse::<i32>()?;   // ? : on Err, return it to the caller
    Ok(n)
}
 
match read_score("x7") {
    Ok(n) => println!("got {n}"),
    Err(e) => println!("skipped: {e}"),   // the compiler makes you handle both
}

Which to use? A rough rule: return errors that are a normal part of the job (bad user input, a file that may not exist), and throw for things that shouldn't happen if the program is correct. Whichever you choose, never let an error vanish: a catch that does nothing, or a NaN nobody checks, turns a loud bug into a silent one.

04 · STACK TRACES

Reading a stack trace

When an exception isn't caught, it arrives with a stack trace: a snapshot of the call stack at the moment it was thrown. It looks like noise until you know how to read it. On the stage, each line of the trace is wired to the frame it came from.

  • Top line: the kind of error and its message. TypeError: xs is not iterable means something tried to loop over a value that can't be looped over.
  • Next line: where it was thrown, as file:line:column. Here, line 3, inside average.
  • Each line below: who called that. report on line 7, called by main on line 11, called from line 14.
stats.js

Grace has no scores. The runner shows where it stopped. Which line is the real bug: line 3, or the data on line 10?

The trace is a value you can read from code, too: it lives in err.stack. (It isn't part of the ECMAScript standard yet, but every major engine has it; each formats it slightly differently. Here the sandbox names your code your-code.js, and the bottom frame says eval because that is how the sandbox starts your file.)

read-the-stack.js

Python prints its trace the other way up, oldest call first, with the error at the bottom, and points at the exact expression that failed:

the same bug in Python
Traceback (most recent call last):
  File "stats.py", line 14, in <module>
    main()
  File "stats.py", line 12, in main
    print(report(p))
  File "stats.py", line 8, in report
    return p["name"] + ": " + str(average(p["scores"]))
                                          ~^^^^^^^^^^
KeyError: 'scores'

Two habits make traces useful. First, read the whole message: it usually says exactly what is wrong. Second, the top frame is where the program noticed; the cause is often further down, in your code, where a bad value was made. Skip frames inside libraries until you reach a file you wrote.

05 · THE BUG HUNT

The bug hunt: a method, not luck

Debugging is the scientific method applied to a program. Beginners often change things at random until the bug goes away; studies of novice programmers find exactly that, and it is slow. The loop on the stage is faster:

  1. Reproduce it. Find input that makes the bug happen every time. A bug you can't reproduce, you can't confirm you fixed.
  2. Guess why. Form one specific hypothesis: "the sum is wrong".
  3. Predict. If that's true, what should you see? "The sum should reach 15."
  4. Test it, with a print, a breakpoint or an experiment, and let the result narrow the search.

The simplest test is a print statement: a probe that reports what is true at one moment. Logging an object, { i, x: xs[i], sum }, labels every value for free:

probe.js

The first probe says i: 1. The first item, 4, at index 0, is never visited. Prediction failed, bug found.

A good reproduction is also a small one. Shrink the failing input until the bug has nowhere to hide. Here, one item is enough, and the answer 0 almost names the bug for you:

shrink.js

06 · BISECT

Bisect: halve the haystack

Sometimes you know the code worked last week and is broken today, but not which of the changes since broke it. Don't read every change. Test the one halfway between the good and the bad: whichever way it goes, half the suspects are cleared. This is binary search, and it is called bisecting.

On the stage, 16 commits: the bracket closes from 0 to 15, then 7 to 15, 7 to 11, 9 to 11, 10 to 11. Four tests, and commit 11 is the culprit. This program does exactly the same:

bisect.js

Change FIRST_BAD to any number from 1 to 15. It never takes more than 4 tests.

Git does this for you. Mark one commit good and one bad, and git bisect checks out the middle commit for you to test; give it a test script and it runs the whole search alone:

git bisect
$ git bisect start
$ git bisect bad                 # the commit you're on is broken
$ git bisect good v1.4           # this old release worked
Bisecting: 7 revisions left to test after this (roughly 3 steps)
$ npm test                       # ...test the commit git checked out
$ git bisect good                # or: git bisect bad
  ...
a1c9e2f is the first bad commit
$ git bisect reset
 
# or let a script decide every step for you:
$ git bisect run npm test
20tests are enough to find one bad commit among a million, because 2²⁰ = 1,048,576. Learn more about commits on the Git page. git-bisect docs

The same trick works on anything you can cut in half: comment out half of a function, delete half of an input file, turn off half of the settings. Whatever still fails holds the bug.

07 · THE DEBUGGER

The debugger, and the duck

A debugger lets you pause a running program and look around. You set a breakpoint on a line (the red dot on the stage); when execution reaches it, everything freezes. Now you can read every variable, see the call stack, and step: run one line, step into a function call, or step out of the current function.

On the stage the debugger pauses on line 4 each time round the loop. The watch panel shows i = 1 on the first stop, and sum ends at 11 instead of 15. The Step through button on every code block in this course is a small debugger. Try it here:

debug-me.js

Step forward until line 4 runs for the first time. What are i and xs[i]? Where did the 4 go?

Every browser has a full debugger built in (open DevTools, go to Sources, or Debugger in Firefox, and click a line number), and so do VS Code, Python (pdb) and C (gdb). Worth learning early: conditional breakpoints (pause only when i === 500), logpoints (print without editing the code), and pausing on any exception. Writing debugger; in JavaScript pauses there when DevTools is open.

08 · KINDS OF TEST

Unit, integration and end-to-end tests

Debugging finds a bug once. A test is a small program that checks your program, so the bug can never come back unnoticed. At its heart a test is just: run the code, compare the result with what you expected, complain loudly if they differ. That comparison is an assertion.

Here is a whole test runner in ten lines, testing our buggy average. Real ones (Jest, Vitest, Node's built-in node:test, Python's pytest) add nicer reports, but do the same:

average.test.js

Three red crosses. Fix the loop to start at 0: two go green. The third needs a decision, not a fix: what should the average of nothing be? Tests force you to decide.

Tests come in sizes, and the stage stacks them as a pyramid. At the bottom, many unit tests: one function, no network, no disk, milliseconds each. In the middle, fewer integration tests: several parts together, often with a real database or file. At the top, a few end-to-end tests that drive the real app like a user, through a browser. Higher tests catch more kinds of bug but are slower and break for more reasons, so you want fewer of them.

Integration tests find bugs that live between units. Both functions below are correct on their own; real input, with a blank line in it, breaks them together:

integration.test.js
an end-to-end test with Playwright (runs a real browser, so not here)
// tests/scores.spec.js · Playwright drives a real browser
import { test, expect } from "@playwright/test";
 
test("a teacher sees the class average", async ({ page }) => {
  await page.goto("http://localhost:3000/class/7b");
  await page.getByLabel("Upload scores").setInputFiles("fixtures/7b.csv");
  await expect(page.getByRole("cell", { name: "Average" })).toContainText("5.0");
});

Assertions aren't only for tests. Put them in your code to state what must be true (an invariant), so a wrong assumption fails loudly and early, near its cause:

assertions.js

09 · TEST FIRST

Test first: red, green, refactor

Test-driven development (TDD) turns the order round: write the test before the code. Kent Beck set it out as a cycle of three short steps, the three lamps on the stage:

  1. Red. Write one small test for behaviour that doesn't exist yet. Run it and watch it fail, which proves the test can fail.
  2. Green. Write the simplest code that makes it pass. Nothing more.
  3. Refactor. Now clean up the code (and the tests), running them after every change. They stay green, or you know at once what you broke.
median.test.js

This is the red step. Make it green yourself in the exercise Test first: make it green below.

Why bother? Writing the test first makes you decide what the code should do before you are attached to how it does it, keeps every step small, and leaves a full test suite behind as a side effect. It suits code with clear inputs and outputs best. When exploring an idea you don't understand yet, many programmers sketch first, then write the tests once the shape is clear. Either way, when you fix a bug, write the test that reproduces it first: a regression test, which makes sure that bug stays fixed.

10 · PROPERTY TESTS

Property-based testing

An example test checks one input you thought of. A property test states a rule that must hold for every input, then lets the computer try hundreds of random ones. For sorting: the output is in order, and it has the same items as the input.

When an input breaks the rule, the tester shrinks it: it keeps trying smaller versions while they still fail, so you get the simplest example of the bug, not a messy one. On the stage, 13 random lists pass, the 14th fails, and it shrinks from [13, 13, 11, 13, 2] to [10, 2]. Run the real thing:

property-test.js

The bug: sort() with no compare function sorts numbers as text, so "10" comes before "2". Fix it with sort((a, b) => a - b) and run again: 200 lists pass.

The idea comes from QuickCheck (Claessen and Hughes, 2000) for Haskell. Today there is a library for most languages: fast-check for JavaScript, Hypothesis for Python. Good properties to look for: a round trip (decode what you encode and get it back), an invariant (sorting never changes the length), or a slow but obviously right version to compare against.

the same property with Python's Hypothesis
from hypothesis import given, strategies as st
 
@given(st.lists(st.integers()))
def test_sort_keeps_order_and_items(xs):
    out = my_sort(xs)
    assert all(a <= b for a, b in zip(out, out[1:]))
    assert sorted(out) == sorted(xs)
 
# Falsifying example: test_sort_keeps_order_and_items(xs=[10, 2])

Its wilder cousin, fuzzing, throws huge numbers of random or mutated inputs at a program looking for crashes. Google's OSS-Fuzz runs it nonstop on open-source projects. It has found bugs in some of the most-used code there is.

11 · COVERAGE

Code coverage, and its limits

Code coverage measures which parts of your code ran while the tests ran: the share of lines (line coverage), or of branches, the paths through each if (branch coverage). Tools such as Istanbul (behind Jest's and Vitest's --coverage) and Python's coverage.py add counters to your code to measure it.

Coverage is good at one thing: showing code that no test touches, so you know where to look. It cannot tell you the tests are good. On the stage the gauge reaches 100%, and the bug is still sitting on its line:

coverage.js

Every line ran, the "test" passed, the answer is still wrong. A test only catches what it checks.

Coverage also can't see code that isn't there. The missing if (xs.length === 0) on the stage is a line no tool can report as uncovered. Studies agree: once you account for how many tests a suite has, coverage is only weakly linked to how many bugs it catches. Google's engineers suggest roughly 60% as acceptable, 75% as commendable and 90% as exemplary, and warn against chasing 100%.

12 · TYPES, LINT, LAYERS

Types, linting, and layers of defence

The cheapest bug is the one caught before the code even runs. Two kinds of tool read your code without running it (static analysis) and work like tests you never had to write.

A type checker knows what kind of value every variable can hold and refuses code that mixes them up. TypeScript would have caught Grace's missing scores from the stack-trace act before anyone ran it:

TypeScript · report.ts
type Player = { name: string; scores?: number[] };   // scores may be missing
 
function report(p: Player) {
  return p.name + ": " + average(p.scores);
  //                             ~~~~~~~~
  // error TS2345: Argument of type 'number[] | undefined' is not
  // assignable to parameter of type 'number[]'.
}
15%of real bugs in public JavaScript projects would have been caught by adding TypeScript or Flow, in a study that went back and tried it on 400 fixed bugs. Gao, Bird & Barr, ICSE 2017

A linter (ESLint for JavaScript, Ruff or Pylint for Python) looks for patterns that are legal but usually wrong: == where you meant ===, a variable you never use, code that can never run. Here is a toy one; real linters read the parse tree, not raw text:

tiny-linter.js

No single check catches everything, and that is the point of the last station. Each layer is a sheet with holes in it: types, lint, unit tests, code review, then a canary release that shows new code to a small share of real users first. Most bugs hit a solid part of some sheet. A bug only reaches everyone when the holes line up.

The Pipeline page lets you ship a bad commit through all of these layers and watch a canary catch it. Bugs That Changed History replays what happened when the holes did line up: in July 2024, a CrowdStrike content update whose template expected 21 input fields, while the sensor supplied 20, crashed about 8.5 million Windows machines at once, and a staged rollout was one of the fixes promised afterwards.

PRACTICE

Now you do it

Type the code yourself rather than pasting it: producing an answer builds memory that reading does not. Use the hints one at a time.

1

Find the off-by-one

The bug from the whole chapter, now yours to fix. average([4, 5, 6]) should be 5 and average([7]) should be 7. Use Step through and watch i before you change anything.

exercise-1.js
2

Catch it, don't crash

safeTotal adds up numbers from lines of text, but one bad line crashes the whole thing. Make it skip a bad line (and say so) and keep going, so safeTotal(["4", "x", "6"]) returns 10. Don't change toNumber.

exercise-2.js
3

The crash is not where the bug is

Run it and read the error: it says formatName failed. But formatName is fine. Follow the trace down to the caller and find out why it was handed undefined. Make greet("2") return Hello, Grace Hopper.

exercise-3.js
4

Let the tests tell you

Two of the five tests fail. Fix isLeapYear so every test passes, without changing the tests. (The Gregorian rule: every fourth year is a leap year, except years divisible by 100, except years divisible by 400.)

exercise-4.js
5

Test first: make it green

The tests are written; median isn't. Write it so all four pass. The last test is there to catch a famous JavaScript trap.

exercise-5.js
6

Fix what the property test found

chunk should split a list into pieces of a given size, with a shorter last piece if needed. A property test throws 300 random lists at it and reports the first one that breaks the rule "glue the chunks back together and you get the input". Run it, read the counterexample, fix chunk.

exercise-6.js
7

Find the bad version in six tries

Versions 0 to 63: version 0 is good, 63 is bad, and somewhere one version broke things. Checking a version is slow, so you may call isGood at most 6 times. The starter checks them one by one and uses far too many calls. Replace the search.

exercise-7.js

RECALL

Check yourself, from memory

Answer each one before you open it. Then come back tomorrow, and again next week, and answer them cold: retrieval spaced over days is the most reliable study method we know of. Why that works.

Name the three kinds of error, and which one is the most dangerous.

When an exception is thrown, what happens to the call stack?

In a stack trace, what does the top line tell you, and what do the lines below it tell you?

You have 1,000 commits and one of them broke the build. About how many do you need to test with bisect?

What is the difference between a unit, an integration and an end-to-end test?

Your tests have 100% line coverage. What does that prove?

GO FURTHER

Resources

  • BOOK · FREEThe Debugging BookAndreas ZellerA whole book of runnable debugging tools: tracing, delta debugging, statistical fault localisation. From the author of Why Programs Fail.
  • COURSE · FREEDebugging and ProfilingMIT, The Missing SemesterOne lecture on logging, debuggers, and finding what is slow. Short, practical, with exercises.
  • DOCS · FREEDebug JavaScript (Chrome DevTools)Chrome for DevelopersBreakpoints, stepping, watch expressions and the call stack in the debugger already in your browser.
  • DOCS · FREEError handling, try...catchjavascript.infoExceptions in JavaScript in depth, with tasks: finally, rethrowing, custom errors.
  • COURSE · FREETesting (Full Stack Open, part 4)University of HelsinkiUnit and integration tests for a real Node back end, then end-to-end tests in part 5.
  • DOCS · FREEErrors and Exceptions (Python tutorial)Python Software FoundationThe same ideas in Python: try, except, raise, finally, exception chaining.
  • ARTICLE · FREEErrors are valuesRob PikeWhy Go returns errors instead of throwing them, and how to keep that from becoming repetitive.
  • TOOL · FREEfast-checkProperty-based testing for JavaScript and TypeScript, with shrinking built in.
  • TOOL · FREEHypothesisProperty-based testing for Python. Its docs are a good tutorial on the idea.
  • PRACTICE · FREEExercismEvery exercise comes with a test suite: the best way to practise working against tests.

SOURCES

Where the facts come from

  1. Smithsonian NMAH: Log Book With Computer Bug Harvard Mark II, 9 September 1947: "First actual case of bug being found." The word bug was older.
  2. ECMAScript 2025 spec: The try Statement
  3. MDN: try...catch
  4. MDN: Error, and Error: cause
  5. MDN: Error.prototype.stack (non-standard)
  6. MDN: console.assert()
  7. MDN: Array.prototype.sort() (compares as strings by default)
  8. ESA: Ariane 501 Inquiry Board report An unprotected conversion from a 64-bit float to a 16-bit integer raised an exception nothing handled.
  9. Python docs: Errors and Exceptions
  10. The Rust Book: Recoverable Errors with Result
  11. Rob Pike, Errors are values (2015)
  12. git-bisect documentation
  13. Rubber duck debugging Named in Hunt and Thomas, The Pragmatic Programmer (1999).
  14. McCauley et al. (2008), Debugging: a review of the literature from an educational perspective
  15. Martin Fowler: Test Pyramid The idea comes from Mike Cohn, Succeeding with Agile (2009).
  16. Kent Beck, Test-Driven Development: By Example (2002)
  17. Claessen & Hughes (2000), QuickCheck, ICFP
  18. Dijkstra, Notes on Structured Programming (EWD249, 1970) "Program testing can be used to show the presence of bugs, but never to show their absence!"
  19. Inozemtseva & Holmes (2014), Coverage is not strongly correlated with test suite effectiveness, ICSE
  20. Google Testing Blog: Code Coverage Best Practices (2020)
  21. Gao, Bird & Barr (2017), To Type or Not to Type: Quantifying Detectable Bugs in JavaScript, ICSE
  22. Tony Hoare: Null References, The Billion Dollar Mistake (2009)
  23. CrowdStrike: Channel File 291 root cause analysis (2024) A template defined 21 input fields; the sensor code supplied 20.
  24. OSS-Fuzz: continuous fuzzing for open-source software
  25. Istanbul: JavaScript code coverage
  26. Stryker Mutator: mutation testing
  27. ESLint
  28. Microsoft: Helping our customers through the CrowdStrike outage About 8.5 million Windows devices affected.