Higher-Order Functions

Mental model: A function is a value. Once you believe that, passing one, returning one and storing one in an array are all the same move.

Level: intermediate · about 13 minutes

const double = (n) => n * 2;

const alias = double;                       // stored in another name
console.log(alias(4));                      // → 8

const apply = (fn, value) => fn(value);     // passed in
console.log(apply(double, 5));              // → 10

const make = () => double;                  // handed back
console.log(make()(6));                     // → 12

A function stored, passed and returned. Nothing else is new in this lesson.

A higher-order function either takes a function as an argument, returns one, or both. You already use several: map, filter, sort, setTimeout and addEventListener all take a function. The idea is not advanced, it is just a consequence of functions being values.

Callbacks: passing behaviour in

function collect(items, keep) {
  const out = [];
  for (const item of items) {
    if (keep(item)) out.push(item);
  }
  return out;
}

const nums = [1, 2, 3, 4, 5, 6];
console.log(collect(nums, (n) => n % 2 === 0));  // → [2, 4, 6]
console.log(collect(nums, (n) => n > 4));        // → [5, 6]
console.log(collect(nums, () => true));          // → all six

One traversal, three behaviours, no duplicated loop.

Returning functions: pipe and compose

const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);

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 There World '));  // → 'hello-there-world'

pipe reads left to right

const f = pipe(trim, lower, dashes);
// trim, then lower, then dashes

compose reads right to left

const compose = (...fns) => (value) =>
  fns.reduceRight((acc, fn) => fn(acc), value);

const g = compose(dashes, lower, trim);
// same result, written in maths order

They are the same function with the fold reversed. pipe matches the order things happen, which is why most codebases prefer it. compose matches the notation f(g(x)), which is why the maths-flavoured libraries prefer it. Pick one per project.

'  Hello There World '
        │
     [ trim ]  ──►  'Hello There World'
        │
     [ lower ] ──►  'hello there world'
        │
     [ dashes ]──►  'hello-there-world'

Currying and partial application

Currying turns a function of several parameters into a chain of one-parameter functions. Partial application fixes some arguments now and leaves the rest for later. They are related but not the same: a curried function insists on one argument at a time, a partially applied one just has fewer left.

// Manual currying: one parameter per arrow.
const add = (a) => (b) => a + b;

const add10 = add(10);
console.log(add10(5), add10(1), add(2)(3)); // → 15 11 5

// Partial application: preset the front, accept the rest later.
const partial = (fn, ...preset) => (...later) => fn(...preset, ...later);

const volume = (l, w, h) => l * w * h;
const flat = partial(volume, 1);
console.log(flat(4, 5));                    // → 20
function curry(fn) {
  return function collect(...args) {
    return args.length >= fn.length
      ? fn(...args)
      : (...more) => collect(...args, ...more);
  };
}

const volume = (l, w, h) => l * w * h;
const c = curry(volume);

console.log(c(2)(3)(4));    // → 24
console.log(c(2, 3)(4));    // → 24
console.log(c(2)(3, 4));    // → 24

A general curry that waits until it has enough arguments.

Wrappers: tap and friends

const tap = (label) => (value) => {
  console.log(label, value);
  return value;              // pass it through untouched
};

const result = pipe(
  (n) => n + 1,
  tap('after add'),
  (n) => n * 10,
  tap('after multiply'),
)(4);

console.log('result:', result);   // → 50

tap is the debugger for a pipeline: it looks at the value in flight and returns it unchanged. The same wrapping trick gives you timing, retries, logging and caching without touching the function being wrapped.

HelperSignatureGives you
pipe(...fns) => (value) => valueleft to right composition
compose(...fns) => (value) => valueright to left composition
curry(fn) => f(a)(b)(c)one argument at a time
partial(fn, ...preset) => (...rest)a specialised version of a general function
tap(label) => (value) => valuea look at the value without changing it
const pipe = (...fns) => (v) => fns.reduce((acc, fn) => fn(acc), v);
const inc = (n) => n + 1;
const dbl = (n) => n * 2;
console.log(pipe(inc, dbl)(5), pipe(dbl, inc)(5));

pipe(inc, dbl) adds first: 6, then doubles to 12. pipe(dbl, inc) doubles first: 10, then adds one to get 11. Composition is not commutative, which is the whole reason the direction has a name.

Try it yourself

Build a pipeline

const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);
const tap = (label) => (v) => { console.log(label, JSON.stringify(v)); return v; };

const words = (s) => s.split(' ');
const lower = (list) => list.map((w) => w.toLowerCase());
const noBlanks = (list) => list.filter((w) => w.length > 0);
const unique = (list) => [...new Set(list)];

const keywords = pipe(
  words,
  tap('split'),
  lower,
  noBlanks,
  tap('cleaned'),
  unique,
);

console.log(keywords('The the  QUICK brown quick'));

Add a step that removes punctuation. Then swap two steps and explain the new output before you run it.

Currying by hand

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

const curried = (a) => (b) => (c) => a + b + c;
console.log(curried(1)(2)(3));

const partial = (fn, ...preset) => (...later) => fn(...preset, ...later);
const from10 = partial(add3, 10);
console.log(from10(1, 2));
console.log(from10(100, 200));

const logAt = (level) => (message) => `[${level}] ${message}`;
const warn = logAt('warn');
console.log(warn('disk almost full'));

Write a curry3 that only handles three-parameter functions, then compare it with the general version. Which would you rather debug?

Exercises

Implement pipe

Write pipe(...fns) which returns a new function. Calling it with a value runs the functions left to right, feeding each result into the next. pipe() with no functions must return the value unchanged.

Implement partial

Write partial(fn, ...preset) which returns a function that calls fn with the preset arguments first, followed by whatever it is called with. The returned function must be reusable, so calling it twice does not accumulate arguments.

Check yourself

What does this log?
7 — twice(inc) returns a function that applies inc to its own result. Calling it with 5 gives inc(inc(5)), which is 7. Taking a function and returning a function is the whole definition of higher order.
What is the difference between currying and partial application?
Currying splits a function into single-argument steps; partial application fixes some arguments and leaves the rest — A curried f(a)(b)(c) takes exactly one argument per step. A partially applied g = partial(f, a) still takes the remaining arguments in one call. Currying is the stricter shape; partial application is the one you reach for daily.
Why does tap return its argument?
To let the pipeline continue with the same value — Every step in a pipeline feeds the next one. If tap returned undefined the whole chain would collapse to undefined after the first inspection, which is exactly the bug people hit when they add a console.log step by hand.

Common mistakes

  • Calling the function instead of passing it: setTimeout(run(), 100) runs it now and schedules its return value.
  • Getting the direction wrong between pipe and compose, then debugging the data instead of the order.
  • Currying a function with defaults or a rest parameter, where fn.length no longer reports what you assume.

Takeaways

  • A higher-order function takes a function, returns one, or both.
  • Two near-identical functions usually want to become one function plus a callback.
  • pipe is reduce over functions; compose is the same fold from the other end.
  • Partial application is the practical half of currying, and tap keeps a pipeline debuggable.