Functional JavaScript

Mental model: Functional programming is a bet that most bugs come from shared mutable state, so you pay a small tax in allocations to make state changes explicit and local.

Level: advanced · about 20 minutes

Functional style is not about avoiding loops or looking clever. It is a response to one observation: the hardest bugs are the ones where something changed and you cannot find out who changed it. Pure functions and immutable data remove that question by construction. Everything else in this lesson is a tool for keeping the code readable once you commit to it.

pure
same inputs, same output, no observable effects. Testable without setup
immutable
operations return new values rather than editing the old one
first class
functions are values: pass them, return them, store them
composition
build big behaviour by wiring small functions together
declarative
say what the result is, not the steps for building it

Immutability without a library

Mutating, and who did it?

function addTax(order) {
  order.total *= 1.2;
  order.items.push('tax');
  return order;
}

const order = { total: 100, items: [] };
addTax(order);
addTax(order);
// total is 144, items has two
// entries, and the caller never
// asked for either

Returning a new value

function addTax(order) {
  return {
    ...order,
    total: order.total * 1.2,
    items: [...order.items, 'tax'],
  };
}

const order = { total: 100, items: [] };
const taxed = addTax(order);
// order is untouched, and
// addTax(addTax(x)) is visibly
// a different thing from addTax(x)

The immutable version allocates more. That is the cost, and it is almost always the right trade: allocation is cheap and predictable, whereas a mutation bug costs an afternoon.

const scores = [3, 1, 2];

console.log(scores.toSorted());        // -> [ 1, 2, 3 ]
console.log(scores.toReversed());      // -> [ 2, 1, 3 ]
console.log(scores.with(0, 99));       // -> [ 99, 1, 2 ]
console.log(scores.toSpliced(1, 1));   // -> [ 3, 2 ]
console.log(scores);                   // -> [ 3, 1, 2 ] untouched

// The old names still mutate in place, which is the trap:
const copy = [...scores];
copy.sort();
console.log(copy, copy.sort() === copy);   // -> [ 1, 2, 3 ] true, sort returns the same array

Since ES2023 the array methods you want exist in non-mutating form.

Composition: pipe and compose

const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
const compose = (...fns) => (x) => fns.reduceRight((v, f) => f(v), x);

const trim = (s) => s.trim();
const lower = (s) => s.toLowerCase();
const dashes = (s) => s.replaceAll(' ', '-');

const slugify = pipe(trim, lower, dashes);
console.log(slugify('  Hello Advanced World  '));   // -> hello-advanced-world

// compose reads right to left, the way f(g(x)) does
console.log(compose(dashes, lower, trim)('  Same Result  '));   // -> same-result

Two functions, four lines, and most of what a functional library sells you.

pipe(a, b, c)(x)        x -> a -> b -> c -> result   (reading order)
compose(a, b, c)(x)     x -> c -> b -> a -> result   (maths order)

Currying and partial application

These are related but not the same. Partial application fixes some arguments now and takes the rest later, in one step. Currying turns an n-argument function into a chain of n one-argument functions, so every argument can be supplied separately.

const partial = (fn, ...fixed) => (...rest) => fn(...fixed, ...rest);

const greet = (greeting, punctuation, name) => `${greeting}, ${name}${punctuation}`;
const hi = partial(greet, 'Hi', '!');

console.log(hi('Ada'));    // -> Hi, Ada!
console.log(hi('Alan'));   // -> Hi, Alan!

// bind does the same thing, with an extra this argument you rarely want
const hey = greet.bind(null, 'Hey', '.');
console.log(hey('Grace')); // -> Hey, Grace.

Partial application is a one-liner you already know how to write.

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) return fn.apply(this, args);
    return (...next) => curried.apply(this, [...args, ...next]);
  };
}

const add3 = (a, b, c) => a + b + c;
const curried = curry(add3);

console.log(curried(1)(2)(3));    // -> 6
console.log(curried(1, 2)(3));    // -> 6
console.log(curried(1)(2, 3));    // -> 6

// The real payoff: specialised functions that read like nouns
const prop = curry((key, obj) => obj[key]);
const name = prop('name');
console.log([{ name: 'Tea' }, { name: 'Mug' }].map(name));   // -> [ 'Tea', 'Mug' ]

Currying, with the usual convenience of accepting several arguments at once.

console.log(['1', '2', '3'].map(parseInt));

map passes three arguments (value, index, array) and parseInt takes two (string, radix), so you get parseInt('1', 0) which is 1, parseInt('2', 1) where radix 1 is invalid so NaN, and parseInt('3', 2) where 3 is not a binary digit so NaN again. This is the standard argument against blind point-free style: map(Number) or map((s) => parseInt(s, 10)) is what you meant.

Transducers: composition without intermediate arrays

A chain of map().filter().map() allocates a full array at every step. A transducer composes the transformations instead of the collections, so the data is walked once and nothing intermediate is built. The trick is that every transformation is expressed as a wrapper around a reducer.

chained:      [1..1000] -> map -> [1..1000] -> filter -> [..] -> reduce
              three arrays allocated, three passes

transduced:   [1..1000] -> (map . filter . step) -> result
              one array, one pass, same code shape
const mapping = (fn) => (step) => (acc, value) => step(acc, fn(value));
const filtering = (pred) => (step) => (acc, value) => (pred(value) ? step(acc, value) : acc);
const composeT = (...ts) => (step) => ts.reduceRight((s, t) => t(s), step);

function transduce(transducer, step, initial, input) {
  const reducer = transducer(step);
  let acc = initial;
  for (const value of input) acc = reducer(acc, value);
  return acc;
}

let mapCalls = 0;
const double = (n) => { mapCalls += 1; return n * 2; };
const isOdd = (n) => n % 2 === 1;

const pushInto = (acc, v) => { acc.push(v); return acc; };
const xform = composeT(filtering(isOdd), mapping(double));

console.log(transduce(xform, pushInto, [], [1, 2, 3, 4, 5]));   // -> [ 2, 6, 10 ]
console.log('doubled only the odd numbers:', mapCalls);         // -> 3

// The same transducer with a different step function, no rewrite
const sum = (acc, v) => acc + v;
console.log(transduce(xform, sum, 0, [1, 2, 3, 4, 5]));         // -> 18

The whole idea in fifteen lines. Read step as "how to add one value to the result".

Maybe-style handling without a library

The problem Maybe solves is a pipeline where any step can produce nothing, and you do not want a null check between every stage. In JavaScript, optional chaining plus ?? covers most of it, and a tiny result object covers the rest.

const ok = (value) => ({ ok: true, value });
const err = (error) => ({ ok: false, error });
const map = (result, fn) => (result.ok ? ok(fn(result.value)) : result);
const chain = (result, fn) => (result.ok ? fn(result.value) : result);

const parse = (text) => {
  const n = Number(text);
  return Number.isFinite(n) ? ok(n) : err(`"${text}" is not a number`);
};
const positive = (n) => (n > 0 ? ok(n) : err(`${n} is not positive`));

const run = (input) => map(chain(parse(input), positive), (n) => n * 2);

console.log(run('21'));    // -> { ok: true, value: 42 }
console.log(run('-3'));    // -> { ok: false, error: '-3 is not positive' }
console.log(run('abc'));   // -> { ok: false, error: '"abc" is not a number' }

A four-line Result type, and why it beats a thrown error in a pipeline.

const config = { server: { ports: null } };

console.log(config.server?.ports?.[0] ?? 8080);      // -> 8080
console.log(config.missing?.deeply?.nested ?? 'default');   // -> default

// Careful: ?? only falls back on null and undefined, || falls back on any falsy value
const port = 0;
console.log(port ?? 8080, port || 8080);   // -> 0 8080

The built-in version of the same idea, for the common case.

Taming shared mutable state

SymptomCauseFix
a value changed and nobody knows whoa shared object passed everywherereturn new values, or centralise writes in a reducer
tests pass alone, fail togethermodule-level mutable statemake state a parameter, or reset in a factory
a callback sees stale dataa captured snapshot of mutable stateread state at call time, or pass it in
array order changes unexpectedlysort or reverse mutating a shared arraytoSorted, toReversed, or copy first
a default value accumulatesa mutable default parameter shared per callbuild it fresh inside the function
const reducer = (state, action) => {
  switch (action.type) {
    case 'add':
      return { ...state, items: [...state.items, action.item], total: state.total + action.price };
    case 'clear':
      return { items: [], total: 0 };
    default:
      return state;
  }
};

const initial = { items: [], total: 0 };
const history = [initial];
for (const action of [
  { type: 'add', item: 'tea', price: 250 },
  { type: 'add', item: 'mug', price: 700 },
  { type: 'clear' },
]) {
  history.push(reducer(history.at(-1), action));
}

console.log(history.map((s) => s.total));      // -> [ 0, 250, 950, 0 ]
console.log(history[1].items, history[2].items); // -> [ 'tea' ] [ 'tea', 'mug' ]  every step survives

The reducer pattern: one place where change happens, and it returns a new state.

Try it yourself

A pipeline you can debug

const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
const tap = (label) => (v) => (console.log(label + ':', v), v);

const words = (s) => s.split(/\s+/).filter(Boolean);
const unique = (arr) => [...new Set(arr)];
const byLength = (arr) => arr.toSorted((a, b) => a.length - b.length || a.localeCompare(b));

const analyse = pipe(
  (s) => s.toLowerCase(),
  tap('lowered'),
  words,
  unique,
  tap('unique'),
  byLength
);

console.log(analyse('The quick brown fox the QUICK fox'));
// -> [ 'fox', 'the', 'brown', 'quick' ]

Add a tap(label) stage that logs the value and passes it through unchanged. Then add an unless(pred, fn) stage and use it to skip a step.

Deep update without a library

function setIn(obj, path, value) {
  const [head, ...rest] = path;
  if (head === undefined) return value;
  return {
    ...obj,
    [head]: setIn(obj[head] ?? {}, rest, value),
  };
}

const state = {
  user: { name: 'Ada', prefs: { theme: 'dark', lang: 'en' } },
  cart: { items: ['tea'] },
};

const next = setIn(state, ['user', 'prefs', 'theme'], 'light');

console.log(next.user.prefs.theme);          // -> light
console.log(state.user.prefs.theme);         // -> dark, untouched
console.log(next.cart === state.cart);       // -> true, unchanged branches are shared
console.log(next.user === state.user);       // -> false, this branch was rebuilt

Make setIn work with array indices as well as object keys. Then prove the untouched branches are shared, not copied, using ===.

Exercises

Write curry

Write curry(fn). The result collects arguments until it has at least fn.length of them, then calls fn with all of them and returns the result. Arguments may arrive one at a time or several at a time, in any grouping. A partially applied function must be reusable (calling it twice with different arguments must not share state), and a curried method must still see the right this.

Transducers from scratch

Write four functions. mapping(fn) and filtering(pred) each take a step function and return a step function. composeT(...transducers) combines them so that the leftmost runs first on each value. transduce(transducer, step, initial, input) walks any iterable once, applying the composed transformation, and returns the accumulated result.

Check yourself

What does this log?
123 true 321 — sort mutates in place and returns the same array reference, so a becomes [1,2,3] and b === a is true. toSorted (ES2023) returns a new array and leaves a alone, so c is [3,2,1] while a stays sorted ascending. Any function that calls .sort() on an argument is mutating its caller's data.
What is the difference between currying and partial application?
Currying turns an n-argument function into a chain of single-argument calls; partial application fixes some arguments now and returns a function taking the rest — Partial application is one step: fix some arguments, get a function that wants the remainder. Currying is a structural transformation: every argument can arrive in its own call. In practice most curry implementations also accept several arguments at once, which blurs the distinction, but the concepts are different and interviewers do ask.
Why does a transducer avoid intermediate arrays?
It composes the reducing functions rather than the collections, so one pass applies every transformation — Each transducer wraps a step function, so composing them builds one combined step. transduce then walks the input once, feeding each value through the whole chain before moving on. No stage ever materialises a collection. Laziness is a different technique (iterator helpers), and nothing is mutated.
Which change makes this function pure? function total(cart) { cart.sum = cart.items.reduce((a, i) => a + i.price, 0); return cart.sum; }
return the computed number without writing it back to cart — The impurity is the write to the caller's object: the function has an observable effect beyond its return value. Computing and returning the number keeps callers in control of what gets stored. Freezing would turn the bug into a thrown error (useful in development, still not pure) and renaming changes nothing at all.

Common mistakes

  • Calling sort, reverse or splice on an argument, which quietly mutates the caller's data.
  • Trusting Object.freeze to be deep.
  • Passing a multi-parameter function straight to map, which supplies three arguments.
  • Mixing pipe and compose in one codebase so nobody knows the reading order.
  • Currying with the data first, which makes the partial useless.
  • Building a shared accumulator inside a curry implementation, so two partials contaminate each other.
  • Using || for defaults where ?? is meant, which swallows 0 and the empty string.
  • Going point-free to the point where a stack trace no longer names anything.

Takeaways

  • Purity and immutability are debugging tools: they remove the question "who changed this?".
  • ES2023 gave arrays non-mutating twins: toSorted, toReversed, toSpliced, with.
  • pipe reads left to right, compose right to left. Pick one and be consistent.
  • Partial application fixes arguments in one step, currying allows one argument per call.
  • Put configuration first and data last, or your curried helpers will not be reusable.
  • Transducers compose reducers instead of collections, giving one pass and no intermediate arrays.
  • A four-line Result object handles fallible pipelines without exceptions or a library.
  • Stop where the reader stops. Elegance that only you can maintain is a liability.