Building Software
Sections

Contents Introduction

Reading the code in this course

Enough TypeScript, SQL, diffs and test output to follow every lesson: how to read a function you did not write, line by line, and predict what it does.

Lesson 0.3 of the Introduction, a 10-minute read.

You ask an agent to let customers type a discount code at checkout. A minute later it replies "Done, all tests pass" and hands you a pull request: a TypeScript function, a test, the SQL that stores the codes, and the terminal output from running the test. The lessons in this course start from material like that and ask you to read it closely enough to say whether it is right.

You do not need to know every rule of TypeScript or SQL to do that. You need a method that works on code you have never seen, and enough of the notation to apply it. The method has four steps:

  1. Find the inputs: what the code is given, and where each value comes from.
  2. Pick one concrete value for each input and trace it through, line by line, writing down what each name holds.
  3. Predict the output before you run anything.
  4. Run it and compare. Where your prediction and the output differ, either the code or your reading is wrong, and both are worth knowing.

The function

Here is the file the agent changed.

TypeScript
import { findDiscount } from "./discounts";

type LineItem = { name: string; priceCents: number; quantity: number };

export async function checkout(items: LineItem[], code: string): Promise<number> {
  const kept = items.filter((item) => item.quantity > 0);
  const lineTotals = kept.map((item) => item.priceCents * item.quantity);
  let total = 0;
  for (const cents of lineTotals) {
    total = total + cents;
  }

  if (code !== "") {
    const discount = await findDiscount(code.toUpperCase());
    if (discount === undefined) {
      throw new Error(`Unknown discount code: ${code}`);
    }
    if (!discount.active) {
      throw new Error(`Code ${discount.code} is no longer active`);
    }
    total = total - discount.centsOff;
  }
  return total;
}

The first line brings in a function, findDiscount, from another file in the project; it looks a discount code up in the database.

The type line describes the shape of a value. A LineItem is an object, a bundle of named fields: a name that is text (string), a priceCents that is a number, and a quantity that is also a number. Prices are kept as whole cents, so 1299 means $12.99, because whole numbers add up exactly in a computer and fractions of a dollar often do not. In a type line, and after a parameter or variable name, what follows the colon is a type annotation: a label saying what kind of value goes there. Inside a value, such as { name: "Notebook", priceCents: 1299, quantity: 2 }, the colon pairs a field with its value instead; a type describes a shape and a value fills it. A | between two types means either one, so Discount | undefined is a discount or nothing. A checker reads them before the program runs; they never change what the code does.

Inputs and names

The line that begins export async function starts the function. Its name is checkout, and the parentheses list its inputs, called parameters: items, which is an array of LineItem (the [] means "a list of"), and code, a string. After the closing parenthesis comes the type of what the function gives back, Promise<number>. Ignore async and Promise for a moment; read it as "this returns a number, eventually." That answers step 1 of the method: the inputs are a list of cart lines and the code the customer typed.

const and let create names for values. A name made with const cannot be pointed at a different value for the rest of the function, though an array or object it holds can still be changed inside. A name made with let can be given a new value later, which is why total is a let: the loop adds to it, and the discount line near the end subtracts from it. When you see let, look for every line that reassigns it, because each one is a place the value can go wrong.

Four columns joined left to right. The first, items, holds three lines: Notebook 1299 times 2, Pen 350 times 0, and Mug 1500 times 1. An arrow labelled filter leads to the second column, kept, which holds Notebook and Mug, with the Pen row crossed out. An arrow labelled map leads to the third column, lineTotals, holding 2598 and 1500. An arrow labelled for leads to the last column, total, where 0 becomes 2598 and then 4098. Notes say filter drops the Pen and neither changes the original array.
The cart the lesson traces later, carried through checkout: filter drops the Pen, map turns each kept line into price times quantity, and the loop adds those to total, which goes from 0 to 2598 to 4098.

The filter and map lines use arrow functions, the (item) => ... pieces. An arrow function is a small function written inline. (item) => item.quantity > 0 takes one input, calls it item, and gives back whether its quantity is more than zero. Nothing runs until something calls it.

filter and map are what call it. items.filter(f) calls f once for each element of items and builds a new array of the elements for which f gave back true. So kept is the cart without the lines whose quantity is zero. kept.map(f) also calls f once per element, and builds a new array of whatever f gave back. So lineTotals is one number per kept line: price times quantity. Neither changes the original array. To read one element, write its position in square brackets; positions count from 0, so lineTotals[0] is the first and lineTotals[1] the second.

The three lines starting at for are a loop. for (const cents of lineTotals) runs the body once per element, with cents holding that element, and the body adds it to total.

Decisions and errors

if (code !== "") is a condition. if (...) { ... } runs the block in braces only when the condition is true. !== means "is not equal to," so the discount code is only looked up when the customer typed something. Its partner === means "is equal to," and it compares strictly: the number 1 and the string "1" are not equal. The double-equals form, ==, converts types before comparing and is rarely what you want.

The next line calls code.toUpperCase(), which gives back a copy of the text in capital letters, so "spring10" is looked up as "SPRING10". Hold on to that; it matters in the SQL.

A flow chart. Code typed, testing code not equal to the empty string, leads on yes to findDiscount with code.toUpperCase(), and on no straight down to return total. findDiscount leads to No match, testing discount equals undefined; yes goes to a throw box, Unknown discount code. No goes down to Inactive, testing not discount.active; yes goes to a throw box, no longer active. No goes down to Subtract, total minus discount.centsOff, which leads left to return total. A note beside the throws says throwing stops the function.
checkout has four ways out. With no code it returns the total untouched, an unknown or inactive code throws and stops the function there, and only a found, active code reaches the subtraction.

The two if blocks after it handle two things that can go wrong. undefined is the value JavaScript uses for "nothing here," and findDiscount gives it back when no code matches. ! means "not," so !discount.active is true when the code exists but has been switched off. Its relatives are &&, "and," and ||, "or": a || b is true when either side is. In both cases the function throws an Error. Throwing stops the function immediately; no later line runs, nothing is returned, and the error travels up to whoever called checkout. That caller can catch it with try and catch.

The messages use template strings, written in backticks. Inside one, ${...} is replaced with the value of whatever is in the braces, so if the customer typed "BOGUS", the first throw builds the text "Unknown discount code: BOGUS".

total = total - discount.centsOff takes the discount off, and return total hands the total back.

Waiting: async and await

Looking up a code means asking a database, which might take a few milliseconds or a few hundred. A program that stood still while it waited would be unable to serve anyone else in that time. So a slow operation gives back a promise straight away: an object that stands for a value that is not ready yet. await pauses this one function until the promise has its value, then gives you the value. Other work carries on while it waits.

A function marked async can use await inside it, and it always returns a promise itself. That is why checkout returns Promise<number> and not number, and why whoever calls it must await it too. If they forget, they hold the promise instead of the number. TypeScript reports total > 0 on a promise as an error; plain JavaScript runs it and quietly gives false.

Trace one value, then run it

Now the method. Take this cart and the code "spring10":

  • Notebook, 1299 cents, quantity 2
  • Pen, 350 cents, quantity 0
  • Mug, 1500 cents, quantity 1

Trace it. filter drops the Pen, so kept has the Notebook and the Mug. map gives lineTotals as 2598 and 1500. The loop takes total from 0 to 2598 to 4098. The code is not empty, so the lookup asks for "SPRING10", which exists, is active, and is worth 1000 cents. The subtraction sets total to 3098. Prediction: 4098 with no code, 3098 with "spring10".

Before you run the block below, predict its last line too: two pens, 700 cents, with a 1000-cent code. The block puts a stand-in for findDiscount at the top, an array of codes and a short pause, so it runs on its own.

TypeScriptRuns in your browser
type LineItem = { name: string; priceCents: number; quantity: number };
type Discount = { code: string; centsOff: number; active: boolean };

// A stand-in for the discount_codes table and the trip to the database.
const discounts: Discount[] = [
  { code: "SPRING10", centsOff: 1000, active: true },
  { code: "WINTER25", centsOff: 2500, active: false },
];
async function findDiscount(code: string): Promise<Discount | undefined> {
  await new Promise((resolve) => setTimeout(resolve, 20));
  return discounts.find((d) => d.code === code);
}

async function checkout(items: LineItem[], code: string): Promise<number> {
  const kept = items.filter((item) => item.quantity > 0);
  const lineTotals = kept.map((item) => item.priceCents * item.quantity);
  let total = 0;
  for (const cents of lineTotals) {
    total = total + cents;
  }

  if (code !== "") {
    const discount = await findDiscount(code.toUpperCase());
    if (discount === undefined) {
      throw new Error(`Unknown discount code: ${code}`);
    }
    if (!discount.active) {
      throw new Error(`Code ${discount.code} is no longer active`);
    }
    total = total - discount.centsOff;
  }
  return total;
}

const cart: LineItem[] = [
  { name: "Notebook", priceCents: 1299, quantity: 2 },
  { name: "Pen", priceCents: 350, quantity: 0 },
  { name: "Mug", priceCents: 1500, quantity: 1 },
];

console.log(await checkout(cart, ""));
console.log(await checkout(cart, "spring10"));

const notYet = checkout(cart, "");
console.log(notYet instanceof Promise, await notYet);

try {
  await checkout(cart, "WINTER25");
} catch (err) {
  console.log("checkout failed:", (err as Error).message);
}

const twoPens: LineItem[] = [{ name: "Pen", priceCents: 350, quantity: 2 }];
console.log(await checkout(twoPens, "SPRING10"));

The first two lines match the trace: 4098 and 3098. The third line shows that checkout called without await gives back a promise (true), and that awaiting it gives the number. The fourth shows try and catch at work: the error thrown for the inactive code skipped the rest of checkout, landed in the catch block, and (err as Error).message read its text. as Error tells TypeScript to treat the caught value as an Error, since a throw can throw anything.

The last line is the one to compare with your prediction: -300. The customer would be owed three dollars for buying two pens. Nothing in the function stops the total at zero, and you found that by choosing a value at the edge, a cart cheaper than the discount. When you trace, pick one ordinary value and then one at a boundary: zero, empty, the smallest, the largest.

Tests and the shell

The agent's pull request came with one test. You add a second for the case you just found:

TypeScript
import { expect, test } from "bun:test";
import { checkout } from "./checkout";

test("a discount code takes money off", async () => {
  const cart = [{ name: "Notebook", priceCents: 1299, quantity: 2 }];
  expect(await checkout(cart, "SPRING10")).toBe(1598);
});

test("a discount never makes the total negative", async () => {
  const cart = [{ name: "Pen", priceCents: 350, quantity: 2 }];
  expect(await checkout(cart, "SPRING10")).toBe(0);
});

Each test has a name, written as a sentence about what should be true, and a function that checks it. expect(x).toBe(y) is the check: it passes when x is exactly y and fails otherwise.

Then you run the tests in a terminal:

Output
$ bun test ./checkout.test.ts
bun test v1.3.6 (d530ed99)

checkout.test.ts:
 9 | test("a discount never makes the total negative", async () => {
10 |   const cart = [{ name: "Pen", priceCents: 350, quantity: 2 }];
11 |   expect(await checkout(cart, "SPRING10")).toBe(0);
                                                ^
error: expect(received).toBe(expected)

Expected: 0
Received: -300

      at <anonymous> (/home/you/shop/checkout.test.ts:11:44)
(fail) a discount never makes the total negative [22.23ms]

 1 pass
 1 fail
 2 expect() calls
Ran 2 tests across 1 file. [57.00ms]

The $ at the start of the first line is the prompt, the terminal's sign that it is waiting for you. You type what comes after it: the command bun test and the file to test. Every line after that is output. You never type the $.

To read a failure, find three things. The line of the test that failed, marked with ^ (the at line gives the same place as file, line and column). Expected, the value inside toBe(...), which is what the test author wanted. Received, the value inside expect(...), which is what the code actually produced. Here the code produced -300 where the test wanted 0, which is the bug you traced. The summary at the bottom says one test passed and one failed.

Reading a diff

You report the failure, and the agent sends a fix as a unified diff, the usual format for showing what changed in a file:

Diff
@@ -18,7 +18,7 @@ export async function checkout(items: LineItem[], code: string): Promise<number>
     if (!discount.active) {
       throw new Error(`Code ${discount.code} is no longer active`);
     }
-    total = total - discount.centsOff;
+    total = Math.max(0, total - discount.centsOff);
   }
   return total;
 }

The line starting @@ is the hunk header; a hunk is one changed region of the file. -18,7 means this region starts at line 18 of the old file and covers 7 lines; +18,7 says the same of the new file. The text after the second @@ names the function the change sits in. Below it, a line starting with - was removed, a line starting with + was added, and a line starting with a space is context: unchanged, shown so you can see where the change sits.

So the change is one line. Math.max(0, x) gives back whichever of its inputs is larger, so a negative total becomes 0 and any positive total is unchanged. Apply the method to the diff as well: trace the two pens through the new line, 700 minus 1000 is -300, the larger of 0 and -300 is 0, and the new test passes.

The SQL behind it

findDiscount reads from a database table, and placing an order writes to two tables. SQL is the language for asking a relational database for rows and for changing them. This block builds small SQLite tables and runs the shop's queries on them.

SQLRuns on SQLite in your browser
CREATE TABLE customers (id integer PRIMARY KEY, name text NOT NULL);
CREATE TABLE discount_codes (
  code        text PRIMARY KEY,
  cents_off   integer NOT NULL,
  active      integer NOT NULL,
  times_used  integer NOT NULL DEFAULT 0
);
CREATE TABLE orders (
  id             integer PRIMARY KEY,
  customer_id    integer NOT NULL REFERENCES customers(id),
  total_cents    integer NOT NULL,
  discount_code  text
);
INSERT INTO customers VALUES (1, 'Ana'), (2, 'Raj');
INSERT INTO discount_codes (code, cents_off, active) VALUES ('SPRING10', 1000, 1), ('WINTER25', 2500, 0);

-- What findDiscount asks for.
SELECT code, cents_off, active FROM discount_codes WHERE code = 'SPRING10';
SELECT count(*) AS rows_found FROM discount_codes WHERE code = 'spring10';

-- Placing an order: both changes happen, or neither does.
BEGIN;
INSERT INTO orders (customer_id, total_cents, discount_code) VALUES (1, 3098, 'SPRING10');
INSERT INTO orders (customer_id, total_cents, discount_code) VALUES (2, 4500, NULL);
UPDATE discount_codes SET times_used = times_used + 1 WHERE code = 'SPRING10';
COMMIT;
SELECT code, times_used FROM discount_codes;

-- Who used SPRING10?
SELECT customers.name, orders.total_cents
FROM orders JOIN customers ON customers.id = orders.customer_id
WHERE orders.discount_code = 'SPRING10';

Read a SELECT in the order the database works through it. FROM discount_codes picks the table. WHERE code = 'SPRING10' keeps only the rows where that condition is true. SELECT code, cents_off, active picks which columns to show. The first query finds the one matching row. A WHERE can join conditions: with AND a row must meet both, with OR either one.

The second query is the same lookup in lower case, and it finds nothing: count(*) counts the rows that survive the WHERE, and here that is 0. In SQLite and PostgreSQL, = on text compares exactly, character by character, so capitals matter. That is why checkout called toUpperCase() before the lookup. A trace that crosses from TypeScript into SQL has to carry the exact value across.

INSERT INTO orders (...) VALUES (...) adds a row, naming the columns and then the values for them in the same order. UPDATE discount_codes SET times_used = times_used + 1 WHERE code = 'SPRING10' changes existing rows: for each row the WHERE keeps, it sets times_used to its old value plus one. An UPDATE without a WHERE changes every row in the table, so always look for one.

BEGIN at top left leads down to a box, three writes, two inserts and one update. From there one arrow goes to COMMIT, kept, highlighted, and a dashed arrow goes to crash or ROLLBACK, discarded. Large text under the writes reads all three, or none.
Between BEGIN and COMMIT the two inserts and the update are provisional. COMMIT keeps all three; a crash or ROLLBACK discards all three.

BEGIN and COMMIT wrap those writes in a transaction. Until COMMIT, the changes are provisional. If the program crashes or calls ROLLBACK before then, the database discards all of them, so an order is never recorded without the code's count going up, or the other way round. The third result shows the count after COMMIT: SPRING10 used once, WINTER25 never.

The last query uses JOIN to combine two tables. For each order, ON customers.id = orders.customer_id finds the customer row whose id matches the order's customer_id, and the two are glued into one wider row, so you can show the customer's name beside the order's total. The WHERE then keeps only orders that used SPRING10, which is why Raj's order is missing from the result. A plain JOIN also drops any order whose customer_id matches no customer at all, which is worth remembering when a count comes out smaller than you expected.

Where to go next

If you are new to working with agents, read How Agents Work and How They Fail first. Otherwise start the course at Section 1, Intent, and trace the first piece of code it shows you before you read the paragraph that explains it.

Check your understanding

8 questions on this lesson. Pick an answer to see whether it holds and why.

  1. 1An agent wrote this shipping rule: free shipping on US orders of 50 dollars or more. What does shippingCents(5000, "us") return?
    TypeScript
    function shippingCents(totalCents: number, country: string): number {
      if (country === "US" && totalCents >= 5000) return 0;
      if (country === "US") return 599;
      return 1999;
    }
    Show the answer

    Answer C. === compares strings exactly, and "us" is a different string from "US", so both US branches are skipped and the function falls through to 1999. The tempting 0 comes from reading what the rule was meant to do; tracing the actual value shows the lower-case country fails both checks. Nothing throws: an unmatched string is just another value.

  2. 2What does the last line print?
    TypeScript
    const users = [
      { name: "Ana", admin: true },
      { name: "Raj", admin: false },
      { name: "Lee", admin: true },
    ];
    const labels = users.filter((u) => u.admin).map((u) => `${u.name} (admin)`);
    console.log(labels.length, labels[1]);
    Show the answer

    Answer A. filter keeps only Ana and Lee, so the new array has 2 elements, and map turns each into a label. Array positions start at 0, so labels[1] is the second label, Lee's. Picking Ana means counting from 1; picking Raj means forgetting that filter removed him before map ran.

  3. 3What does this print?
    TypeScript
    function parseQuantity(input: string): number {
      const n = Number(input);
      if (!Number.isInteger(n) || n < 1) throw new Error(`Bad quantity: ${input}`);
      return n;
    }
    
    let quantity = 1;
    try {
      quantity = parseQuantity("2.5");
      console.log("parsed");
    } catch (err) {
      console.log((err as Error).message);
    }
    console.log(quantity);
    Show the answer

    Answer B. Number("2.5") is 2.5, which is not a whole number, so parseQuantity throws. The throw happens before the function produces a value, so the assignment to quantity never runs and parsed never prints; the catch prints the message. quantity keeps the 1 it started with, because a let changes only when an assignment actually runs.

  4. 4Four orders and two customers. How many rows does the last query return?
    SQL
    CREATE TABLE customers (id integer PRIMARY KEY, name text);
    CREATE TABLE orders (id integer PRIMARY KEY, customer_id integer, status text);
    INSERT INTO customers VALUES (1, 'Ana'), (2, 'Raj');
    INSERT INTO orders VALUES
      (1, 1, 'shipped'), (2, 1, 'pending'), (3, 2, 'shipped'), (4, 9, 'shipped');
    
    SELECT customers.name, orders.id
    FROM orders JOIN customers ON customers.id = orders.customer_id
    WHERE orders.status = 'shipped';
    Show the answer

    Answer D. Orders 1, 3 and 4 are shipped, but a plain JOIN keeps an order only when some customer's id matches its customer_id. No customer has id 9, so order 4 drops out, leaving Ana with order 1 and Raj with order 3. Keeping order 4 with an empty name is what a LEFT JOIN does, and order 2 was removed by the WHERE.

  5. 5Account 1 starts with a balance of 100 and account 2 with 50. The server loses power where the comment says. After the database restarts, what are the two balances?
    SQL
    BEGIN;
    UPDATE accounts SET balance = balance - 30 WHERE id = 1;
    UPDATE accounts SET balance = balance + 30 WHERE id = 2;
    -- power is lost here, before COMMIT
    Show the answer

    Answer A. Changes inside a transaction become permanent only at COMMIT. Power was lost before it, so the database discards both updates and the balances are back where they started. The tempting 70 and 80 assumes a statement that ran is saved; inside a transaction it is provisional until COMMIT, which is what keeps a transfer from being half done.

  6. 6An agent calls this change a small cleanup. Which invoice is charged a late fee after it that was not charged before?
    Diff
    @@ -40,6 +40,6 @@ function lateFeeCents(invoice: Invoice, today: string): number {
       const daysLate = daysBetween(invoice.dueOn, today);
    -  if (daysLate > 30) {
    +  if (daysLate >= 30) {
         return LATE_FEE_CENTS;
       }
       return 0;
     }
    Show the answer

    Answer B. Trace the boundary. At 30 days the removed line, 30 > 30, is false and the added line, 30 >= 30, is true, so that invoice now pays a fee. At 31 days both lines are true and at 29 both are false. A one-character change to a comparison changes behavior, so calling it a cleanup is wrong.

  7. 7You run the tests and get this failure. What does it tell you?
    Output
    11 |   expect(displayName({ given: "Ana", family: "Lima" })).toBe("Ana Lima");
                                                                  ^
    error: expect(received).toBe(expected)
    
    Expected: "Ana Lima"
    Received: "Lima, Ana"
    Show the answer

    Answer D. Received is the value passed to expect(...), which is what the function returned; Expected is the value in toBe(...), which is what the test wanted. The output alone cannot say which side is wrong; that depends on what the task asked for. Editing the test to match the received value makes it pass and fixes nothing if the task wanted Ana Lima.

  8. 8An agent wrote this to split a restaurant bill evenly. You trace splitBill(1000, 3), three people sharing a 10-dollar bill. What do you find?
    TypeScript
    // Math.floor rounds a number down to the whole number below it.
    function splitBill(totalCents: number, people: number): number {
      return Math.floor(totalCents / people);
    }
    Show the answer

    Answer C. 1000 / 3 is 333.33..., and Math.floor rounds it down to 333. Three shares of 333 add to 999, so one cent of the bill is never paid. An even split such as splitBill(1000, 4) would have looked fine; the uneven value is the one that exposes the defect. Division with a remainder does not throw in JavaScript.