Module 8 · Functional & Advanced Patterns ⏱ 18 min

Pure Functions & Immutability

By the end of this lesson you will be able to:
  • Identify side effects and explain why they make functions hard to reason about
  • Write pure functions that return the same output for the same arguments
  • Avoid mutating shared objects and arrays by returning new copies instead

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
A pure function is a closed box: same inputs in, same value out, nothing else touched. An impure one leaks changes into the surrounding program.

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.

The same addition, written two ways. Press Run and watch the difference.
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
Two variables, one array. Mutating through either reference changes the shared object — the other variable sees it too.
The mutation bug, then the spread fix. The original array is the witness.
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
An immutable transform: map reads the input, produces a brand-new array, and never touches the original.
Three immutable transforms. Each returns a new value; nums never changes.
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
Exercise

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`
}
Exercise

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);
Exercise

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;
}
Exercise

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
}
Exercise

Which of these is a side effect that makes a function impure?

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 toolkitmap, 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.

Checkpoint quiz

What makes a function pure?

You pass an array to a function and the function does arr.push(x). What happens to the caller's array?

Go deeper — technical resources