Functional patterns

Advanced50 min

Write pure functions and immutable updates with the non-mutating array methods and spread, compose them with pipe, curry functions for reuse, memoise safely with bounded caches, and know when the functional style costs too much.

What you will learn
  • Spot hidden inputs and outputs that make a JavaScript function impure
  • Update arrays and nested objects without mutation, using toSorted, with and spread
  • Build pipe and curry by hand, and explain why the pipeline operator is not used
  • Memoise a function with a correct cache key, a WeakMap for objects, and a size limit
  • Judge when copying, currying or caching makes code slower or harder to read
On this page

The functional style builds programs from small functions that compute new values instead of changing old ones. The fundamentals lesson on paradigms explains the idea, and two beginner lessons already showed the JavaScript basics: higher-order functions built compose, partial and once, and the recursion lesson memoised Fibonacci with a Map.

This lesson turns those tools into habits you can use in real code. It covers what makes a JavaScript function impure, how to update data without mutating it, pipe and currying, memoisation that does not give wrong answers or eat memory, and the point where the style stops paying for itself. The non-mutating array methods are ES2023, and everything here runs on Node 22.

Pure functions in JavaScript

A pure function’s result depends only on its arguments, and calling it changes nothing outside it. In JavaScript, impurity usually sneaks in through three doors: mutating an argument (objects and arrays are passed by reference), reading something that changes (Date.now(), Math.random(), a module-level variable), and doing input or output.

purity.mjs
function addTagImpure(post, tag) {
post.tags.push(tag);
return post;
}
function addTag(post, tag) {
return { ...post, tags: [...post.tags, tag] };
}
const draft = { title: "Hi", tags: ["intro"] };
const tagged = addTag(draft, "js");
console.log(draft.tags, tagged.tags, draft === tagged);
addTagImpure(draft, "oops");
console.log(draft.tags);
Output
[ 'intro' ] [ 'intro', 'js' ] false
[ 'intro', 'oops' ]

The pure version returns a new post with a new tags array, and the original is untouched. The impure version changes the caller’s object, so anyone else holding draft sees the change, often far from the line that caused it.

A function that needs the time or a random number can stay pure by taking it as a parameter: isExpired(token, now) instead of calling Date.now() inside. The caller passes Date.now() in production and a fixed number in a test. The impure part moves to the edge of the program, and the logic in the middle becomes easy to test.

Updating arrays without mutation

Several array methods change the array they are called on: sort, reverse, splice, and assignment to an index. ES2023 added a copying twin for each, which returns a new array and leaves the original alone.

immutable_arrays.mjs
const scores = [30, 10, 20];
const sorted = scores.toSorted((a, b) => a - b);
const reversed = scores.toReversed();
const removed = scores.toSpliced(1, 1);
const replaced = scores.with(0, 99);
console.log(scores);
console.log(sorted, reversed);
console.log(removed, replaced);
scores.sort((a, b) => a - b);
console.log(scores);
Output
[ 30, 10, 20 ]
[ 10, 20, 30 ] [ 20, 10, 30 ]
[ 30, 20 ] [ 99, 10, 20 ]
[ 10, 20, 30 ]

toSorted, toReversed and toSpliced take the same arguments as sort, reverse and splice. with(index, value) is the copying form of array[index] = value, and it accepts a negative index to count from the end. Together with map, filter, slice, concat and spread, they cover every update without touching the original.

Updating nested objects

Spread copies one level. To change a value deep inside an object, copy each object on the path to it and reuse everything else. That reuse is called structural sharing: the new object shares every branch that did not change with the old one.

nested_update.mjs
function setIn(object, [key, ...rest], value) {
const next = rest.length === 0 ? value : setIn(object[key], rest, value);
return Array.isArray(object)
? object.with(key, next)
: { ...object, [key]: next };
}
const state = {
user: { name: "Ada", prefs: { theme: "light" } },
cart: { items: [1, 2] },
};
const next = setIn(state, ["user", "prefs", "theme"], "dark");
console.log(next.user.prefs.theme, state.user.prefs.theme);
console.log(next.cart === state.cart, next.user === state.user);
Output
dark light
true false

The function recurses along the path, rebuilding each level with spread or with. The parameter list destructures the path into its first key and the rest. The changed branch is new at every level, while cart is the very same object in both states. Sharing makes the copy cheap, and it lets other code detect a change with === instead of comparing every field.

structuredClone, from the copies lesson, is the opposite tool: it copies everything, which is right before you hand data to code you do not trust, and wasteful for a routine update. Object.freeze can guard against accidental mutation in development, but it is shallow, as the descriptors lesson explained.

pipe: composition that reads left to right

The beginner lesson’s compose(f, g) applies g first and then f, which reads right to left. pipe takes the functions in the order they run, which is easier to follow for a data pipeline.

pipe.mjs
const pipe = (...fns) => (input) => fns.reduce((value, fn) => fn(value), input);
const orders = [
{ id: 1, status: "paid", total: 40 },
{ id: 2, status: "open", total: 15 },
{ id: 3, status: "paid", total: 25 },
];
const paidRevenue = pipe(
(list) => list.filter((order) => order.status === "paid"),
(list) => list.map((order) => order.total),
(totals) => totals.reduce((sum, n) => sum + n, 0),
);
console.log(paidRevenue(orders));
Output
65

Each step takes the previous step’s result. You can name the steps, test them one by one, and reorder or remove them without rewriting the others.

TC39 is discussing a pipeline operator, |>, that would write this as an expression. It is a Stage 2 proposal (TC39 proposals list, checked 2026-10-17), its syntax is not settled, and no Node version runs it. Write pipe as a function.

Currying

A curried function takes its arguments one at a time: add(1)(2) instead of add(1, 2). Each call returns a function waiting for the next argument until all have arrived. It is related to partial application from the beginner lesson, but it is automatic: you can stop after any argument.

curry.mjs
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args);
}
return (...more) => curried(...args, ...more);
};
}
const between = curry((min, max, n) => n >= min && n <= max);
console.log(between(1, 10, 5), between(1)(10)(50), between(1, 10)(7));
const isDigitCode = between(48, 57);
console.log([..."a1b2"].map((c) => isDigitCode(c.charCodeAt(0))));
Output
true false true
[ false, true, false, true ]

curry counts the arguments it has collected and compares them with fn.length, the number of parameters the function declares. Once there are enough, it calls fn.

The order of the parameters decides how useful a curried function is. Put the data last and the settings first, as between(min, max, n) does, and fixing the settings gives a one-argument function that drops straight into map, filter or pipe.

fn.length is also curry’s weak spot. It stops counting at the first parameter with a default value, and a rest parameter does not count at all:

curry.mjs (continued)
console.log(((a, b) => 0).length, ((a, b = 1) => 0).length);
console.log(((...all) => 0).length, ((a, ...rest) => 0).length);
const greet = curry((greeting, name = "you") => `${greeting}, ${name}`);
console.log(greet("Hi"));
Output
2 1
0 1
Hi, you

The function with a default for name has a length of 1, so curry calls it as soon as it has one argument and the default kicks in. Curry functions with a fixed number of plain parameters, or pass the count explicitly.

Memoisation done right

Memoising caches a pure function’s results by its arguments, which the problem patterns lesson shows can turn exponential work into linear. A general memoize must answer one question well: what is the cache key?

memo_key.mjs
function memoize(fn, keyOf = (...args) => args[0]) {
const cache = new Map();
return (...args) => {
const key = keyOf(...args);
if (!cache.has(key)) {
cache.set(key, fn(...args));
}
return cache.get(key);
};
}
let calls = 0;
const slowSquare = (n) => {
calls += 1;
return n * n;
};
memo_key.mjs (continued)
const square = memoize(slowSquare);
console.log(square(4), square(4), square(5), "calls:", calls);
const area = memoize((w, h) => w * h);
console.log(area(2, 3), area(2, 10));
const fixed = memoize((w, h) => w * h, (w, h) => `${w}x${h}`);
console.log(fixed(2, 3), fixed(2, 10));
Output
16 16 25 calls: 2
6 6
6 20

The default key is the first argument, which is fine for one-argument functions and wrong for area: area(2, 10) finds the result cached under 2 and returns 6. The fix is a key function that includes every argument. cache.has rather than a truthiness check means a cached 0 or undefined is still a hit.

JSON.stringify(args) is a tempting general key, but it treats { a: 1, b: 2 } and { b: 2, a: 1 } as different keys, gives [undefined] and [null] the same key, drops functions, and throws on a BigInt. Prefer a key built for the function at hand.

When the argument is an object, use a WeakMap keyed by the object itself. The cache entry then lives exactly as long as the object, as the weak collections lesson explained. This only works if the object is never mutated, which is one more reason to pair memoisation with immutable data.

A cache keyed by strings or numbers grows without limit. A Map remembers insertion order, so a small change makes it a least recently used (LRU) cache: move each hit to the end, and evict from the front.

memo_lru.mjs
function memoizeLimited(fn, limit) {
const cache = new Map();
return (key) => {
if (cache.has(key)) {
const value = cache.get(key);
cache.delete(key);
cache.set(key, value);
return value;
}
const value = fn(key);
cache.set(key, value);
if (cache.size > limit) {
cache.delete(cache.keys().next().value);
}
console.log("computed", key, "cache:", [...cache.keys()]);
return value;
};
}
memo_lru.mjs (continued)
const double = memoizeLimited((n) => n * 2, 2);
for (const n of [1, 2, 1, 3, 2]) {
double(n);
}
Output
computed 1 cache: [ 1 ]
computed 2 cache: [ 1, 2 ]
computed 3 cache: [ 1, 3 ]
computed 2 cache: [ 3, 2 ]

The read of 1 moves it to the end, so when 3 arrives the oldest entry is 2, which is evicted and has to be computed again later. cache.keys().next().value is the first key, the least recently used one.

Only memoise pure functions. A memoised function that reads the clock or a database returns the first answer forever.

When not to write it this way

Functional code is easier to reason about, but each technique has a cost. Weigh it the way you would weigh any abstraction.

Copying is not free. Each immutable update allocates new arrays and objects. One update is cheap. A loop that copies the whole array on every step is not: items.reduce((acc, x) => [...acc, x * 2], []) copies acc every time, so its work grows with the square of the length. Mutation that never leaves the function is invisible to callers, so the function stays pure from the outside:

local_mutation.mjs
function histogram(words) {
const counts = new Map();
for (const word of words) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
return counts;
}
const words = ["a", "b", "a"];
console.log(histogram(words), words);
Output
Map(2) { 'a' => 2, 'b' => 1 } [ 'a', 'b', 'a' ]

histogram mutates its own Map freely, never touches words, and returns the same result for the same input. That is pure enough. Reserve copying for data that is shared.

Small anonymous functions hurt debugging. A pipeline of ten arrow functions gives a stack trace of anonymous frames, and stepping through it in a debugger means stepping in and out of reduce. Name the steps that matter (const onlyPaid = (list) => ...) so traces and tests can refer to them.

Currying everything hurts readability. between(1)(10)(n) is clever. In a team codebase, a plain function with named parameters, or one small partial where it helps, is usually clearer. Each partial call also creates a new closure, which a plain call does not.

Caches cost memory and correctness. A memo cache is a long-lived data structure. Bound its size, key it correctly, and only put pure functions behind it. If a function is cheap, a cache lookup can cost more than the work.

Common pitfalls

  • Spreading only the top level. { ...state } copies one level. A nested change needs a copy of every object on the path.
  • Using sort or reverse on shared data. They mutate. Use toSorted and toReversed.
  • Memoising with only the first argument as the key. Include every argument that affects the result.
  • Memoising with JSON.stringify keys. Property order, undefined, functions and BigInt all break it.
  • Currying a function with default or rest parameters. fn.length undercounts, so the function runs too early.
  • Writing |> from a blog post. The pipeline operator is a Stage 2 proposal and a syntax error in Node.
  • Calling a function pure because it returns a value. Check that it also leaves its arguments alone and reads nothing that changes.

Exercises

ExerciseMove an item without mutation

Write moveItem(list, from, to), which returns a new array with the element at index from moved to index to, using toSpliced twice and never mutating list. Test it with const tasks = ["write", "test", "ship", "rest"]: print moveItem(tasks, 0, 2), moveItem(tasks, 3, 0) and finally tasks itself. The expected output is [ 'test', 'ship', 'write', 'rest' ], [ 'rest', 'write', 'test', 'ship' ] and [ 'write', 'test', 'ship', 'rest' ].

Show solution

The first toSpliced removes the item and returns a new array. The second inserts it at the target index into that new array. tasks is never changed.

move_item.mjs
function moveItem(list, from, to) {
const item = list[from];
return list.toSpliced(from, 1).toSpliced(to, 0, item);
}
const tasks = ["write", "test", "ship", "rest"];
console.log(moveItem(tasks, 0, 2));
console.log(moveItem(tasks, 3, 0));
console.log(tasks);
Output
[ 'test', 'ship', 'write', 'rest' ]
[ 'rest', 'write', 'test', 'ship' ]
[ 'write', 'test', 'ship', 'rest' ]
ExerciseData-last helpers

Write three small curried helpers with the data as the last argument: prop(name)(object) returns object[name], where(name, value)(object) returns whether object[name] === value, and sortBy(name)(list) returns a sorted copy ordered by that property. Write them by hand as functions returning functions, without a general curry. Then use them with pipe (write it again, or copy it) to turn the list [{ name: "Kai", team: "red", age: 31 }, { name: "Bo", team: "blue", age: 25 }, { name: "Ari", team: "red", age: 22 }] into the names of the red team, youngest first. The expected output is [ 'Ari', 'Kai' ].

Show solution

Because each helper takes its settings first and the data last, every call such as where("team", "red") produces a one-argument function, and the pipeline reads like a sentence.

data_last.mjs
const pipe = (...fns) => (x) => fns.reduce((value, fn) => fn(value), x);
const prop = (name) => (object) => object[name];
const where = (name, value) => (object) => object[name] === value;
const sortBy = (name) => (list) =>
list.toSorted((a, b) => (a[name] < b[name] ? -1 : 1));
const people = [
{ name: "Kai", team: "red", age: 31 },
{ name: "Bo", team: "blue", age: 25 },
{ name: "Ari", team: "red", age: 22 },
];
const redNames = pipe(
(list) => list.filter(where("team", "red")),
sortBy("age"),
(list) => list.map(prop("name")),
);
console.log(redNames(people));
Output
[ 'Ari', 'Kai' ]
ExerciseA memoised slug pipeline

Build slugify as a pipe of four small named steps: trim the text, lower-case it, replace each run of characters other than a to z and 0 to 9 with a single -, and remove - from both ends. Wrap the whole pipeline in a memoiser that counts how many times the pipeline really runs (keep the count in a variable). Slugify the titles " Hello, World! ", "Tagged Templates & You", " Hello, World! " again and "Proxy/Reflect 101", printing the list of slugs and then the run count. The expected output is the slugs hello-world, tagged-templates-you, hello-world and proxy-reflect-101 in an array (Node prints it over several lines), then runs: 3.

Show solution

The repeated title is a cache hit, so the pipeline runs three times for four titles. Named steps make the pipeline readable and each step testable on its own.

slug_pipeline.mjs
const pipe = (...fns) => (x) => fns.reduce((value, fn) => fn(value), x);
const trim = (s) => s.trim();
const lower = (s) => s.toLowerCase();
const dashes = (s) => s.replace(/[^a-z0-9]+/g, "-");
const trimDashes = (s) => s.replace(/^-+|-+$/g, "");
let runs = 0;
const pipeline = pipe(trim, lower, dashes, trimDashes);
const cache = new Map();
function slugify(text) {
if (!cache.has(text)) {
runs += 1;
cache.set(text, pipeline(text));
}
return cache.get(text);
}
slug_pipeline.mjs (continued)
const titles = [
" Hello, World! ",
"Tagged Templates & You",
" Hello, World! ",
"Proxy/Reflect 101",
];
console.log(titles.map(slugify));
console.log("runs:", runs);
Output
[
'hello-world',
'tagged-templates-you',
'hello-world',
'proxy-reflect-101'
]
runs: 3

Summary

  • A pure function depends only on its arguments and changes nothing outside. In JavaScript, watch for mutated arguments, the clock, randomness and module-level state. Pass changing inputs in as parameters.
  • toSorted, toReversed, toSpliced and with (ES2023) are the copying forms of the mutating array methods. Spread and recursion update nested objects with structural sharing.
  • pipe composes left to right with reduce. The |> operator is a Stage 2 proposal, so do not use it.
  • curry relies on fn.length, which ignores default and rest parameters. Put data last so curried functions fit map, filter and pipe.
  • Memoise only pure functions, with a key that covers every argument, a WeakMap for object arguments and a size limit for the rest.
  • Mutation that stays inside a function is fine. Avoid copying in loops, name pipeline steps, and prefer plain functions over clever currying in shared code.

Next: decorators, a much-discussed proposal for wrapping class members, and the plain-function patterns that do the same job in JavaScript today.