Software Construction
Your program works. You ran it, it did the thing, and you were right to feel good about that. Then somebody else opens it in March, changes one number, and three unrelated parts stop working. This course is about the distance between code that runs and code that can be changed by a stranger without fear.
Every idea here is something you do rather than something you read. You write assertions and watch them pass or fail against a function you cannot see inside. You fill a map of an input space with your own test cases and find the corners you missed. You break a data type's private promise on purpose, then install the tripwire that catches it. The JavaScript you write runs on an interpreter that watches every statement, so a loop that never ends is stopped and explained rather than left to spin.
You can write a little JavaScript: variables, loops, functions, objects and lists. That is the whole prerequisite. Nothing about testing, specifications or design is assumed, and the words are all introduced where they first appear. If you have written a program and watched somebody else struggle to read it, you are exactly the right reader.
One course comes before this one: Writing an Application.
These are the parts of it this course leans on, with the step that teaches each.
Step 2 introduces say(), the word this
site uses for printing a line.
Step 3 makes a named box with let and
const.
Step 5 chooses between two paths with if
and else, and compares with === and >=.
Step 6 counts round a for loop.
Step 7 holds many things in a list, takes one back
out by its position, counts them with .length and adds one with push.
Step 8 declares a function, calls it, and hands an
answer back with return.
Step 9 keeps named values together in an object and
reaches inside one with a dot.
You do not have to go and read that first. Every one of those words is explained again here, in a sentence or two, at the first place this course uses it. The links above are for when the short version turns out not to be enough.
The steps
Why working code rots
Code you have just written is the easiest code in the world to change. The whole of it is in your head: every constant, every assumption, every place a number happens to appear twice. None of that survives a month, and none of it was ever in anybody else's head at all.
So "does it work" turns out to be the small question. The larger one is what happens the first time somebody changes it, which for any code worth keeping is roughly always. Below is a shop's order module, written twice. Both versions give identical answers today.
That module is JavaScript, and a few of its words carry the whole course.
function shipping(order) { ... } declares a function: a named set of steps, with
order a slot for a value handed in from outside. Writing shipping(myOrder) calls
it, and myOrder fills that slot for that one run. The slot is a parameter and the value
put into it is an argument. return ends the function there and hands one value back to
whoever called it. That is not the same as printing: a returned value can be stored, added to and tested,
while printed text can only be read. let sum = 0 makes a named box holding 0 and a
let box can be refilled later, while const makes one that keeps whatever it was
first given. if (sum >= 30) { ... } runs the part in curly brackets only when the question is
true, and else says what to do when it is not. >= is greater than or equal to,
so 30 itself counts, and > would leave it out; <= is the same idea the other
way round, less than or equal to. And the dot in line.price means
"reach inside this thing and name one part of it".
What "a module" means here
A module is a group of functions that belong together and share a few rules. In this course a module is short enough to read on one screen. In a real project it might be a file, a class, or a folder with thirty files in it.
The size changes and the question does not: how much of this do I have to understand before I can change one line without breaking something I have not looked at.
What is a list, and what is that order?
A list holds many values one after another, in one box. Square brackets make one:
let prices = [14, 3, 9]. Square brackets also take one back out by its position, and
positions are counted from 0, so prices[0] is 14 and prices[1] is 3.
prices.length is how many items there are, 3 here, which means the last position is always
one less than the length. prices.push(7) adds 7 to the end and leaves the list one longer.
The order handed to each function in the lab below is a list, with one item in it per
line of the order. Lists are what Steps 6 and 7 spend all their time on, because two names can end up
holding the same list without either of them saying so.
What does line.price mean?
An object keeps several named values together as one thing. Curly brackets make one, with a name, a
colon and a value for each part: { price: 14, qty: 2 }. The names are called keys and
what sits after each colon is that key's value.
The dot reaches in and names one of them. If line holds that object, then
line.price is 14 and line.qty is 2, and line.qty = 3 puts a new
value in that one part. So every line of the order is an object, and line.price * line.qty
is what that line costs.
What does for (let line of order) do?
It runs the part in curly brackets once for every item in order, and each time round
line holds the next item. Four lines in the order means the body runs four times, which is
how one short loop totals up an order of any length without anybody knowing in advance how long it is.
There is a second way to write a loop, and you will need it later:
for (let i = 0; i < order.length; i = i + 1). Start i at 0, keep going while
it is under the length, add one to it each time round. i is short for index, meaning the
position and the item at that position is order[i]. Reach for this one when you need the
position itself, as you will in Step 9, where a check has to compare each item with the one before it.
Both modules pass every test the shop has today. They differ in what it costs to change them, and that cost is invisible until somebody tries. Most of what follows in this course is ways of making that cost visible early, while it is still cheap.
30 in the delivery function, change it, run the app, and the delivery charge is right.
A week later the banner on the basket page still says "spend 2 more for free delivery" when the basket
is at 28. What happened?Saying what a function promises
A function is a deal between two people who never meet: whoever calls it, and whoever wrote it. Nearly all the pain in shared code comes from that deal living in one person's head and nowhere on the page.
Written down, the deal is called a specification, or a spec in everyone's mouth, and it has two halves. The precondition is what has to be true before the call, and keeping it is the caller's job. The postcondition is what will be true when the call returns, and keeping that is the implementation's job. Splitting them apart is what makes blame answerable.
// find(sorted, target) // requires: sorted is a list of numbers, in increasing order // effects: returns an index i where sorted[i] is target, // or -1 if target is not in the list
That comment is a spec. The line marked requires: is the precondition and the line marked
effects: is the postcondition, and from the next lab onwards you will see a third line,
modifies:. modifies: nothing is a promise that the function will not change
anything you handed it: the list you passed in comes back exactly as it went in. Leaving the line out
promises nothing at all. Write the line even when the function modifies nothing. Step 6
is about what goes wrong when a function quietly modifies something and never said so.
Where a spec actually lives, in a real project
In a comment above the function, in a documentation string the language pulls out into a manual, in the types of the parameters, or in the tests. Usually in several of those at once, which is fine as long as they agree.
The format matters far less than one property: somebody who wants to call the function has to be able to find out what it needs and what it promises without reading the body. A promise that only exists inside the implementation is a description of today's code, and descriptions go out of date silently.
What is that code actually doing?
Guessing the middle, over and over. lo and hi mark the stretch of the list
the target could still be hiding in, and they start as the whole thing. mid is the position
halfway between them; Math.floor just rounds down, because there is no position 2.5. If the
item sitting at mid is the target, done. If it is too small, then everything from
lo up to mid is too small as well, so lo jumps past the lot in one
move. If it is too big, hi comes back the same way. Either way half of what was left
disappears each time round, so a list of a thousand items is finished in ten guesses instead of a
thousand.
while (something) { ... } is a loop with no counter in it: do the body again for as long
as the condition is still true. Here it says "keep halving while there is any stretch left to search".
And every bit of that depends on the list being in order: jumping past everything below mid
is only safe if everything below mid really is smaller. That is precisely what the
precondition is buying, and Step 9 shows you what happens to this same search when the order quietly
stops holding.
What are those three equals signs asking?
=== asks whether two things are exactly the same, and the answer is true or
false. So sorted[mid] === target asks "is the item at position
mid the very thing we are looking for". !== is the opposite question, and
< and > ask smaller and larger in the way they do in maths.
Keep all of those apart from = on its own, which asks nothing at all:
lo = mid + 1 is an instruction to put a new value in the box called lo. Asking and
putting are two different acts that look nearly identical on the page. Step 10 is about how much harder
"exactly the same" becomes once the two things being compared are lists.
Say the precondition another way
A precondition is something the function is given, rather than something it checks. "Requires: the list is sorted" means: if you hand me an unsorted list, I owe you nothing, and anything at all may happen, including a plausible wrong answer that you do not notice for a year.
That sounds harsh, and it buys something real. A function that has to cope with every possible input is bigger, slower and harder to reason about than one that is allowed to assume. The cost is that somebody has to make the precondition true. That somebody has to be told.
What is say(), and is it real JavaScript?
say(something) prints one line into the output panel under the editor, and that is the
whole of what it does. It is not part of JavaScript. It is a word this site adds to the interpreter, the
program that runs your code on this page, because it is shorter to read than the real one. In a browser
you would write
console.log(something) instead and nothing else would change.
Printing is not returning, and the difference matters from here on. say(x) puts
x somewhere you can read it and the program cannot use it afterwards, while
return x hands x back to whoever called the function, to store or add or test.
A function that says its answer instead of returning it looks as though it works and cannot be built on,
and none of the checks in Part 2 can see it. The other page word in the next two labs is
check, which records one comparison and carries on; Step 3 is where it is explained.
check. Write the
body so the assertions agree with the spec, then change the spec instead and watch which assertions
stop making sense. Three other specs are behind the buttons, including one where the answer is right
and the deal is broken anyway.requires: n is at least 1. Someone calls it with 0 and
gets back a nonsense answer instead of an error. Who is at fault, and what is the honest fix?From “I think it works” to evidence
"I think it works" is a feeling about code. Evidence is a list: inputs you chose, the answer you expected for each one, and what the machine actually gave you. A single line of that list is called an assertion and a pile of them is a test suite.
You write one like this: check(what you got, what it should be,"a name for the case"). The
first part is a value your program has just computed, almost always a call to the function you are testing.
The second is what you say it ought to be, worked out by you, by hand, from the spec. Never by running the
code and copying down what came out: that writes down a fact about today rather than a promise. The third
is a name, so that when it fails you know which case broke. If the first two disagree the
case is marked failed and shown to you, and the program carries on to the next line.
Here is the function the next three steps work on. You get its spec and not its body, which is the situation you are usually in, and the better situation to test from: you are checking the promise, not the code that happens to implement it today.
// fee(weight, distance) // requires: weight and distance are numbers, both at least 0 // effects: returns what the courier charges, in pounds // // The shop's rules, in the shop's words: // a parcel up to 1 kg going up to 5 km travels free // everything else starts at 3 // over 20 kg adds 5 // over 100 km adds 4 // over 20 kg and over 100 km adds a further 2 on top
Why am I not allowed to see the code
Because tests written while looking at the body tend to test the body. You see a loop and write a case that walks the loop; you see three branches and write three cases. What that misses is the behaviour the code was supposed to have and does not, because there is nothing on screen to remind you of it.
Testing from the spec alone is called black box testing, and it is the kind that can catch a missing rule. The other kind, where you read the code and make sure every line runs at least once, is called glass box testing. It is a useful second pass rather than a replacement.
What check() is, and what it is called in real projects
check(what you got, what it should be,"a name") records one assertion and moves on. If
the two disagree, the case is marked failed and shown to you. That is the whole idea, and every testing
tool in every language is a dressed-up version of it.
The spellings differ. You will meet assertEquals(want, got) in Java,
assert got == want in Python, and expect(got).toBe(want) in JavaScript.
Different words, same three parts: a value you computed, a value you expected, and a name so you know
which one broke.
An assertion earns its place only if there is a version of the code that it would catch. Otherwise it is a comment that costs time to run. The next lab makes that concrete: your suite has to pass against the real function and fail against a sabotaged one, or it does not count.
Choosing the cases that matter
Two numbers, each of them free to be anything. You could test for a hundred years and cover none of that space. Testing everything is available only for functions so small that you would not bother.
What you can do instead is carve the space into regions, where the function is supposed to behave
the same way everywhere inside a region, then take one case from each region. Then take the boundaries
between the regions as well, because a boundary is where somebody typed > and meant
>=. How much of that carving your cases have actually visited is called your
coverage. Nine regions with at least one case in each is full coverage of the regions. Be precise
about what that buys you. It is a fact about where you looked, and it says nothing whatever about whether
what you saw was right.
How you find the regions when nobody hands them to you
Read the spec and mark every phrase that changes the answer. In the delivery rules those are "up to 1 kg","up to 5 km","over 20 kg" and "over 100 km". Each phrase cuts one axis into bands, and the regions are every combination of bands.
Two axes with three bands each gives nine regions. It grows quickly, which is the point: nine is already more cases than most people write by instinct, and every one of the nine is somewhere the function might behave differently from all the others.
"Input space" sounds grand. What is it?
Every possible call you could make to the function, laid out as a shape. A function of one number has a line of inputs. A function of two numbers has a flat sheet, one axis each, and a particular call is a dot somewhere on it. A function taking three things has a solid, and after that the picture gives up while the idea keeps working.
The map in the lab below is that sheet: weight up the side, distance along the bottom, one dot per test case. The lines drawn across it are the places where the shop's rules change what happens.
fee is recorded, and the region is
worked out from the arguments that actually arrived. Assertions that fail do not count as coverage,
because a suite you have to ignore is not covering anything.> and >= is invisible
at 10 and at 30 and visible only at 20, on that single value. Fifteen tells you what 10 already told
you. A thousand is worth a case for a different reason, overflow and silly-value handling, but it will
not find this one.The bug your tests do not catch
A green suite means every case you thought of behaved the way you expected. It says nothing about the cases you did not think of, and that is where the bug is, because if you had thought of it you would have written the code differently in the first place.
Below are three functions with passing suites and hidden bugs. Your job each time is one assertion: it has to pass against the correct version and fail against the broken one. That is the exact definition of a test that pulls its weight.
Why "make it fail on the broken one" is the right standard
Deliberately breaking a function and checking that your suite notices has a name: mutation testing.
Tools do it automatically, changing a > to a >= or deleting a line, then
reporting which mutations nobody caught. Every uncaught mutation is a behaviour with no test behind it.
You can do the cheap version by hand any time. Break the code on purpose, run the suite, and if it stays green you have just learned something about your tests rather than about your code.
Round three: what was actually wrong with it?
Putting a list in order is something the language will do for you, and by default it does it as
though everything in the list were text. Text is compared character by character, the way a dictionary
orders words, so "10" comes before "2" for exactly the reason "apple" comes
before "b": the first character settles it. On single digits that gives the same answer as ordering by
size, every time, which is precisely why the bug can hide. The fix is to hand the sort a small function
of your own that says which of two numbers should go first.
The testing lesson is the one worth keeping, though. Three cases, all passing, all drawn without thinking from the same narrow shape, and a bug sitting just outside it. Ask it of any suite you write: what do all my cases have in common that they did not have to have? Single digits. All positive. All the same length. Never empty. Every one of those is a corner you have not looked in.
fee, both broken copies and
check, with no goal attached. The line underneath counts how many of the nine regions and
how many of the four boundary values your calls actually reached, so you can aim at the gaps and watch
the count move.Words that come from other people having jobs
Production is the copy of the software that real people are actually using, as against the copy on your own machine or the one the tests run against."It works on my machine and breaks in production" is the oldest complaint in the trade. The gap it describes is the reason a bug can be real and invisible to you at the same time. A codebase is just all of the code for one piece of software, taken together.
Three more you will meet on this page. A code review is somebody else reading your change before it is allowed in. A README is the file at the top of a codebase saying what the thing is and how to run it. A screenshot test takes a picture of the screen and complains when the next picture differs. None of those is enforced by the machine: the first two are people agreeing, which is exactly why Step 8 argues that hiding something in the code beats agreeing not to touch it.
Two names for one list
A number in a variable is the number itself. A list works differently: the variable holds a reference, which says where the list lives, and two variables can hold the same reference. Change the list through one name and the other name sees it, because there was only ever one list.
Two names for one object has a name of its own: it is called aliasing, and the two names are aliases for each other. It is the single most common source of bugs that make no sense. The code you are staring at is correct. Something on the other side of the program is holding the same list.
So how do you actually make a copy?
a.slice() builds a brand new list holding the same items as a and hands it
back. Two boxes now instead of one, so b.push(99) lands in b's box and a never hears about
it. The name is odd because slice can also be given a start and an end, to copy part of a
list. Called with nothing in the brackets it copies the lot, and that is the everyday way of saying
"give me my own list".
You could write it yourself: start an empty list, loop over the original, push each item onto the new
one. That is exactly what slice is doing for you. Worth knowing, because the copy is never
free: copying a million-item list really does do a million pieces of work. That is the whole reason the
language hands out arrows by default and makes you ask for the copy when you want one.
Why the language does it this way at all
Because a list can be enormous. Copying one every time it is passed to a function would make passing a million-item list a million-step operation, and most of the time you did not want a copy. So the language passes the reference, which is one small number, and copying becomes something you ask for.
The cost of that speed is that sharing is the default and it is invisible in the code. Nothing at the call site says whether the function you are handing your list to intends to change it. Only the spec can tell you, which is why specs say things like "modifies: nothing".
effects: returns the list sorted. It is implemented
with list.sort(), which sorts in place and returns the same list. Callers pass their own
lists in. What breaks, and when?Drawing what is really there
When aliasing bites, the way out is to stop reading and start drawing. A snapshot diagram is a picture of one moment in a running program: a small box for every name, an arrow from a name to the thing it refers to, and a box for every object that exists.
Two names with arrows into one box is a fact you can see. The same fact written as four lines of code is something you have to work out, and people work it out wrongly.
Two words for that, and you need both later. A shallow copy duplicates one level: a new outer list
holding the very same arrows the original held. A deep copy walks the whole structure and duplicates
everything it finds, all the way down to the bottom. slice() is shallow, which is why the inner
lists in the lab above turned out to be shared, and why the deep version needed a loop that slices each
inner list too. The note below is about why any language would hand you the shallow one by default.
Shallow, deep, and why anybody chooses shallow
A shallow copy duplicates one level: a new outer list holding the same references. A deep copy walks the whole structure and duplicates everything it finds. Deep is what people usually mean when they say "copy", and it is also the one that can be slow, can loop forever on a structure that points back at itself, and can duplicate things that were meant to be shared.
Most languages give you shallow by default and make you write the deep one, because shallow is cheap and predictable. Knowing which one you have is enough to avoid nearly all of the trouble.
b = somethingElse) only repoints that one arrow; the box is untouched
and a still refers to it. Changing the contents (b.push(1)) reaches through
the arrow into the shared box, and that is what both names see.Hiding how it works
A stack is not a list. A stack is three operations, push, pop and
size, plus a promise about the order things come back in: whatever went on last is the
first thing to come off. Think of a pile of plates. The fourth plate goes on top of the third, and when
you take one you get the fourth back, not the first. Push "a", then "b", then "c", and three pops hand you
"c", then "b", then "a". Popping when there is nothing there hands back undefined. A list is
one way to keep that promise. Whoever uses the stack has no business knowing which way you chose. There is
a word for whoever uses it, the client, and it does not mean a customer. The client of a piece of
code is simply whatever code calls it: another part of the same program, or a program somebody else wrote
next year.
That is what an abstract data type means: the type is defined by what you can do with it, not by what is inside it. The inside is called the representation, and the whole design question is whether anyone outside can reach it.
Here is the shape every type in this course uses. makeStack() is an ordinary function, and
what it hands back is an ordinary object, except that the things stored under its keys are functions rather
than numbers. So let s = makeStack() gives you an object, and s.push(4) reaches
into it for the function stored under push and calls it with 4. The list that actually holds
the items is declared with let inside makeStack, above the return,
where all three functions can see it and nothing outside can name it.
Wait, a function stored inside an object?
A function is a value, the same way 7 and "hat" are values, so it can be kept under a key like
anything else. { push: function (x) { items.push(x) } } is an object with one key, called
push, whose value happens to be a function. Reading it back with s.push hands
you the function itself; writing s.push(4) hands you the function and then calls it with
4.
The other half is that makeStack runs its body afresh every time it is called. Each call
builds its own items list and its own three functions, which is why two stacks made from
the same makeStack know nothing about each other. If you moved let items = []
up above the word function, there would be one list in the whole program and every stack
would share it. The lab checks for exactly that.
How do I take the last thing off a list?
list.push(x) puts x on the end, which you have met. list.pop() is its exact
opposite: it takes the last item off and hands it back, so let last = list.pop() both
shortens the list by one and tells you what was removed. Called on an empty list it removes nothing and
hands back undefined.
Those two operations are all a stack needs, and that is not a coincidence. A list with things pushed on and popped off at the same end already behaves the way a stack promises to behave. Which is the point of the step: a stack is not the list, it is the promise and a list is one convenient way of keeping it.
How the inside gets hidden when there is no private keyword
Some languages have a keyword: private in Java, an underscore convention in Python,
module-level visibility in Go. JavaScript's oldest and strongest answer is the closure. Variables
declared inside the factory function are reachable by the operations defined inside it and by nothing
else, because there is no name for them outside.
It is not a rule that somebody chooses to respect. There is no expression a client can write that reaches those variables, which is a different and much stronger kind of hidden.
stack.items directly, and
writes it in the README. Two years later, is the representation hidden?stack.items.length is right there and it works. Once one
caller depends on the inside, changing the inside breaks that caller, which is exactly the freedom the
abstract data type was supposed to buy you. Hiding it in the code costs one line and settles the
question permanently.The tripwire inside the type
Every data type keeps a quiet promise about its own insides. A sorted list is sorted. A fraction is in lowest terms. A stack's count matches how many items it holds. That promise is called the representation invariant, and nothing on earth checks it unless you write the check.
When an invariant breaks, the program does not stop. It carries on with a structure that no longer means
what every other function assumes it means, and the wrong answer surfaces later, somewhere unrelated. So
you write the check yourself: a small function that looks at the inside and answers true or false, called
after every operation that could have damaged anything. That function has a customary name,
checkRep, short for "check the representation". You will meet it under that name in real
code and for the rest of this course. This page also calls it the tripwire, because that is what it is for.
It repairs nothing. It goes off at the exact moment something steps on it, which is the only moment when
the cause is still standing next to the symptom.
Representation, invariant: two words at once
The representation is the actual stuff a data type keeps inside to do its job: the list behind the stack, the two whole numbers behind a fraction, the table behind a set. Step 8 called it the inside. This is the proper name for it.
An invariant is something that stays true the whole time, no matter which operations get called or in what order. Put together, a representation invariant is a sentence about the inside that every operation promises to leave true: the list is sorted, the count matches the length, the bottom of the fraction is never zero.
Does this stay in the shipped code
Often yes. A check that costs a few comparisons next to work that costs far more is not worth removing, and in a running system a loud failure at the right place beats a quiet wrong answer everywhere else. Some languages let you switch assertions off at build time so you can keep them in the source and pay nothing in production.
The one to think about is a check that walks the whole structure inside an operation that is supposed to be quick. Checking that a thousand-item list is sorted, on every insert, turns cheap inserts into expensive ones. The usual answer is to keep it on in testing and in development, and to sample it rather than delete it in production.
How do I ask whether something is a number?
typeof x hands you back a word describing what kind of value x is. typeof 7
is "number", typeof"7" is "string" (string is the usual word for
text), and typeof true is "boolean". So
typeof xs[i] !=="number" is how you ask "is this item something other than a number".
Why the invariant bothers to say it: a list will hold anything you put in it, and the text
"2" sits quietly between 1 and 3 when you compare it with <. A check that
only compares neighbours waves it straight through. Then some later operation does arithmetic on it
and gets "21" where it wanted 3. An invariant is the place to say what the inside is
allowed to contain, not only what order it has to be in.
When is one thing equal to another
Equality looks like the dullest idea in programming until you write it down. Three different questions are hiding behind one symbol: are these the same box, do these hold the same contents, and can any operation tell them apart.
The third one is the definition professionals reach for. Two values are observationally equal if no sequence of operations in the type's interface can distinguish them. The interface is the list of operations the type offers, and nothing at all about how they are done. Everything else is a way of approximating that cheaply.
"Observationally equal", said with two vending machines
Two machines stand side by side. You can put coins in and press buttons, and that is all you can do, because the panels are welded shut. If every sequence of coins and buttons gets the same thing out of both, then as far as anybody using them is concerned they are the same machine. One might hold its cans in a spiral and the other in a stack, and no customer can ever find out.
A data type is the same arrangement. The operations are the buttons. Two values are observationally equal when no sequence of operations tells them apart. Take a set, which Step 11 builds twice. It is a type that holds a collection of different things. You can put something in, ask whether something is in it, and ask how many different things it holds. Those three are the whole interface. Notice what is not on the list: nothing asks which order they went in. Which is why a set holding 1 and 2 equals a set holding 2 and 1: there is no button you can press that tells them apart.
Why 0.1 + 0.2 misses, in one paragraph
Numbers are stored in binary, as a sum of halves, quarters, eighths and so on. A tenth is not a sum of those, in the same way that a third is not a finite decimal: 0.3333 never quite gets there. So 0.1 is stored as the nearest number that binary can hold, 0.2 likewise, and the sum of those two nearest numbers is not the nearest number to 0.3.
No better computer fixes this. The way out is to stop comparing decimals for exact equality: ask whether the difference is smaller than some tolerance, or hold money as a whole number of pennies and never divide it.
Writing an equality that goes all the way down needs one idea you have not used yet: the function has to call itself.
A function that calls itself, is that allowed?
It is, and it is the only sensible way to compare two things when you do not know how deep they go.
Asking whether [1, [2, [3]]] equals [1, [2, [3]]] means comparing the outer
lists position by position, which means asking whether [2, [3]] equals
[2, [3]], which means asking whether [3] equals [3]. It is the
same question three times over, on smaller and smaller pieces. So write the question once, and let it
ask itself.
What stops it going on forever is that each call is handed something strictly smaller than what it
was given. Eventually you reach a piece with no parts inside it: a number, some text,
true, null. There the answer is just ===, and the function
answers without asking again. That case, the one that does not call itself, is the one to write first. A
recursive function with no such case runs until the interpreter stops it. This page's interpreter
will stop it and say so.
Three helpers you will want. typeof x hands back a word saying what kind of thing x is:
"number", "string", "boolean", "object". Lists and
objects both answer "object", which is why Array.isArray(x) exists to tell
those two apart. And Object.keys(obj) hands back a list of the object's key names, so
Object.keys({ x: 1, y: 2 }) is ["x","y"], which you can loop over with
for ... of and count with .length.
check(makeSet([1, 2]), makeSet([1, 2])) with an equality that
compares the inside lists position by position, and it fails, because one set stored them in a different
order. What is wrong?One interface, two implementations
An interface is the list of operations and what each one promises, with nothing said about how. Write your tests against the interface and something useful falls out: the same suite runs against any implementation of it, and swapping one for another becomes a decision about speed rather than a rewrite.
Here is a set, with the usual three operations, built twice. One keeps a list and searches it. One keeps a table and looks straight up. Both keep the same promises.
// makeSet() // effects: a new empty set, with three operations // add(x) puts x in; adding the same thing twice changes nothing // has(x) true when x is in the set // size() how many different things are in it
Interface, in languages that have the word
Java writes interface Set and the compiler refuses to build anything that claims to be a
Set without every operation. Go infers it from the operations a type has. Python leans on the operations
existing at the moment you call them. Here it is an object holding three functions, which is the same
idea with no ceremony.
What none of those languages check is the promises. Nothing in a type system says "adding the same thing twice changes nothing". That part is the spec, and the only thing that enforces it is the test suite you run against every implementation.
How can it find something without looking through everything?
The list version starts at the front and compares its way along, so asking whether something is in a set of two hundred can cost two hundred comparisons. The table version does something else entirely: it turns the item into a place. It runs the item through a fixed piece of arithmetic that always gives the same answer for the same item, and puts it in the slot that arithmetic names. Later, to find out whether the item is there, it runs the same arithmetic, goes to that one slot and looks. One look, and it does not get slower as the set fills up.
You have used a table already without calling it one. An object with keys is exactly this.
counts["apple"] does not walk the object's keys one at a time looking for "apple"; it works
out where "apple" is kept and goes straight there. The table-backed set in this step is an object being
used that way. That is the whole of why its cost per operation barely moves, while the list version's
cost grows with every item you add.
makeSet and see which promise breaks first. Anything the suite stays quiet about
is a decision the interface left to you.Harden a messy module
Everything so far, applied to a piece of code somebody else wrote in a hurry. It works for the orders it has seen. It is about to be changed by you, and then by three other people. Changing the shape of code without changing what it does is called refactoring: splitting a long function into named pieces, giving a bare number a name, moving a decision to one place. It is most of what you are about to do here.
Three properties, each measured by a probe rather than by opinion. Safe from bugs: it holds up on inputs nobody tried. Easy to understand: the parts have names and can be looked at one at a time. Ready for change: the rule that changes most often can be changed in one move.
What refactoring is, and what it is not
Refactoring means changing the shape of code without changing what it does. Splitting a long function into named pieces, giving a number a name, moving a decision to one place. The behaviour before and after is identical, which is what makes it safe to do in small steps.
Here you are doing two jobs at once, because the module also has a bug in it, and separating those is the professional habit: refactor with the tests green, then fix the bug as its own change, so that when something breaks you know which of the two did it.
How does one function change something all the others can see?
Declare it once, above all of them: let freeAt = 30. Every function written underneath
can read freeAt, because a function can see the names that surround it as well as its own
parameters. And one of them can change it, like this:
function setFreeDeliveryAt(mark) { freeAt = mark }. After that, every function that reads
freeAt sees the new value, without being told and without being edited. That is what Step
1's tidy module was doing, and it is what "the rule that changes most often can be changed in one move"
looks like written down.
It is also the thing to be careful with, and you already know why. A value that anybody can reach and anybody can change is the trouble from Step 6 one floor up. It is tolerable here because there is exactly one of them, it is changed through one named function rather than from wherever and the module owns it. A module with nine of them, changed from everywhere, is the mess somebody calls you in to fix.
What you can do now
- Write a spec with a precondition and a postcondition, and say who is at fault when each one breaks.
- Turn a belief about code into a suite of assertions that can fail.
- Carve an input space into regions, cover them, and pick the boundary cases where the mistakes live.
- Write the test that catches a bug a green suite missed, and check your suite by breaking the code.
- Tell aliasing from copying, draw a snapshot diagram, and write functions that leave their arguments alone.
- Build a data type that hides its representation, and install a checkRep that stops the program at the moment the invariant breaks.
- Say what equality means for a type you designed, and implement it.
- Test against an interface so that two implementations become interchangeable.
Where this goes
- Concurrency. Everything in Step 6 gets sharper when two things run at once: shared mutable state is manageable when one thread touches it and nearly impossible when two do. Immutability stops being a style preference and becomes the main defence.
- Language engineering. Specifications, invariants and abstract data types are the tools you build a compiler out of, because a parse tree is an abstract data type with a rep invariant and a lot of operations.
- How programs break. The same argument one layer down. A buffer overflow is a rep invariant broken by something outside the type, in a language where nothing is hidden and there is no tripwire unless you wrote one.
Treat generated code as an untrusted patch
A language model or coding agent can search a repository, explain unfamiliar code, propose a test and edit several files. That changes the speed of software work, not the definition of a correct change. The output is a patch produced from incomplete context and statistical prediction. It must pass the same specification, tests and review as a patch written by a person.
Begin with a bounded task: the required behaviour, files that may change, commands that may run, data that may be read and actions that require separate permission. Give the tool the relevant source and project instructions, then inspect the diff rather than accepting its final explanation. A concise explanation can be wrong; a test can be written to confirm the model's own mistaken interpretation.
Run formatters, type checks, unit and integration tests in a controlled environment. Add a regression test that fails before the fix and passes after it. Review changed interfaces, dependencies, error paths and security boundaries. If an agent can write to external services, deploy or delete data, trusted code and user permissions must authorise each class of action. Generated text never grants that authority.
When is an AI-generated test useful?
It is useful when it states an independent requirement and would catch a plausible wrong implementation. Run it against the old code, the proposed change and a deliberately broken variant. A test that passes every version supplies no evidence.
Keep hidden or independently written evaluation cases for work where the generator also writes the implementation. Otherwise the same misunderstanding can appear in both code and test, producing a green but incorrect pair.
Use types and still check the boundary
A static type checker examines code before it runs. If a function expects a number and another part passes a list, the checker can reject that path before a person finds it in use. Types make many impossible states harder to express and document what each module expects.
Network responses, saved files and user input arrive at runtime. A type annotation does not inspect those bytes. Parse untrusted data at the boundary, check its shape and turn it into a trusted internal value.
type Quest = { id: string; title: string; done: boolean }
function parseQuest(value: unknown): Quest {
// Return a Quest only after every required field passes.
}
Does a typed program have no type errors?
It has no errors covered by the type rules and declarations that passed. An unsafe cast, incorrect library declaration or unvalidated runtime value can still make a declaration disagree with reality.
Types also do not prove business rules such as “the end date follows the start date.” Enforce those with constructors, runtime checks and tests.
What are union and generic types?
A union says a value is one of several named shapes. Checking a field such as state tells the
checker which other fields are available.
A generic describes one relationship for many types. Result<T, E> holds either a value of
type T or an error of type E without losing which one is present.
Quest but never inspected. What is still missing?Design how operations fail
Some failures are expected outcomes: a title is empty, a record is missing, or a version conflicts. Return a value that makes the caller handle those cases. Exceptions suit faults that cannot be handled at the current layer. Catch them at a boundary that can add context, clean up and choose a response.
Error text should name the operation and useful identifiers without exposing secrets. Preserve the original cause when adding context. “Save failed for quest 42 because the database deadline expired” is actionable.
What must cleanup handle?
Files, locks, transactions and temporary resources need release on success, failure and cancellation. A
finally block or resource scope runs cleanup whichever path leaves the operation.
Cleanup can fail too. Record that without hiding the original fault, and make repeated cleanup safe where possible.
Are timeout and cancellation the same?
A timeout is a policy that cancels work after a deadline. Cancellation is the signal sent through the operation. Code must stop new work, release resources and decide what partial effects remain.
A write may have committed before cancellation arrived, so the outcome can still be uncertain.
Point dependencies toward stable rules
A dependency exists when one module must know another module's name, data shape or behavior. When business rules import a database driver, changing storage reaches into the rules. Define a small port owned by the rules, then let a database adapter implement it.
High cohesion keeps related decisions together. Low coupling limits what one part must know. Splitting every function into its own service creates more network and compatibility coupling rather than less.
What is a dependency cycle?
If A imports B, B imports C and C imports A, none can be understood or replaced independently. Draw the arrows and move a shared rule behind an interface owned by the more stable layer.
Cycles at package or service level affect builds, ownership and deployment more than local recursion does.
What do ports and adapters buy?
The core describes operations it needs, such as QuestStore.save. A database, in-memory test
store or remote API can sit behind that port.
Architecture tests can inspect imports and fail when a framework detail points into the wrong layer.
Generate cases and test the tests
An example test checks one selected case. A property states a relationship for many generated values. Reversing a list twice should return the original list. Sorting should preserve every item and place each neighboring pair in order.
When a generated case fails, a shrinker searches for a smaller case that still fails. Fuzzing feeds malformed bytes into parsers. Mutation testing makes deliberate code changes and checks whether the suite notices.
Where does the expected answer come from?
A property can compare two routes, check an invariant or use a slow reference. If the test copies the production algorithm, both may share one mistake. Choose an independent oracle.
Generators should deliberately include empty, maximum, duplicate, Unicode and deeply nested cases.
What does a surviving mutant mean?
The changed behavior may be uncovered, the assertion weak, or the code equivalent or unreachable. Inspect the mutant before adding a test. A mutation score is evidence, not a reason to add meaningless assertions.
Use mutation selectively on important logic because running many altered versions costs time.
<= to < and see whether the suite detects it.Make concurrent changes deterministic
Two tasks can each be correct alone and wrong when their steps interleave. If both read counter 7, add one and write 8, one increment is lost. Protect the complete operation with an atomic update, lock, transaction or one owner that receives messages.
A data race is unsynchronized access where at least one access writes. A higher-level race can exist even with atomic pieces, such as checking a seat and booking it in two separate operations.
Do locks solve every race?
A lock protects code that uses the same lock. Several locks can deadlock when tasks acquire them in different orders. Keep critical sections small and use a fixed order.
Immutability and message passing reduce shared writable state, but ownership and message order still need rules.
How can a race test be repeatable?
Pause each task at named yield points, choose a known interleaving and assert the final state. Save the schedule that exposes the failure.
Continue with Concurrent Data Structures for atomics, memory order and safe reclamation.
Change contracts while old versions still run
A public contract includes data fields, errors, timing expectations and side effects, not just a function name. A compatible change preserves what existing callers rely on. Version numbers communicate intent; tests and migration plans establish whether compatibility is real.
Production versions overlap. Expand first while old code still works. Migrate data in a restartable job. Switch readers and writers. Contract last, after no live version or stored record needs the old shape.
Why not change every service at once?
An instant coordinated change is difficult to retry or roll back. Mobile clients may remain old for months. Prefer tolerant readers, additive fields and explicit deprecation windows.
When a semantic break is necessary, version the contract and give callers a tested migration path.
What makes a data migration restartable?
Process bounded batches, record progress and make repeating a batch safe. Verify counts and invariants before and after. Avoid one transaction across the entire dataset.
Rolling code back does not automatically reverse data already changed by the new version.
Know what enters the build
A dependency saves work but adds code, maintainers, update policy and transitive packages to the system. A lockfile records exact resolved versions. A software bill of materials lists what entered a build so a team can answer which released products contain a vulnerable component.
Prefer authenticated registries, verified checksums or signatures, reviewed update automation and reproducible build steps. A scanner reports known findings; it does not prove an unreported package is safe.
Where should configuration and secrets live?
Configuration selects behavior for an environment. Secrets are credentials and keys. Keep secrets out of source, logs, generated artifacts and browser code. Supply them at runtime through a controlled secret store.
Grant each workload only the secret and permission it needs. Rotation must replace both stored credentials and running consumers without an unsafe gap.
Is a pinned old dependency always safer?
Pinning makes a build repeatable, but old code can contain known flaws. Update deliberately: inspect the change, run compatibility and security tests, stage the release and keep a recovery route.
A package with no maintenance plan becomes risk even if it has no current advisory.
Make ownership and decisions visible
A reviewable change has one reason, a small diff, passing checks and enough context to evaluate risk. Code ownership identifies who understands a boundary and must review sensitive changes. It should route knowledge, not create one person who can never leave.
An architecture decision record states the problem, constraints, options, decision and consequences. It preserves why a choice was made. Update or supersede it when the decision changes instead of rewriting history.
What belongs in code review?
Check the requirement, diff, tests, data and security effects, compatibility, operations and deletion plan. Style automation should handle mechanical details so people can examine behavior.
Large mixed changes hide risk. Separate formatting, generated files, dependency updates and behavior when that makes each part independently verifiable.
How should a feature flag end?
Name an owner, intended lifetime, default and removal condition. Test both states while both exist. After rollout is complete, remove the old branch and flag rather than adding permanent combinations.
Flags help separate deployment from release, but they also create runtime states that need monitoring.
Use production evidence without exposing people
Logs record events, metrics summarize values over time, and traces connect the spans of one request. Include request IDs, operation names and safe dimensions. Exclude passwords, tokens and unnecessary personal data.
A service-level objective states the reliability people need, such as a proportion of successful requests within a latency limit. An error budget measures how much failure remains before reliability work takes priority over risky release work.
What should an incident review produce?
Build a timeline from evidence, describe impact and contributing technical or organizational conditions, then assign specific prevention, detection and recovery improvements.
Stopping at the person who made the last edit hides the system conditions that allowed one action to cause broad harm. Track actions to completion and verify that they change the failure mode.
Can a rollback always restore service?
It can restore old code. It may not reverse database writes, messages, emails or external effects already produced. Test rollback and forward-repair plans against stateful changes.
Gradual rollout, health checks and automatic stopping reduce the amount affected before recovery begins.
Measure where change is expensive
Maintainability is observed through changes. Count how many modules a typical feature touches, how often a change is followed by a defect, how long tests and review take, and where dependency cycles or repeated logic concentrate work. A single line-count or complexity score cannot judge design by itself.
Refactor in verified slices. Characterize current behavior, create a seam, move one responsibility, compare results and remove the old path. Do not mix a large behavior change with a large structural rewrite unless no smaller safe route exists.
Should every duplicate line be abstracted?
No. Similar code can change for different reasons. Wait until the shared rule is clear, then give that rule one name. A premature abstraction couples cases that only happen to look alike today.
Duplication of a safety rule or protocol may deserve earlier attention than repeated presentation markup.
Where do the deeper topics continue?
Performance Engineering measures time and resource costs. Reliable Systems develops failure boundaries, retries and operations. Fault Tolerance tests recovery.
Build a Language shows how types, interfaces and invariants support parsers, interpreters and compilers.