Think like a programmer Foundation
Reasoning about programs
Ask what you know, what you assume and what must stay true, build edge-case tables, and use invariants to show a program works for every input, not just a few.
BY THE END OF THIS LESSON YOU CAN
- Separate what you know from what you assume about a problem
- Write the conditions that must be true before and after an algorithm runs
- Build an edge-case table and turn it into tests
- Find an invariant and check it after every step
- Argue that an algorithm works for all inputs, and back the argument with many automatic checks
The bill that did not add up
Five friends eat at a restaurant in Lekki. The bill is ₦25,000. One of them writes a tiny program to split it:
const total = 25000;
const people = 5;
const share = total / people;
console.log("Each person pays ₦" + share);
node split-v1.js and of the browser terminalEach person pays ₦5000
It works, so she shares it with her colleagues. The next week three of them split a ₦10,000 bill:
const total = 10000;
const people = 3;
const share = total / people;
console.log("Each person pays ₦" + share);
node split-v1-three.js and of the browser terminalEach person pays ₦3333.3333333333335
Nobody can pay a third of a kobo. Each of them pays ₦3,333, the restaurant receives ₦9,999, and the waiter has to chase ₦1. Then someone tries 0 people by mistake, and the program says each person pays Infinity.
No error message appeared at any point. The program did exactly what it was told. What was missing was reasoning: sitting down, before and after writing the code, and asking what could be true, what must be true, and what happens when it is not. Tests catch the cases you try. Reasoning finds the cases you did not think to try, and it is the only way to be confident about all the inputs you will never see. This lesson gives you a method for it.
The reasoning checklist
Here are the questions experienced programmers ask, often without saying them out loud. You have met most of them already in this module; this is the full list, in order:
- What do I know? The facts given: the inputs, the rules, the output wanted.
- What don't I know? The questions the problem does not answer.
- What am I assuming? The things you are treating as true without being told.
- What must be true before it runs? The conditions on the input for the algorithm to make sense.
- What if they are false? What the program does when those conditions fail.
- Where are the boundaries? The values where the behaviour changes.
- What could go wrong? Everything else: odd input, rounding, things happening at the same time.
- Can I show it works? An argument that it is right for every input, backed by tests.
The rest of the lesson goes through them with the bill splitter, then with a second problem, a concert ticket seller.
Knowns, unknowns and assumptions
Start by writing down what you actually know about the bill splitter:
- The input is a total in naira and a number of people.
- The output is what each person pays.
- The shares must add up to the total. (Nobody said this, but the restaurant will insist.)
Now what you don't know. Each of these is a question to ask, not a thing to guess:
- Is the total always a whole number of naira, or can it have kobo?
- If the total does not divide evenly, who pays the extra? Can shares differ at all?
- Is a service charge or tip included, or added on top?
- Where do the inputs come from: typed by a person, or calculated by another program?
And then the assumptions: things the first program treated as true without anyone saying so. It assumed the division always comes out even. It assumed people is at least 1. It assumed both inputs are numbers. None of these was written down, so nobody checked them, and each became a bug.
The rule is simple: every assumption must either be checked by the code or confirmed by a person, and written down either way. An assumption nobody wrote down is a bug waiting for the right input.
REASON IT OUT
Knowns and unknowns for a booking rule
A guest house in Calabar has this rule: "Guests can cancel for free up to 48 hours before check-in." You are asked to write the check. Before any code, list: what do you know, what don't you know, and what would you be tempted to assume?
Show the reasoning
- Known: there is a check-in time and a cancellation time. If the gap is at least 48 hours, cancelling is free.
- Unknown: is exactly 48 hours free or not ("up to" suggests yes, but ask)? What is the check-in time: 2 pm, or midnight of the check-in day? Which time zone, if a guest in London cancels a room in Calabar? What happens after the deadline: full charge, or one night?
- Tempting assumptions: that check-in is at midnight; that everyone is in the same time zone; that the booking cannot be changed (if the guest moves the check-in date, which date counts?).
Every one of these changes the answer for some real guest. The one-line rule hid at least five decisions. Writing them down, and asking the owner, is most of the work.
Conditions that must be true
Once the questions are answered, write the rules down as conditions. Two kinds matter most:
- A precondition must be true before the algorithm runs, or its answer means nothing. For the bill splitter:
peopleis a whole number, at least 1;totalis a whole number of naira, 0 or more. - A postcondition must be true after it runs, if the preconditions held. For the bill splitter: every share is a whole number of naira; no two shares differ by more than ₦1; the shares add up exactly to the total.
The owner answered the unknowns: totals are whole naira, and when the bill does not divide evenly, the first few people pay ₦1 more. Now the design follows from the postconditions. Divide and round down to get the basic share; what is left over is the remainder, and that many people pay ₦1 extra.
Two tools do this. Math.floor(x) rounds a number down to the whole number below it. %, which you met in Pseudocode and flowcharts, gives the remainder:
console.log(Math.floor(10000 / 3));
console.log(10000 % 3);
console.log(3333 * 3 + 1);
node floor.js and of the browser terminal3333 1 10000
₦10,000 among 3 is ₦3,333 each with ₦1 left over, and the last line checks it: three shares of ₦3,333 plus the ₦1 remainder is the whole bill. So one person pays ₦3,334 and two pay ₦3,333. The postcondition "shares add up to the total" holds: 3,334 + 3,333 + 3,333 = 10,000.
What if a precondition is false?
For each precondition, decide what happens when it fails. Usually the answer is: refuse, with a message that says what is wrong. Never carry on and produce a number that looks like an answer, like Infinity or NaN. Here is the splitter with its preconditions checked first:
const total = 10000;
const people = 3;
if (people < 1) {
console.log("Refused: need at least 1 person");
} else if (total < 0) {
console.log("Refused: the total cannot be negative");
} else if (total % 1 !== 0 || people % 1 !== 0) {
console.log("Refused: use whole numbers");
} else {
const base = Math.floor(total / people);
const extra = total % people;
let sum = 0;
for (let person = 1; person <= people; person++) {
let share = base;
if (person <= extra) {
share = base + 1;
}
sum = sum + share;
console.log("Person " + person + " pays ₦" + share);
}
console.log("Shares add up to ₦" + sum);
}
node split-v2.js and of the browser terminalPerson 1 pays ₦3334 Person 2 pays ₦3333 Person 3 pays ₦3333 Shares add up to ₦10000
total % 1 !== 0is true when a number has a fractional part: dividing by 1 leaves the part after the decimal point. It checks "is a whole number".||means "or": the condition is true if either side is true. You will study it properly in Boolean logic.- The loop gives each person
base, and the firstextrapeople one naira more. - The last line prints the sum of the shares, so the output shows the postcondition holding, instead of asking you to trust it.
Boundaries and edge-case tables
An edge case is an input at the edge of what is allowed: the smallest, the largest, the empty, the value where a rule switches. Most bugs live there, because the normal cases are the ones everybody tests. An edge-case table lists them with the reason each is interesting and the expected result, worked out before running anything.
| Total | People | Why it is interesting | Expected |
|---|---|---|---|
| 25000 | 5 | Divides evenly: the normal case | 5000 each |
| 10000 | 3 | Remainder 1 | 3334, 3333, 3333 |
| 10001 | 3 | Remainder 2: the largest remainder for 3 people | 3334, 3334, 3333 |
| 2 | 3 | Total smaller than the number of people | 1, 1, 0 |
| 0 | 4 | Nothing to pay | 0 each |
| 7500 | 1 | One person pays everything | 7500 |
| 5000 | 0 | Precondition broken | Refused |
| -100 | 2 | Precondition broken | Refused |
| 1000.5 | 2 | Not whole naira | Refused |
The row "₦2 among 3 people" is worth a moment. Someone pays ₦0. Is that right? The algorithm is consistent, and the postconditions hold. Whether the owner wants a ₦0 share printed is a product question, and the table is what made you notice it.
Now turn the table into a test. Each row becomes one run, using two lists in the same order. Instead of printing every share, the test checks the postconditions itself and prints a verdict:
const totals = [25000, 10000, 10001, 2, 0, 7500, 5000, -100, 1000.5];
const peopleList = [5, 3, 3, 3, 4, 1, 0, 2, 2];
for (let i = 0; i < totals.length; i++) {
const total = totals[i];
const people = peopleList[i];
if (people < 1 || total < 0 || total % 1 !== 0 || people % 1 !== 0) {
console.log(total, "/", people, "-> refused");
} else {
const base = Math.floor(total / people);
const extra = total % people;
let sum = 0;
let shares = "";
for (let person = 1; person <= people; person++) {
let share = base;
if (person <= extra) {
share = base + 1;
}
sum = sum + share;
shares = shares + share + " ";
}
console.log(total, "/", people, "->", shares + "| adds up:", sum === total);
}
}
node split-test.js and of the browser terminal25000 / 5 -> 5000 5000 5000 5000 5000 | adds up: true 10000 / 3 -> 3334 3333 3333 | adds up: true 10001 / 3 -> 3334 3334 3333 | adds up: true 2 / 3 -> 1 1 0 | adds up: true 0 / 4 -> 0 0 0 0 | adds up: true 7500 / 1 -> 7500 | adds up: true 5000 / 0 -> refused -100 / 2 -> refused 1000.5 / 2 -> refused
Every row matches the table. Notice how the tests came from the reasoning, not from guessing: each precondition produced a test that breaks it; each boundary (remainder 0, 1, 2; total smaller than people; one person) produced a test on it; and the postcondition is checked in every row.
Invariants: what never changes
Some conditions are not just true at the start or the end, but at every step. Such a condition is called an invariant. You met one in Breaking problems down: a transfer moves money between accounts, so the total money in the bank never changes. Invariants are powerful because you can check them after every single step, and the moment one breaks, you know exactly which step broke it.
A ticket seller
A concert hall in Abuja has 500 seats. Requests arrive to buy tickets, sometimes for one person, sometimes for a group. Two invariants must hold after every sale:
sold + available === 500: seats are never created or lost.sold <= 500: the hall is never oversold.
Here is a first version. It checks that there are seats left before selling, then checks both invariants after every request:
const capacity = 500;
let sold = 496;
let available = 4;
for (const request of [2, 3, 1]) {
if (sold < capacity) {
sold = sold + request;
available = available - request;
console.log("Sold", request, "-> sold", sold, "available", available);
} else {
console.log("Sold out, refused", request);
}
if (sold + available !== capacity || sold > capacity) {
console.log("INVARIANT BROKEN after request for", request);
}
}
node tickets-v1.js and of the browser terminalSold 2 -> sold 498 available 2 Sold 3 -> sold 501 available -1 INVARIANT BROKEN after request for 3 Sold out, refused 1 INVARIANT BROKEN after request for 1
The first invariant held (501 + -1 is still 500), but the second broke: 501 tickets for 500 seats. It is still broken after the next request, even though that request was refused: once the state is wrong, it stays wrong, which is why the first broken message is the one to look at. The check sold < capacity asked "is there at least one seat?" when the question was "are there enough seats for this whole group?". The invariant pointed at the exact request that caused it. The fix is the right precondition for each sale, request <= available:
const capacity = 500;
let sold = 496;
let available = 4;
for (const request of [2, 3, 1, 1, 1]) {
if (request <= available) {
sold = sold + request;
available = available - request;
console.log("Sold", request, "-> sold", sold, "available", available);
} else {
console.log("Not enough seats for", request, "- only", available, "left");
}
if (sold + available !== capacity || sold > capacity) {
console.log("INVARIANT BROKEN after request for", request);
}
}
node tickets-v2.js and of the browser terminalSold 2 -> sold 498 available 2 Not enough seats for 3 - only 2 left Sold 1 -> sold 499 available 1 Sold 1 -> sold 500 available 0 Not enough seats for 1 - only 0 left
No invariant message, all the way to a full hall and one refusal after it. The group of 3 was refused while seats remained, which is correct; the owner might like a smarter message ("only 2 left, buy 2?"), but that is a feature, not a bug.
Invariants inside a loop
Invariants also explain why an algorithm works. Take "best so far" from Algorithms. Its invariant is: after looking at some items, biggest is the biggest of the items looked at so far.
- It is true at the start. Before the loop,
biggestis the first item, and the first item is the biggest of a list containing only itself. - Each step keeps it true. When the next item arrives, either it is bigger than
biggest(and becomesbiggest), or it is not (andbiggeststays). Either way,biggestis again the biggest of everything seen. - So it is true at the end. When the loop finishes, "everything seen" is the whole list.
This three-step argument is how you show a loop is correct for every list, not just the ones you tried: true at the start, kept true by each step, therefore true at the end. It also shows exactly why starting at 0 was wrong: with all-negative profits, "0 is the biggest of the items seen so far" is false from the very beginning.
You can print the invariant at each step and watch it hold:
const sales = [4500, 12000, 800, 15500];
let biggest = sales[0];
let seen = "";
for (const amount of sales) {
if (amount > biggest) {
biggest = amount;
}
seen = seen + amount + " ";
console.log("seen:", seen + "| biggest so far:", biggest);
}
node invariant-trace.js and of the browser terminalseen: 4500 | biggest so far: 4500 seen: 4500 12000 | biggest so far: 12000 seen: 4500 12000 800 | biggest so far: 12000 seen: 4500 12000 800 15500 | biggest so far: 15500
Can I show it works?
A test shows that a program works for the inputs you tried. It says nothing about the inputs you did not try. There are two ways to get further, and good programmers use both.
An argument
Here is why the bill splitter's shares always add up, for any valid total and number of people. Division with a remainder always satisfies total = base × people + extra, with extra smaller than people. That is what "divide and keep the remainder" means: 10,000 = 3,333 × 3 + 1. The algorithm gives every person base (that is base × people in all) and gives extra people one more naira (another extra). So the shares add up to base × people + extra, which is the total. And since extra is smaller than people, there are always enough people to hand the extra naira to.
That argument covers every input at once. No test, however many you write, can do that.
Many automatic checks
Arguments can contain mistakes too. So back them with tests on far more inputs than you would ever write by hand. Two loops, one inside the other, can try every total from 0 to 100 with every group size from 1 to 10, and check both postconditions on each of those 1,010 cases:
let checked = 0;
let failed = 0;
for (let total = 0; total <= 100; total++) {
for (let people = 1; people <= 10; people++) {
const base = Math.floor(total / people);
const extra = total % people;
let sum = 0;
let smallest = base + 1;
let largest = 0;
for (let person = 1; person <= people; person++) {
let share = base;
if (person <= extra) {
share = base + 1;
}
sum = sum + share;
if (share < smallest) {
smallest = share;
}
if (share > largest) {
largest = share;
}
}
checked = checked + 1;
if (sum !== total || largest - smallest > 1) {
failed = failed + 1;
console.log("FAILED:", total, "among", people);
}
}
}
console.log("Checked", checked, "cases,", failed, "failed");
node split-many.js and of the browser terminalChecked 1010 cases, 0 failed
Instead of choosing expected outputs one by one, this test checks properties that must hold for every input: the shares add up to the total, and no two differ by more than ₦1. This style is called property-based testing, and there are libraries that generate thousands of random inputs for you. The idea is the one you just used: reason out what must always be true, then check it everywhere.
What could go wrong
The last question on the checklist is the widest. These are the answers that come up again and again:
Decimals that are not exact
Computers store most decimals approximately. Money calculated in naira with kobo as decimals picks up tiny errors:
const price = 0.1;
const fee = 0.2;
console.log(price + fee);
console.log(price + fee === 0.3);
console.log(10 + 20 === 30);
node decimals.js and of the browser terminal0.30000000000000004 false true
Whole numbers are exact. That is why the bill splitter insists on whole naira, and why banks store amounts in kobo, as whole numbers. Mathematical reasoning covers this properly.
Input that breaks an unchecked assumption
If people arrives from a form, it arrives as text: "3". 10000 / "3" happens to work, because division converts the text, but "3" + 1 is "31". Any calculation that uses + on it goes wrong silently. The precondition "is a whole number" should be checked on the converted value, not assumed.
Off by one
Loops that start at 1 instead of 0, or stop at < instead of <=, are wrong by exactly one item. In the splitter, person <= people is right because person counts from 1. Tracing the first and the last round of a loop by hand catches most of these.
Things happening at the same time
Two customers try to buy the last concert ticket at the same moment. Both requests check request <= available, both see 1 seat, both succeed, and 501 tickets are sold. The invariant is right and the check is right, but they were run by two requests at once. This is called a race condition, and you cannot see it by tracing one request. The reasoning question that finds it is: "what if this happens twice, at the same time?". Databases solve it with transactions and locks, which you will use in the backend lessons.
Reasoning is not optional on a server
A bill splitter with a bug costs a waiter ₦1. A ticket seller with a race condition sells seats that do not exist, and a bank with one creates money. The more users and the more money a program handles, the more of this checklist you must answer before you ship.Practice
TRY IT YOURSELF
Old enough to open an account?
A bank's form asks for a customer's birth year only, and the rule is "you must be 18 or older". Go through the checklist: what do you know, what don't you know, what would the obvious code assume? Then write an edge-case table for a check done in 2026.
Show a solution
- Known: the birth year and the current year, 2026.
- Unknown: the birthday. Someone born in 2008 is 18 in 2026 only after their birthday; before it, they are 17. With the year alone, the question cannot always be answered.
- The obvious code assumes that
2026 - birthYearis the age. For 2008 that says 18, which is wrong for about half of those customers for part of the year.
| Birth year | 2026 − year | Real age in 2026 | Safe decision |
|---|---|---|---|
| 2007 | 19 | 18 or 19 | Allow |
| 2008 | 18 | 17 or 18 | Cannot tell: ask for the full date |
| 2009 | 17 | 16 or 17 | Refuse |
| 2030 | -4 | Not born | Refuse: invalid input |
const thisYear = 2026;
for (const birthYear of [2007, 2008, 2009, 2030]) {
const difference = thisYear - birthYear;
let decision = "";
if (difference < 0) {
decision = "invalid birth year";
} else if (difference >= 19) {
decision = "allow";
} else if (difference === 18) {
decision = "ask for the full date of birth";
} else {
decision = "refuse";
}
console.log(birthYear, "->", decision);
}
node age.js and of the browser terminal2007 -> allow 2008 -> ask for the full date of birth 2009 -> refuse 2030 -> invalid birth year
The real fix is not in the code: it is to ask for the full date of birth. Reasoning showed that the input cannot support the rule. That is a finding to take back to the people who designed the form.
TRY IT YOURSELF
A stock count that goes wrong
A shop starts the day with 20 bags of rice. Through the day, events arrive: a positive number is a delivery, a negative number is a sale. Two invariants should hold after every event: the stock is never negative, and the stock equals the starting stock plus all deliveries minus all sales. Write a loop that processes [5, -12, -8, -7, 10], tracks the stock, and checks the invariants after each event. What does it find, and what should the shop do about it?
Show a solution
const start = 20;
let stock = start;
let delivered = 0;
let soldTotal = 0;
for (const change of [5, -12, -8, -7, 10]) {
stock = stock + change;
if (change > 0) {
delivered = delivered + change;
} else {
soldTotal = soldTotal - change;
}
console.log("change", change, "-> stock", stock);
if (stock < 0) {
console.log("INVARIANT BROKEN: negative stock");
}
if (stock !== start + delivered - soldTotal) {
console.log("INVARIANT BROKEN: stock does not match the records");
}
}
node stock.js and of the browser terminalchange 5 -> stock 25 change -12 -> stock 13 change -8 -> stock 5 change -7 -> stock -2 INVARIANT BROKEN: negative stock change 10 -> stock 8
The records are consistent (the second invariant always holds), but after the fourth event the shop sold 7 bags when it had only 5. The invariant pinpoints the event. The fix is a precondition on every sale: refuse it when -change > stock, exactly like the ticket seller. Note that soldTotal = soldTotal - change subtracts a negative number, which adds.
TRY IT YOURSELF
Split a bill with a service charge
A restaurant adds a 10% service charge to the bill before it is split. Write the preconditions and postconditions, then implement it for a bill of ₦23,000 among 4 people. Assume the charge is always a whole number of naira (the restaurant rounds it down). Check that the shares add up.
Show a solution
- Preconditions: the bill is a whole number, 0 or more; people is a whole number, at least 1.
- Postconditions: charge is 10% of the bill, rounded down; the shares add up to bill + charge; no two shares differ by more than ₦1.
const bill = 23000;
const people = 4;
const charge = Math.floor(bill / 10);
const total = bill + charge;
const base = Math.floor(total / people);
const extra = total % people;
let sum = 0;
for (let person = 1; person <= people; person++) {
let share = base;
if (person <= extra) {
share = base + 1;
}
sum = sum + share;
console.log("Person " + person + " pays ₦" + share);
}
console.log("Total with charge: ₦" + total, "| adds up:", sum === total);
node split-service.js and of the browser terminalPerson 1 pays ₦6325 Person 2 pays ₦6325 Person 3 pays ₦6325 Person 4 pays ₦6325 Total with charge: ₦25300 | adds up: true
The order is a decision: the charge is on the bill before splitting, not added to each share. (10% of each share, rounded down, could lose up to ₦1 per person.) Try a bill of ₦23,005: the charge is ₦2,300, the total ₦25,305, and the remainder 1 goes to person 1.
Recap
- Ask the checklist: what do I know, what don't I know, what am I assuming, what must be true, what if it is false, where are the boundaries, what could go wrong, can I show it works?
- Every assumption is either checked by the code or confirmed by a person, and written down.
- Preconditions must hold before the algorithm runs; when they fail, refuse clearly. Postconditions must hold after it runs; print or check them instead of trusting them.
- An edge-case table lists the smallest, largest, empty and switching inputs with their expected results, and turns directly into tests.
- An invariant holds after every step. Check it in every round and it points at the exact step that broke it. "True at the start, kept by each step, so true at the end" is how you show a loop is correct.
- Tests cover the inputs you tried; an argument covers all of them. Use both, and check properties over many inputs automatically.
Next: Boolean logic, the true/false reasoning inside every condition you have written in this module.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.