A fee calculator sums line items into a local mutable accumulator — does that mutation make the function impure?
answer
- observable to whom?
- the call boundary is the test
- mutation is fine if nothing escapes
- reachable before, reachable after
- arguments are already reachable outside
basics
~10 sNo. Purity forbids observable change, not assignment. A variable created, mutated and discarded inside one call cannot be reached by any caller, so the mutation is invisible and the function stays pure.
solid answer
~50 sNo — purity is a property of the call **boundary**, not of the statements inside it. The accumulator is created when the call starts and is gone when it returns, so no caller, no later call and no other thread can tell it was ever written to. The verdict flips the moment the mutated thing is reachable from outside: if it was passed in as an argument, if it is a field on shared state, or if a previous call left it there, then writing to it is an outward write and the function is impure. The useful test is reachability, not the presence of an assignment: *could anything other than this call see the difference?* Building a fresh mutable value inside the call and returning it keeps the call itself pure, though the caller then owns something mutable.
code
pseudocode · 8 linesfunction deliveryFee(order, rateCard)
weight = 0
for each line in order.lines
weight = weight + line.weight * line.quantity
fee = rateCard.base + rateCard.perKilo * weight
if order.subtotal >= rateCard.freeAbove
fee = 0
return feego deeper
Remember that purity bans observable change, not assignment, and that a variable created and dropped inside one call is observable to nobody.
Explain the reachability test in both directions — was it reachable before the call, is it reachable after — and use it to classify a local, an argument's field and shared state.
Demonstrate you catch the effect with no I/O anywhere near it: a helper quietly writing a flag onto a passed-in object, and a local that stopped being local when something stored it.
The trade-off you own is how far internal mutation is allowed to go for performance before the boundary guarantee becomes something reviewers can no longer check by reading the function.
## Purity is measured at the boundary Purity is not a claim about the shape of the code inside a function. It is a claim about what a caller can observe across the call: arguments go in, a result comes out, and nothing else in the world is different afterwards. A loop with a mutable accumulator satisfies that perfectly well. Nobody outside the call ever held a reference to the accumulator, nobody can hold one after the call returns, and a second call starts with a fresh one. The mutation happened, and it was never **observable**. This is the point where a lot of candidates over-apply the rule they half-remember. They hear "functional means no mutation" and conclude that every assignment is a defect. The real rule is narrower and more useful: no *observable* change. Internal mutation for accumulation, buffering or a running index is compatible with purity, and it is how a pure function is usually implemented underneath, whatever the surface style of the language. ## Reachability is the whole test Ask two questions about whatever is being mutated: 1. **Was it reachable from outside before the call started?** 2. **Is it reachable from outside after the call returns?** If the answer to both is no, the mutation is benign. A single yes makes it an effect. | What gets mutated | Reachable before | Reachable after | Verdict | |---|---|---|---| | A local accumulator declared in the body | no | no | benign — invisible to every caller | | A field on an object passed in as an argument | yes | yes | outward write — the caller's object changed | | Shared state the enclosing service holds | yes | yes | outward write — other calls can read it | | A collection a previous call stored somewhere shared | yes | yes | outward write, and an undeclared input too | | A fresh object built in the body and returned | no | yes, via the result | the call stays pure; the caller now owns it | The last row is the one worth being precise about. Each call builds its own value, so the result is still determined by the arguments and nothing that existed before the call was touched — the call is pure. What the caller does with a mutable result afterwards is the caller's problem, not evidence against this function. ## The three cases that actually break it - **Mutating an argument.** The caller handed you an object and still holds it. Writing a flag or a total onto it means the caller's data differs after the call, and a second call may now see the flag the first one set — so the same arguments can even produce a different result. - **Mutating captured or enclosing state.** A field on the surrounding object, or a value captured from an outer scope, outlives every call. Writes to it accumulate across calls. - **Letting a local escape.** A local is only benign while it stays local. If it is handed to something that stores it — registered, appended to a shared list, attached to an argument — it stopped being local at that moment, and every later write to it is observable. ## Why interviewers ask this one It separates candidates who learned the rule from candidates who understand what the rule buys. Someone who says "impure, there is an assignment" cannot reason about a function they did not write, because almost every implementation mutates something internally. Someone who says "pure, nothing escapes" is applying the boundary test, and they will get the harder cases right too: they will spot the argument mutation that has no I/O anywhere near it, and they will not flag a loop counter. A compact way to say it in the room: **purity is about what leaks, not about what moves.** Then give the two extremes — a loop counter in a fee calculation is invisible; writing one field onto the order object the caller passed is as much an effect as writing to a log, even though nothing was printed and nothing was stored. ## Checking your own function 1. Name every variable the body writes to. 2. For each, trace where it came from: created here, a parameter, something read from shared state, or something captured. 3. For each, trace where it goes: discarded at return, returned, or handed to something that keeps it. 4. Only the created-here-and-discarded ones, plus anything built fresh and returned, are safe to call benign. Anything else is in the effect inventory, however local the assignment looked on the line.
- What changes if the accumulator is an object the caller passed in instead of one created inside?It was reachable before the call and stays reachable after, so every write to it is an outward write. The caller's object differs once the call returns, and because the object now carries state, a second call with the same argument can produce a different result — both halves of purity broken by one line.
- Does returning a freshly built mutable collection break the function's purity?No. Each call builds its own, the result is still fixed by the arguments, and nothing that existed before the call changed. The caller now holds a mutable value, so whether the caller stays pure depends on what it does next — but that verdict belongs to the caller.
- A local list is built in the body and also appended to a registry the service keeps. Is the mutation still benign?No. It stopped being local the moment it was stored somewhere that outlives the call. From then on every write to it is visible to whoever reads the registry, and a later call can observe what this one appended.
A chef who whisks eggs in a bowl of their own and washes it up leaves the kitchen exactly as they found it. A chef who whisks them into the shared stockpot has changed dinner for everyone, however small the whisk.
saying these in an interview costs you the question
- Says any assignment inside the body makes a function impure
- Calls a function pure while it writes fields onto an argument it was handed
- Thinks no I/O in the body is enough to conclude purity
- Believes purity requires a language without mutable variables
- Assumes returning a mutable object is itself a side effect
- Judges by how small the mutation is rather than by what can see it