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)); // → 12A 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 sixOne 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 orderThey 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)); // → 24A 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.
| Helper | Signature | Gives you |
|---|---|---|
pipe | (...fns) => (value) => value | left to right composition |
compose | (...fns) => (value) => value | right 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) => value | a 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 appliesincto its own result. Calling it with5givesinc(inc(5)), which is7. 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 appliedg = 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
tapreturn its argument? - To let the pipeline continue with the same value — Every step in a pipeline feeds the next one. If
tapreturnedundefinedthe whole chain would collapse toundefinedafter the first inspection, which is exactly the bug people hit when they add aconsole.logstep 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
pipeandcompose, then debugging the data instead of the order. - Currying a function with defaults or a rest parameter, where
fn.lengthno 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.
pipeisreduceover functions;composeis the same fold from the other end.- Partial application is the practical half of currying, and
tapkeeps a pipeline debuggable.