Pure Functions & Immutability
Think of a snack vending machine. You press B4, and out drops the same
packet of crisps — today, tomorrow, at 3am, no matter who pressed it before you. The machine
looks only at the button you push, hands you exactly one thing back, and quietly changes nothing
else about the world. A pure function is a vending machine made of code: its
output depends only on its inputs, and calling it leaves everything else untouched.
Purity is really two promises rolled into one:
- Same input → same output, every time. No hidden dependence on the clock, a
random number, a global variable, or a file on disk. This property has a grand name:
referential transparency — you could replace a call with its result and the
program would behave identically.
- No side effects. The function doesn't change any variable outside itself,
doesn't print, doesn't write a file, doesn't mutate the objects it was handed. It just computes
a value and returns it.
Pure versus impure, side by side
Both functions below add tax to a price. The first is pure — hand it the same price and you always
get the same answer. The second secretly reads a global \text{rate} that
someone else might change, so the same call can give different answers. Run it,
then imagine debugging the second one at 2am.
// PURE: output depends only on the two inputs
function withTax(price: number, rate: number): number {
return price * (1 + rate);
}
// IMPURE: reaches out to a global that can change under its feet
let taxRate = 0.2;
function withTaxImpure(price: number): number {
return price * (1 + taxRate);
}
console.log("pure:", withTax(100, 0.2));
console.log("pure again:", withTax(100, 0.2)); // identical, guaranteed
console.log("impure:", withTaxImpure(100));
taxRate = 0.5; // someone changes the world...
console.log("impure again:", withTaxImpure(100)); // ...same call, different answer!
The pure function is referentially transparent: \text{withTax}(100, 0.2)
is just another way of writing 120. The impure one is not — you cannot
know what it returns without knowing the entire history of the program.
Immutability: don't change data, make new data
The sneakiest side effect is mutation — reaching into a list or object you were
given and altering it in place. Other parts of the program are still holding that same list, and
suddenly it changed under them. The functional cure is immutability: treat data
as read-only. Never edit the thing you were handed; build and return a new value instead.
Watch the difference. The impure \text{addImpure} wrecks the caller's
array with push; the pure \text{add} spreads the old items
into a fresh array and leaves the original alone.
// IMPURE: mutates the array it was given
function addImpure(list: number[], x: number): number[] {
list.push(x); // reaches into the caller's data!
return list;
}
// PURE: builds a brand-new array, original untouched
function add(list: number[], x: number): number[] {
return [...list, x]; // spread the old items, then the new one
}
const original = [1, 2, 3];
const wrecked = addImpure(original, 4);
console.log("after impure, original =", original); // [1,2,3,4] — changed!
const original2 = [1, 2, 3];
const fresh = add(original2, 4);
console.log("after pure, original =", original2); // [1,2,3] — safe
console.log("returned new array =", fresh); // [1,2,3,4]
The same habit gives you pure updates for objects ({ ...user, age: 31 }) and pure
transforms for whole collections. It feels wasteful to copy — but modern engines are fast, and the
peace of mind is enormous: data you didn't return, you didn't change.
A fair worry: a program with zero side effects would be useless — it could never draw a
pixel or save a file. Functional programming doesn't ban effects; it corrals
them. You keep the vast, testable core of your program pure, and push the messy effects
(printing, network, disk, randomness) out to a thin shell at the edges. This is often called
"functional core, imperative shell". The pure core is where the thinking lives; the
shell just plugs it into the real world. You get the best of both: logic you can trust, and a
program that still does things.
Why bother? Three quiet superpowers
- Testing is trivial. A pure function has no setup and no world to fake: feed
it inputs, check the output. No mocks, no clock, no database.
- Reasoning is local. To understand a pure function you read only that
function. It can't be sabotaged by something far away, because it depends on nothing far away.
- Parallelism is safe. Two pure calls can't interfere — they share no mutable
state — so you can run them on different cores without a single lock. This is why functional
ideas power so much big-data and concurrent code.
The trap is the hidden side effect — a function that looks pure but
isn't. The classic offenders:
- reading a global variable or the system clock
(
Date.now()) — same input, different output;
- calling
Math.random() — the least referentially-transparent
line there is;
- mutating an argument with
push, sort,
splice, or obj.field = ... — you've quietly rewritten the caller's
data.
Beware especially array.sort() and array.reverse(): they sort
in place and return the same array, so they look harmless but mutate. Reach for
the copy-first version — [...list].sort() — to keep the original safe.