Pure Functions and Side Effects

Mental model: A pure function is a formula: the same input always gives the same output, and nothing outside it can tell that it ran.

Level: intermediate · about 10 minutes

let taxRate = 0.2;

function impureTotal(price) {
  taxRate += 0.05;                 // changes something outside itself
  return price * (1 + taxRate);
}

console.log(impureTotal(100));     // → 125
console.log(impureTotal(100));     // → 130   same input, different answer

const pureTotal = (price, rate) => price * (1 + rate);
console.log(pureTotal(100, 0.2), pureTotal(100, 0.2)); // → 120 120

Same call, twice, with two different outcomes for the caller.

A function is pure when it satisfies two conditions: the return value depends only on its arguments, and calling it changes nothing observable outside itself. Everything else is a side effect: writing to a variable in an outer scope, mutating an argument, logging, touching the DOM, reading the clock, generating a random number, or making a request.

FunctionPure?Why
(a, b) => a + byesoutput depends only on input
(list) => [...list].sort()yessorts a copy, leaves the argument alone
(list) => list.sort()nomutates the array the caller passed in
() => Date.now()noreads the outside world, different answer each call
(x) => { console.log(x); return x; }nologging is an observable effect
(id) => fetch(url)noperforms a request

Referential transparency

A pure call can be replaced by its result without changing the program. That property is called referential transparency, and it is what makes caching, memoisation, reordering and parallel work safe. Try it with an impure function and you change behaviour.

const slug = (title) => title.trim().toLowerCase().replaceAll(' ', '-');

// Because slug is pure, these two lines are interchangeable:
console.log(slug(' Hello World '));   // → 'hello-world'
console.log('hello-world');           // → 'hello-world'

// And caching it can never be wrong:
const cache = new Map();
const cachedSlug = (title) => {
  if (!cache.has(title)) cache.set(title, slug(title));
  return cache.get(title);
};
console.log(cachedSlug('A B'), cachedSlug('A B'), cache.size); // → 'a-b' 'a-b' 1

Immutable updates

Most accidental impurity comes from mutating an argument. The fix is a copy plus a change, which the spread syntax and the non-mutating array methods make short.

Mutating: the caller is surprised

function addItem(list, item) {
  list.push(item);      // edits their array
  return list;
}

const original = ['a'];
const next = addItem(original, 'b');
console.log(original);  // ['a', 'b']  changed
console.log(next === original); // true

Copying: the caller is safe

function addItem(list, item) {
  return [...list, item];
}

const original = ['a'];
const next = addItem(original, 'b');
console.log(original);  // ['a']  untouched
console.log(next === original); // false

The mutating version also lies about its return value: callers cannot tell whether they got a new array or the same one, so a comparison like prev !== next (used by every rendering library) stops working.

const user = { id: 1, name: 'Ada', tags: ['admin'] };
const items = [1, 2, 3];

console.log({ ...user, name: 'Grace' });          // change one field
const { tags, ...withoutTags } = user;            // remove a field
console.log(withoutTags);
console.log([...items, 4]);                        // append
console.log(items.filter((n) => n !== 2));         // remove
console.log(user, items);                          // → both originals unchanged

The four everyday immutable updates.

Isolating the effects

// Pure: all the decisions live here, and it is trivial to test.
function applyDiscount(order, percent) {
  return { ...order, total: order.total * (1 - percent / 100) };
}

// Impure shell: reads input, calls the pure part, performs the effect.
function checkout(order, percent) {
  const discounted = applyDiscount(order, percent);
  console.log('charging', discounted.total);   // the only effect
  return discounted;
}

console.log(checkout({ id: 7, total: 200 }, 25));

Calculation in the middle, effects at the edge.

When a function needs something from the outside world, take it as a parameter instead of reaching for it. Pass now rather than calling Date.now(), pass a random function rather than calling Math.random(). The function becomes pure, and your tests become boring, which is the goal.

Reaches out: hard to test

function isExpired(token) {
  return token.expiresAt < Date.now();
}
// the test result changes with the clock

Takes it in: trivial to test

function isExpired(token, now = Date.now()) {
  return token.expiresAt < now;
}
isExpired({ expiresAt: 50 }, 100); // true, always

A default parameter keeps the convenient call site while making the dependency injectable. This one change turns a flaky test into a deterministic one, and it costs a single parameter.

deterministic
same arguments, same result, every time
no side effects
nothing outside can tell it ran
testable
no mocks, no setup, no teardown
cacheable
safe to memoise, because the answer cannot change
const nums = [3, 1, 2];
function smallest(list) {
  return list.sort((a, b) => a - b)[0];
}
console.log(smallest(nums), nums);

sort sorts in place and returns the same array, so smallest quietly reorders the caller array. The result is 1, but nums is now [1, 2, 3]. Copy first with [...list].sort(…) and the function becomes pure.

Try it yourself

Make it pure

const original = { id: 1, name: 'Ada', tags: ['admin'] };

function addTagImpure(user, tag) {
  user.tags.push(tag);
  return user;
}

function addTagPure(user, tag) {
  return { ...user, tags: [...user.tags, tag] };
}

const a = addTagPure(original, 'editor');
console.log('pure result:', a);
console.log('original after pure call:', original);

const b = addTagImpure({ ...original }, 'editor');
console.log('shallow copy did not protect the array:', original.tags);

Rewrite addTag so it does not touch the original user, including the nested tags array. Then check that original.tags still has one entry.

Exercises

Add an item without mutating

Write addItem(list, item) which returns a new array with item appended. The array you were given must be unchanged, and the result must be a different array object.

A pure discount

Write applyDiscount(product, percent) which returns a new product with price reduced by percent, rounded to two decimal places. Every other field is carried over unchanged, and the input object must not be modified.

Check yourself

What does this log?
true [3, 2, 1] — reverse mutates in place and returns the same array, so out and items are the same object and both read [3, 2, 1]. [...list].reverse() or toReversed() keeps the caller array intact.
Which of these is pure?
(list) => [...list].sort() — Sorting a copy leaves the argument alone and always gives the same result for the same input. The others mutate an argument, read an outside source of randomness, or perform an observable effect.
Why does making a function take now as a parameter help?
It removes a hidden dependency, so the function is deterministic and easy to test — Reading the clock inside the function makes the result depend on when the test runs. Passing it in, with a default for convenience, turns a flaky assertion into a fixed one and documents the dependency in the signature.

Common mistakes

  • Calling sort, reverse or splice on an argument and returning it, which silently edits the caller data.
  • Assuming a spread copy is deep. Nested objects and arrays are still shared.
  • Reading Date.now() or Math.random() inside logic you then try to test.

Takeaways

  • Pure means the result depends only on the arguments and nothing observable changes.
  • A pure call can be replaced by its result, which is what makes caching and memoisation safe.
  • Copy then change: spread for objects and arrays, map and filter instead of in-place edits.
  • Take the outside world as a parameter, and keep the effects at the edges of the program.