Picture a subtotal(cart) function. You call it once to show a live preview, and again at checkout — but the second answer is wrong, because the first call also edited the cart. The function did its stated job and something extra behind your back. That hidden something is a side effect, and it is the root of most confusing bugs.
A pure function does the opposite. It takes its inputs, returns an output, and touches nothing else in the program. Hand it the same arguments a thousand times and you get the same answer a thousand times — no surprises, no hidden state to track. Purity is what makes a function predictable, easy to test in isolation, and safe to drop into any part of your program. Almost every technique later in this module — memoization, composition, currying — quietly assumes your functions are pure, so this is the right place to start.
flowchart TD P["pure add(2, 3)"] -->|"always 5"| OK["nothing else touched"] I["impure add(2, 3)"] -->|"maybe 5"| SIDE["also edits a shared counter"] style P fill:#1e7f3d,color:#fff style I fill:#b91c1c,color:#fff style OK fill:#3776ab,color:#fff style SIDE fill:#b45309,color:#fff
What counts as a side effect
A side effect is any change a function makes to the world outside itself: writing to a variable declared elsewhere, pushing into an array it did not create, logging, or reaching out to the network. A function with none of these is pure. The payoff has a formal name: referential transparency. A referentially-transparent call can be replaced with its return value and the program behaves identically — pureAdd(2, 3) could be swapped for 5 anywhere, and no one would notice.
That property is not academic. It is why you can cache a pure function's result (if the inputs match, the answer matches), why you can test it by feeding inputs and checking outputs with no elaborate setup, and why two pure functions can run in any order without stepping on each other. Impure functions force you to re-read the entire program before you know what they do.
let callCount = 0;
function impureAdd(a, b) {
callCount += 1; // side effect: edits outer state
return a + b;
}
function pureAdd(a, b) {
return a + b; // no side effect at all
}
console.log(impureAdd(2, 3)); // 5
console.log(impureAdd(2, 3)); // 5 again, but callCount is now 2
console.log(callCount); // 2 — proof the world changed
console.log(pureAdd(2, 3)); // 5
console.log(pureAdd(2, 3)); // 5 — and nothing else changed
What you gain from purity
Pure functions shrink the surface area you have to hold in your head. To understand an impure function you must trace every variable it might touch; to understand a pure one you only need its arguments and its return value. That difference compounds as a program grows. A codebase built from pure pieces reads like a pipeline — data in, data out — and each piece can be reasoned about, swapped, or tested on its own.
You will not always write pure functions, and you should not try: anything that talks to the user, the network, or the disk is impure by definition. The discipline is to push those effects to the edges of your program and keep the core logic pure. Pure core, impure shell — that is the shape of well-structured code.
Immutability: do not edit, return a new one
Purity's partner is immutability — never changing data after you create it. The reason this matters in JavaScript is a fact about how arguments travel: objects and arrays are passed by sharing, not by copying. When you hand an array to a function, the parameter is not a fresh copy; it is a second reference to the very same array in memory. Mutate the parameter and you have mutated the caller's data too.
Primitives behave differently. Numbers, strings, and booleans are passed by value, so reassigning a parameter inside a function never reaches the caller. The contagion only affects objects and arrays — which is exactly where most mutation bugs hide, because nothing in the syntax warns you that two variables point at the same thing.
flowchart TD A["array a holds 1,2,3"] --> R["one shared array object"] B["b = a"] -.-> R R -->|"b.push 4"| M["same object becomes 1,2,3,4"] style R fill:#3776ab,color:#fff style M fill:#b45309,color:#fff
const original = [1, 2, 3];
// BAD: push mutates the array it was given
function badAdd(list, item) {
list.push(item);
return list;
}
const badResult = badAdd(original, 4);
console.log(badResult); // [1,2,3,4]
console.log(original); // [1,2,3,4] — oops, the original changed too
// GOOD: spread builds a NEW array, the original stays intact
const fresh = [1, 2, 3];
function goodAdd(list, item) {
return [...list, item];
}
console.log(goodAdd(fresh, 4)); // [1,2,3,4]
console.log(fresh); // [1,2,3] — untouched
The immutable toolkit
JavaScript hands you a set of methods that build new values instead of editing old ones. map returns a new array with each element transformed; filter returns a new array of the elements that passed a test; slice returns a new sub-array. The spread operator [...arr] copies an array, and {...obj} copies an object — extend the copy and the original stands still.
Reach for these the moment you are tempted to push, splice, or assign to an index. The result is the same collection with your change applied, but the data you started with is intact. Other code holding a reference to that data is never ambushed by an edit it did not expect. Building new values is how a function stays pure when it works with collections.
flowchart LR IN["input 1,2,3"] --> M["map: n becomes n times 2"] M --> OUT["new array 2,4,6"] IN -.->|"untouched"| SAFE["original still 1,2,3"] style M fill:#1e7f3d,color:#fff style OUT fill:#3776ab,color:#fff style SAFE fill:#e0900b,color:#fff
const nums = [1, 2, 3, 4]; // map: transform each element into a new array console.log(nums.map((n) => n * 10)); // [10,20,30,40] // filter: keep only the elements that pass a test console.log(nums.filter((n) => n > 2)); // [3,4] // spread: copy, then extend without mutating const extended = [...nums, 5]; console.log(extended); // [1,2,3,4,5] console.log(nums); // [1,2,3,4] — original unchanged
Write doubleAll(numbers) that returns a NEW array where each element is doubled. It must NOT modify the original array — call it twice on the same input and the input must still be unchanged.
function doubleAll(numbers) {
// return a NEW doubled array; do not mutate `numbers`
}
map returns a brand-new array, so the original is never touched.
Wrap the no-mutation check in an IIFE: (() => { ... return ...; })().
function doubleAll(numbers) {
return numbers.map((n) => n * 2);
}
What does this print? addItem mutates the array it was given — watch what cart holds on the second log.
const cart = ["apple"];
function addItem(list, item) {
list.push(item);
return list;
}
const preview = addItem(cart, "pear");
console.log(preview);
console.log(cart);
push mutates the original array, and
previewpoints at the same object.So both
previewandcartare the same single array now.
This addBonus is meant to return a NEW array of scores with 10 added, leaving the original untouched. But it mutates the input in place — so scores changes too. Fix it so it builds a fresh array instead of editing the one it was handed.
function addBonus(scores) {
for (let i = 0; i < scores.length; i++) {
scores[i] += 10;
}
return scores;
}
Build a new array with result.push(...) instead of editing scores[i].
Each pushed value should be scores[i] + 10, read-only.
function addBonus(scores) {
const result = [];
for (let i = 0; i < scores.length; i++) {
result.push(scores[i] + 10);
}
return result;
}
Write withDiscount(price) that returns a NEW object { price, sale } where sale is the sale price — the original price with a 20% discount (so for 100, sale is 80). It must be pure — no shared state, and each call must return a fresh object. So two calls with the same price return equal contents but are NOT the same object reference.
function withDiscount(price) {
// return a fresh object { price, sale }; no shared state
}
A literal { price: price, sale: price * 0.8 } is a fresh object every call.
Two separately-built objects have the same contents but different identities.
function withDiscount(price) {
return { price: price, sale: (price * 8) / 10 };
}
Which of these is a side effect that makes a function impure?
Appending to an outer (non-local) array changes state the function did not receive as a fresh input and does not return — a side effect. The other options touch only local or returned state, so they leave the outside world unchanged.
Recap
- A pure function returns the same output for the same inputs and has no side effects — it touches nothing outside itself.
- Referential transparency means a call can be swapped for its result; that is what makes pure functions testable, cacheable, and safe to run in any order.
- Objects and arrays are passed by sharing, so mutating a parameter mutates the caller's data; primitives are passed by value and are safe.
- Prefer the immutable toolkit —
map,filter,slice, spread — to build new values instead of editing shared ones.
Next you'll meet currying, which leans on purity to turn one general function into many specialised ones.