Learn JS Series (#28) - Pure Functions and Side Effects: The Foundation of Predictable Code
Learn JS Series (#28) - Pure Functions and Side Effects: The Foundation of Predictable Code

What will I learn
- You will learn the exact definition of a pure function, and its two non-negotiable requirements;
- what a side effect actually is, and why some are unavoidable and even the whole point of a program;
- what referential transparency means, and why it is the property that makes pure code so easy to trust;
- why pure functions are trivially testable, easy to reason about, cacheable, and safe to run in parallel;
- how mutation is a hidden side effect, and how the copy-instead-of-mutate habit keeps functions pure;
- the professional "functional core, imperative shell" strategy of pushing side effects to the edges.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Node.js (20+) distribution, or just a modern browser console;
- Episodes 1-27 read, especially composition, higher-order functions, and closures.
Difficulty
- Intermediate
Curriculum (of the Learn JS Series):
- Learn JS Series (#1) - What Is JavaScript, Why It Runs Everywhere, and How to Run It
- Learn JS Series (#2) - Variables and Bindings
- Learn JS Series (#3) - The Primitive Types: number, string, boolean, null, undefined, symbol, bigint
- Learn JS Series (#4) - Operators and Expressions: Arithmetic, Comparison, Logical, and Short-Circuiting
- Learn JS Series (#5) - Strings: Template Literals, Unicode, and the Methods You Actually Use
- Learn JS Series (#6) - Numbers: IEEE 754, Why 0.1 + 0.2 Is Not 0.3, and How to Cope
- Learn JS Series (#7) - Control Flow: if/else, switch, and the Ternary Expression
- Learn JS Series (#8) - Loops: for, while, for...of, for...in, and When to Use Which
- Learn JS Series (#9) - Functions: Declarations, Parameters, Return Values, and Hoisting
- Learn JS Series (#10) - Scope and the Temporal Dead Zone: How JavaScript Finds Your Variables
- Learn JS Series (#11) - Arrays: The Workhorse Data Structure and Its Core Methods
- Learn JS Series (#12) - Objects: Key-Value Data, Dot vs Bracket Access, and Nesting
- Learn JS Series (#13) - Truthiness, Equality, and Coercion: == vs === Done Properly
- Learn JS Series (#14) - Mini Project: A Command-Line Tip Calculator
- Learn JS Series (#15) - First-Class Functions: Passing, Returning, and Storing Functions
- Learn JS Series (#16) - Arrow Functions vs function: Syntax, this, and When Each Wins
- Learn JS Series (#17) - Closures: The Single Most Important Idea in JavaScript
- Learn JS Series (#18) - Higher-Order Functions: Functions That Take or Return Functions
- Learn JS Series (#19) - Callbacks and the Callback Pattern (Before We Reach Promises)
- Learn JS Series (#20) - Default, Rest, and Spread: Flexible Function Signatures
- Learn JS Series (#21) - Destructuring Parameters: Named Arguments the JS Way
- Learn JS Series (#22) - The this Keyword: Five Rules That Explain Every Case
- Learn JS Series (#23) - call, apply, and bind: Controlling this Explicitly
- Learn JS Series (#24) - Recursion: Base Cases, the Call Stack, and Stack Overflows
- Learn JS Series (#25) - IIFEs and the Module Pattern (the Pre-2015 Way to Get Privacy)
- Learn JS Series (#26) - Currying and Partial Application
- Learn JS Series (#27) - Function Composition: Building Pipelines from Small Functions
- Learn JS Series (#28) - Pure Functions and Side Effects: The Foundation of Predictable Code (this post)
Learn JS Series (#28) - Pure Functions and Side Effects: The Foundation of Predictable Code
Solutions to Episode 27 Exercises
Exercise 1 - a double-increment-square pipe:
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
const double = (n) => n * 2;
const increment = (n) => n + 1;
const square = (n) => n * n;
console.log(pipe(double, increment, square)(3)); // 49
The insight: pipe threads the value left to right, 3 -> 6 -> 7 -> 49, so each step's output is simply the next step's input.
Exercise 2 - compose versus pipe:
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
const compose = (...fns) => (x) => fns.reduceRight((v, f) => f(v), x);
const f = (n) => n + 1, g = (n) => n * 2, h = (n) => n - 3;
console.log(compose(f, g, h)(10)); // f(g(h(10))) = ((10-3)*2)+1 = 15
console.log(pipe(h, g, f)(10)); // 15 - same result, order reversed
The insight: compose runs right to left and pipe left to right, so reversing the argument order makes the two equivalent.
Exercise 3 - a word-length pipeline:
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
const trim = (s) => s.trim();
const words = (s) => s.split(" ");
const longOnes = (arr) => arr.filter((w) => w.length > 3).length;
const countLong = pipe(trim, words, longOnes);
console.log(countLong(" the quick brown fox jumps ")); // 4
The insight: pure single-purpose steps make the pipeline trivial to read and reason about; each stage transforms its input and hands off cleanly, and nothing outside the pipeline is touched.
Last episode we wired small functions together into pipelines, and right at the end I leaned hard on a word I had not yet earned the right to use: pure. I kept saying pure functions compose predictably, pure functions are safe to reuse, prefer pure functions. Today we cash that cheque. Purity is not some academic ideal preached by people who write Haskell for fun (guilty, occasionally) -- it is the single most practical discipline in this whole phase, and once you can spot an impure function at a glance, a surprising number of "mysterious" bugs simply stop being mysterious. Let's make it precise.
What a pure function is
A pure function has two properties, and both must hold. Drop either one and the function is impure:
- Same input, same output. Given the same arguments, it always returns the same result -- today, tomorrow, on your machine, on mine. It does not depend on anything external that can change: no global variable, no clock, no random number, no reading a file or a database.
- No side effects. It does not change anything outside itself. It does not mutate its arguments, modify a global, write to the console or a file, touch the network, or poke the DOM. It just computes a return value from its inputs and hands it back.
A pure function is a little island: data goes in, a result comes out, and nothing else in the world is touched or observed. Here is a pure function and an impure one, side by side, so the contrast is concrete:
// PURE: depends only on its inputs, changes nothing outside
function add(a, b) {
return a + b;
}
console.log(add(2, 3)); // 5, always, forever
// IMPURE: depends on external state that can change
let taxRate = 0.21;
function withTax(price) {
return price * (1 + taxRate); // reads a global that could change
}
console.log(withTax(100)); // 121, but only while taxRate happens to be 0.21
add is pure: add(2, 3) is 5 no matter what else the program is doing, what time it is, or how many times you call it. withTax is impure: its output depends on the external taxRate, so the exact same input can produce different outputs at different moments. Change taxRate to 0.09 somewhere else in the codebase and withTax(100) silently becomes 109. That hidden dependency on mutable outside state is exactly what makes a function unpredictable, and unpredictable is the enemy of debuggable.
Notice something important: the fix is almost always to turn the dependency into a parameter. If withTax took the rate as an argument, withTax(100, 0.21), it would be pure again -- same inputs, same output, no reaching outside. That little move (promote the hidden input to an explicit one) is the workhorse technique of this whole episode.
What a side effect is
A side effect is any observable interaction a function has with the world beyond its own return value. If a function does something you could notice from the outside other than handing back a result, that something is a side effect:
let counter = 0;
function impureIncrement() {
counter += 1; // side effect: mutates external state
console.log(counter); // side effect: writes to the console
return counter;
}
impureIncrement(); // 1 (and 'counter' out here is now 1)
impureIncrement(); // 2 (same call, different result - not pure!)
Call impureIncrement() twice with the identical (empty) argument list and you get two different answers, 1 then 2, because it leans on and mutates counter. Modifying an external variable, logging, writing a file, making a network request, changing the DOM, generating a random number, reading the current time -- these are all side effects.
And here is the crucial point, the one people miss when they first meet this idea: side effects are not evil. They are the entire reason programs are useful. A program with no side effects computes an answer and then tells absolutely nobody -- it heats your CPU and achieves nothing. Every useful program must eventually print, save, send, or draw something. The goal is therefore not to eliminate side effects (impossible and pointless), but to control and isolate them: keep the overwhelming majority of your code pure, and herd the effects into a small, obvious, well-guarded corner. Hold that thought, because it becomes the punchline of the episode.
Referential transparency: the property purity buys you
There is a slightly fancy term worth knowing, because it names the superpower cleanly: referential transparency. A call is referentially transparent if you can replace the call with its result without changing how the program behaves. Pure functions are always referentially transparent; impure ones are not.
function square(n) {
return n * n;
}
// square(4) is referentially transparent: anywhere you see square(4),
// you can literally write 16 in stead, and nothing changes.
const a = square(4) + square(4); // 32
const b = 16 + 16; // 32 - provably identical
console.log(a === b); // true
Why do you care? Because referential transparency is exactly the mental move you make when you debug or refactor. You look at a line, and you reason "this call gives 16, so I can think of it as just 16". With a pure function that reasoning is always valid. With an impure one it is a trap -- replacing impureIncrement() with 1 would be wrong the second time you called it, because the call did something besides return a value. Pure functions let you reason by substitution, and reasoning by substitution is how humans actually understand code. That is not a small thing.
Why pure functions are worth the discipline
Purity is a constraint, and constraints feel like they cost you something. They do -- but the payback is enormous. Pure functions have a stack of superpowers that make them worth reaching for wherever you reasonably can.
First, they are trivially testable. With no external dependencies and no effects to observe, a test is just "given this input, do I get this output?" No setup, no mocks, no teardown, no spinning up a fake database:
function slugify(title) {
return title.trim().toLowerCase().replaceAll(" ", "-");
}
// testing a pure function is just: does this input give this output?
console.log(slugify(" Learn JS ") === "learn-js"); // true
console.log(slugify("Pure Functions") === "pure-functions"); // true
Compare that to testing something that reads the clock or hits the network -- suddenly you need to freeze time or stub a server, and your "unit" test grows tentacles. Pure functions are the easiest code in the world to test, which is why the functional core we build at the end of this episode is where your tests will feel effortless.
Second, they are easy to reason about. You can understand a pure function completely by reading its body; nothing hidden elsewhere reaches in to change its behaviour. Third, they are cacheable -- because the same input always yields the same output, you can remember previous results and skip the work entirely. That is memoization, and it is literally next episode's topic; it only works because the function is pure. Fourth, they are safe to compose (episode 27) and safe to run in parallel, since they never step on shared mutable state, so two of them running at once cannot corrupt each other.
And finally, pure functions never produce what I like to call "spooky action at a distance" -- the maddening class of bug where calling a function over here mysteriously breaks something over there, because the function quietly changed some shared data both parts rely on. That bug does not exist in pure code, by construction. When people say functional programming makes code more reliable, this is a big chunk of what they mean.
Mutation is a sneaky side effect
The side effect that catches people most often -- including people who know about side effects -- is mutation: changing an object or array that was passed in as an argument. It is sneaky precisely because it looks so innocent. Remember from episode 12 and 13 that objects and arrays are passed by reference: the function receives a pointer to the same array the caller holds, not a copy. So mutating the argument reaches back through that pointer and changes the caller's data. A side effect, written by accident:
// IMPURE: mutates the array it was handed
function addItemBad(list, item) {
list.push(item); // this changes the CALLER'S array!
return list;
}
const original = [1, 2, 3];
addItemBad(original, 4);
console.log(original); // [1, 2, 3, 4] - the original was changed, surprise!
// PURE: returns a NEW array, leaves the input untouched
function addItemGood(list, item) {
return [...list, item]; // a fresh array via spread (episode 20)
}
const before = [1, 2, 3];
const after = addItemGood(before, 4);
console.log(before, after); // [1, 2, 3] [1, 2, 3, 4] - original is safe
addItemBad mutates original, so the caller's data silently shifts under their feet -- exactly the kind of bug that eats an afternoon, because the line that caused the change and the line that noticed it can be hundreds of lines apart. addItemGood builds a new array with the spread operator and leaves the input completely alone. The same trick works for objects, using object spread:
// IMPURE: mutates the object it was given
function applyDiscountBad(cart) {
cart.total = cart.total * 0.9; // changes the caller's cart!
return cart;
}
// PURE: returns a new object, original untouched
function applyDiscountGood(cart) {
return { ...cart, total: cart.total * 0.9 }; // copy, then override one field
}
const cart = { item: "book", total: 100 };
const discounted = applyDiscountGood(cart);
console.log(cart.total, discounted.total); // 100 90 - original preserved
This copy-instead-of-mutate habit -- [...list, item] for arrays, { ...obj, field: newValue } for objects -- is the practical technique for writing pure functions over structured data. It feels wasteful at first ("why make a whole new object just to change one field?"), but it buys you predictability, and it is the entire foundation of the immutable-update patterns we lean on heavily in Phase 5 and again in Phase 10. Modern JavaScript is even growing built-in helpers for this (toSorted, toReversed, with), precisely because copy-don't-mutate turned out to be such a good default. For now, just build the reflex.
Push side effects to the edges
So side effects are necessary but risky, and pure functions are safe but cannot, by themselves, actually do anything. How do we get both? With the single most valuable structural idea in this episode: keep the core of your program pure, and push the side effects out to the edges. Structure your code so that pure functions do all the real work -- the calculating, the deciding, the transforming -- and a thin outer layer, and only that layer, handles the impure parts: reading input, printing output, saving to disk, calling the network.
// PURE core: all the logic lives here, no effects whatsoever
function calculateReceipt(items) {
const subtotal = items.reduce((sum, i) => sum + i.price, 0);
const tax = subtotal * 0.21;
return { subtotal, tax, total: subtotal + tax };
}
// IMPURE edge: the ONLY place an effect happens
function printReceipt(items) {
const receipt = calculateReceipt(items); // pure computation
console.log(`Total: ${receipt.total.toFixed(2)} EUR`); // the effect, at the edge
}
printReceipt([{ price: 10 }, { price: 20 }]); // "Total: 36.30 EUR"
Look at how the responsibilities split. All the arithmetic, all the logic that could actually be wrong, lives inside calculateReceipt, which is pure and therefore trivially testable -- you feed it items and check the numbers, no console to intercept. The single side effect, the console.log, sits alone in the thin printReceipt shell that does no thinking of its own. This architecture has a name that has quietly become gospel in serious codebases: "functional core, imperative shell." A large, reliable, easily tested pure core, wrapped in a small, boring, contained impure boundary.
Why does this matter so much in practice? Because bugs cluster where logic meets effects. If your logic is tangled up with your logging and your database writes, every test needs the whole world stood up, and every change risks breaking something three layers away. Separate them, and the scary part (the effects) becomes tiny and obvious, while the big part (the logic) becomes safe to test and refactor at will. This is why we spent an entire phase learning to think in small, composable, pure functions -- it all points here.
How other languages handle this
Since quite some of you arrived from the Learn Python Series (with a few Rust and Go readers in tow), a look sideways nails the idea down, because purity is a universal concept, not a JavaScript quirk.
Python has the exact same distinction and the exact same mutation trap -- lists are passed by reference just like JS arrays, so a function that does lst.append(x) mutates the caller's list. The pure version returns a new list in stead:
# IMPURE: mutates the caller's list
def add_item_bad(items, x):
items.append(x)
return items
# PURE: returns a new list, original untouched
def add_item_good(items, x):
return [*items, x] # a fresh list, same spirit as JS spread
before = [1, 2, 3]
after = add_item_good(before, 4)
print(before, after) # [1, 2, 3] [1, 2, 3, 4]
Same lesson, same fix, different syntax. Python programmers who care about this reach for tuples (which are immutable) and copy-on-write helpers for precisely the reasons we just covered.
Rust takes the idea and bakes it into the compiler, which is genuinely eye-opening. In Rust, whether a function may mutate its argument is part of the type: a plain &T is a read-only borrow, and you need an explicit &mut T to change anything. The compiler simply refuses to let a function that took a shared reference mutate it:
fn total(items: &[i32]) -> i32 {
// &[i32] is an immutable borrow - we literally CANNOT mutate 'items' here.
items.iter().sum()
}
fn main() {
let nums = vec![10, 20, 30];
println!("{}", total(&nums)); // 60, and 'nums' is provably untouched
}
Where JavaScript relies on you to have the discipline not to mutate, Rust makes impurity something you have to ask for out loud. That is the same purity instinct we are cultivating by hand, promoted to a language guarantee. Haskell, the functional purist of the family, goes furthest of all: functions are pure by default and side effects are corralled into an explicit IO type, so the compiler literally tracks which code touches the outside world. The takeaway is the portable one: pure-versus-impure is a fundamental axis of all programming. JavaScript just leaves the discipline in your hands, which is exactly why learning to see it matters. ;-)
When purity is not the whole answer
Now the honest part, because I would be doing you a disservice to sell purity as a religion. The goal was never "every function must be pure" -- that is impossible, since a program with zero effects does nothing useful. The goal is pure by default, impure on purpose. A few sober notes:
- The effects still have to happen. Do not contort your code into knots trying to make an inherently effectful thing (saving a file, sending a request) look pure. Isolate it at the edge, name it honestly, and move on.
- Copying has a cost. For most code, allocating a fresh array or object is completely negligible and the safety is well worth it. But in a genuinely hot loop over huge data, copy-on-every-change can matter -- and there, local mutation of a variable you created inside the function is still perfectly fine. A function that mutates only its own private locals and never anything it was handed is still pure from the outside. Purity is about observable behaviour, not about never using
let. - Do not over-engineer a script. A ten-line throwaway does not need a ceremonial functional core and imperative shell. Reach for the architecture when the logic is big enough to be worth protecting.
Like every tool in this series, purity is excellent in its place and needless ceremony everywhere else. Aim for clarity: a large, honest, testable pure core, and a small, obvious edge where the messy real-world effects live. Get that split right and, in my experience, entire categories of bug quietly disappear from your life.
Try it yourself
Three exercises, increasing in difficulty. Try to reason each one through before you run it -- full solutions open the next episode.
- Look at these three functions and label each pure or impure, explaining why in one line each:
(a) => a * 2;(arr) => arr.sort();() => new Date().getHours(). (Hint: one of them mutates its argument, and one of them depends on something that changes.) - Take this impure function that mutates its argument,
const discount = (cart) => { cart.total = cart.total * 0.9; return cart; }, and rewrite it as a pure function that returns a new cart object with the discounted total, leaving the original untouched. Then prove the original is unchanged by logging both before and after. - Take the task "given a list of prices, return the ones above 50, each doubled" and structure it as a pure core function (does the filtering and doubling, returns an array) plus a thin impure shell function that only logs the result. Explain, in one sentence, why the pure core is the easy part to test.
So what did we actually cover?
- A pure function has two properties: same input always gives the same output, and no side effects. Break either and it is impure.
- A side effect is any observable interaction with the outside world (mutation, logging, network, DOM, time, randomness); side effects are necessary, not evil -- they just must be controlled and isolated.
- Referential transparency means you can replace a call with its result without changing the program; pure functions have it, which is exactly what lets you reason about code by substitution.
- Pure functions are trivially testable, easy to reason about, cacheable (hello, memoization next episode), safe to compose, safe to run in parallel, and free of spooky action at a distance.
- Mutation of arguments is a sneaky side effect because objects and arrays pass by reference; the copy-instead-of-mutate habit (
[...list, item],{ ...obj, field: v }) keeps functions pure over structured data. - The winning architecture is a pure "functional core" doing all the logic, wrapped in a thin "imperative shell" that holds the effects at the edge.
- Purity is a universal idea: Python has the same trap and fix, Rust encodes it in the type system, Haskell tracks effects in the compiler. JavaScript leaves the discipline to you.
Next episode we collect on the biggest promise purity makes: because a pure function always returns the same output for the same input, we can remember past results and skip the work entirely on repeat calls. We will build that caching layer with closures and see exactly why it only works when the function is pure.