13 — Higher-Order Functions: Composition, Currying & Partial Application
Map, Filter, Reduce: The Functional Trinity
javascript
// ── A higher-order function takes or returns a function ──
// map, filter, reduce are the foundation of functional array processing.
// ── map: transform each element (1:1 mapping) ──
const doubled = [1, 2, 3].map(x => x * 2); // [2, 4, 6]
// map calls the callback for each element, collects results into a new array.
// Returns a new array of the SAME LENGTH (always — even if callback returns undefined).
// ── filter: select elements that pass a test (1:0 or 1:1) ──
const evens = [1, 2, 3, 4].filter(x => x % 2 === 0); // [2, 4]
// filter calls the callback for each element, keeps elements where callback returns truthy.
// Returns a new array (possibly shorter or empty).
// ── reduce: fold to a single value (N:1) ──
const sum = [1, 2, 3, 4].reduce((acc, x) => acc + x, 0); // 10
// reduce calls the callback with (accumulator, current) for each element.
// The accumulator carries state between calls. Initial value is the second argument.
// Step: 0+1=1, 1+2=3, 3+3=6, 6+4=10
// ── Composition: chain map/filter/reduce (each creates a new array) ──
const result = [1, 2, 3, 4, 5, 6]
.filter(x => x % 2 === 0) // [2, 4, 6]
.map(x => x * x) // [4, 16, 36]
.reduce((a, b) => a + b, 0); // 56
// ⚠️ Each step creates an intermediate array (3 arrays total).
// For large arrays, a single reduce is more efficient (one pass, no intermediates):
const efficient = [1, 2, 3, 4, 5, 6].reduce((acc, x) => {
if (x % 2 === 0) return acc + x * x; // filter + map + accumulate in one pass
return acc;
}, 0); // 56 (same result, one loop, no intermediate arrays)
Function Composition
javascript
// ── compose: right-to-left function composition ──
// compose(f, g, h)(x) = f(g(h(x)))
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
const square = x => x * x;
const addOne = x => x + 1;
const double = x => x * 2;
const transform = compose(double, addOne, square);
// Applied right-to-left: square(3)=9 → addOne(9)=10 → double(10)=20
console.log(transform(3)); // 20
// ── pipe: left-to-right composition (more intuitive reading order) ──
// pipe(f, g, h)(x) = h(g(f(x)))
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const transformPipe = pipe(square, addOne, double);
// Applied left-to-right: square(3)=9 → addOne(9)=10 → double(10)=20
console.log(transformPipe(3)); // 20 (same result, different order)
// ── Async pipe (each function is async) ──
const pipeAsync = (...fns) => (x) =>
fns.reduce(async (acc, fn) => fn(await acc), x);
const asyncPipeline = pipeAsync(
async x => x + 1, // 5 → 6
async x => x * 2, // 6 → 12
async x => x - 3, // 12 → 9
);
asyncPipeline(5).then(console.log); // 9
// ── Compose with multiple arguments ──
const composeMulti = (...fns) => fns.reduceRight((f, g) => (...args) => f(g(...args)));
const addAndMultiply = composeMulti(
x => x * 2, // multiply by 2
(a, b) => a + b // add two numbers
);
addAndMultiply(3, 4); // (3+4)*2 = 14
Currying and Partial Application
javascript
// ── Currying: transform f(a, b, c) into f(a)(b)(c) — one arg at a time ──
const curry = (fn) => {
return function curried(...args) {
if (args.length >= fn.length) { // fn.length = number of params
return fn.apply(this, args); // enough args → call
}
return (...more) => curried(...args, ...more); // not enough → return a new function
};
};
const add = curry((a, b, c) => a + b + c);
add(1)(2)(3); // 6 (fully curried — one arg at a time)
add(1, 2)(3); // 6 (partial application — first two, then last)
add(1, 2, 3); // 6 (all at once — same as calling the original)
// ── Currying for reusable partial functions ──
const log = curry((level, message) => console.log(`[${level}] ${message}`));
const logInfo = log("INFO"); // partial: level fixed to "INFO"
const logError = log("ERROR"); // partial: level fixed to "ERROR"
logInfo("started"); // [INFO] started
logError("crashed"); // [ERROR] crashed
// ── Currying for filter predicates ──
const matches = curry((prop, value, obj) => obj[prop] === value);
const findByName = matches("name");
const findAlice = findByName("Alice");
const users = [{ name: "Alice" }, { name: "Bob" }];
users.filter(findAlice); // [{ name: "Alice" }]
// ── Partial application: pre-fill some args (not full currying) ──
const partial = (fn, ...preset) => (...rest) => fn(...preset, ...rest);
const add10 = partial((a, b) => a + b, 10);
add10(5); // 15
// ── Bind as partial application ──
const multiply = (a, b) => a * b;
const double = multiply.bind(null, 2); // first arg pre-filled with 2
double(5); // 10
Point-Free Style (Tacit Programming)
javascript
// ── Point-free: define functions without mentioning the data (the "point") ──
// ❌ NOT point-free (explicit data parameter):
const getNames = (users) => users.map(u => u.name);
const getActiveUsers = (users) => users.filter(u => u.active);
const countActive = (users) => getActiveUsers(users).length;
// ✅ POINT-FREE (no explicit data — composed from small functions):
const prop = (key) => (obj) => obj[key];
const filter = (pred) => (arr) => arr.filter(pred);
const map = (fn) => (arr) => arr.map(fn);
const compose = (...fns) => fns.reduceRight((f, g) => (...args) => f(g(...args)));
const getName = prop("name");
const isActive = prop("active");
const getNames2 = map(getName);
const getActiveUsers2 = filter(isActive);
const getActiveNames = compose(getNames2, getActiveUsers2);
// getActiveNames(users) — no `users` parameter mentioned in the definition
// ── Point-free pipeline for data transformation ──
const parseJson = (str) => JSON.parse(str);
const getData = prop("data");
const getItems = prop("items");
const getFirst = (arr) => arr[0];
const getName2 = prop("name");
const getFirstName = compose(
getName2, // get name from the first item
getFirst, // get first item
getItems, // get items from data
getData, // get data from response
parseJson, // parse the JSON string
);
getFirstName('{"data":{"items":[{"name":"Alice"},{"name":"Bob"}]}}'); // "Alice"
Production Pattern: Validator Pipeline
javascript
// ── Composable validators using higher-order functions ──
const Validator = {
// Primitive validators (return error message or null)
required: (value) => value == null ? "required" : null,
minLength: (n) => (value) => value.length < n ? `min ${n} chars` : null,
maxLength: (n) => (value) => value.length > n ? `max ${n} chars` : null,
matches: (regex) => (value) => !regex.test(value) ? "invalid format" : null,
isEmail: (value) => !/^[^@]+@[^@]+$/.test(value) ? "invalid email" : null,
isNumber: (value) => typeof value !== "number" ? "must be a number" : null,
inRange: (min, max) => (value) => value < min || value > max ? `must be ${min}-${max}` : null,
// Compose validators: run all, collect all errors
all: (...validators) => (value) =>
validators
.map(v => v(value))
.filter(Boolean), // returns array of error messages (empty = valid)
// Run validators: first error wins (short-circuit)
first: (...validators) => (value) =>
validators.reduce((err, v) => err || v(value), null),
};
// ── Usage ──
const validateUsername = Validator.all(
Validator.required,
Validator.minLength(3),
Validator.maxLength(20),
);
const validateEmail = Validator.all(
Validator.required,
Validator.isEmail,
);
const validateAge = Validator.first(
Validator.required,
Validator.isNumber,
Validator.inRange(0, 150),
);
console.log(validateUsername("ab")); // ["min 3 chars"]
console.log(validateUsername("alice")); // [] (no errors)
console.log(validateEmail("test@test.com")); // []
console.log(validateEmail("invalid")); // ["invalid email"]
console.log(validateAge(25)); // null (valid)
console.log(validateAge(200)); // "must be 0-150"
💡 Tips & Tricks
javascript
// ── `flow` (pipe alias) for left-to-right readability ──
const flow = (...fns) => (x) => fns.reduce((v, f) => f(v), x);
// ── `tap` for side effects in a pipeline (inspect without modifying) ──
const tap = (fn) => (x) => { fn(x); return x; };
const log = tap(console.log);
const result = pipe(
x => x + 1,
log, // logs the intermediate value, passes it through
x => x * 2,
log, // logs again
x => x - 3,
)(5); // logs 6 and 12, returns 9
// ── `memoize` with a cache (higher-order function for caching) ──
const memoize = (fn) => {
const cache = new Map();
return (...args) => {
const key = JSON.stringify(args);
if (!cache.has(key)) cache.set(key, fn(...args));
return cache.get(key);
};
};
// ── `debounce` as a higher-order function ──
const debounce = (fn, ms) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
};
// ── `once` as a higher-order function ──
const once = (fn) => {
let done = false, result;
return (...args) => done ? result : (done = true, result = fn(...args));
};
// ── `pluck` for extracting a property from each item in an array ──
const pluck = (key) => (arr) => arr.map(obj => obj[key]);
const getNames = pluck("name");
getNames([{ name: "Alice" }, { name: "Bob" }]); // ["Alice", "Bob"]
⚠️ Edge Cases & Gotchas
javascript
// ── `reduce` on an empty array without initial value throws ──
[].reduce((a, b) => a + b); // TypeError: Reduce of empty array with no initial value
[].reduce((a, b) => a + b, 0); // 0 (safe — always provide initial value)
// ── `map` skips holes (sparse arrays) ──
[1, , 3].map(x => x * 2); // [2, <empty>, 6] (hole skipped, not processed)
// Use [...arr].map() or arr.flat() first if you need to process all elements.
// ── `filter` with `Boolean` for truthiness filtering ──
[0, 1, "", "a", null, undefined, NaN, false].filter(Boolean); // [1, "a"]
// Boolean is a function: Boolean(0) = false, Boolean("a") = true
// ── `sort` mutates the original array ──
const arr = [3, 1, 2];
const sorted = arr.sort(); // arr is now [1, 2, 3] (mutated!)
// Use [...arr].sort() or arr.toSorted() (ES2023) for non-mutating sort.
// ── `curry` with rest params: fn.length is unreliable ──
function restFn(...args) { return args.reduce((a, b) => a + b, 0); }
restFn.length; // 0 (rest params don't count toward .length)
// curry(restFn) would call immediately (0 args >= 0 length) — currying doesn't work with rest params.
// ── Composition order: compose (right-to-left) vs pipe (left-to-right) ──
compose(f, g, h)(x) // = f(g(h(x))) — right-to-left
pipe(f, g, h)(x) // = h(g(f(x))) — left-to-right
// Don't mix them up — use the one that reads naturally for your use case.
🧠 Quick Quiz
What does this output?
javascript
const pipe = (...fns) => fns.reduce((f, g) => (...args) => g(f(...args)));
const add = x => x + 1;
const mul = x => x * 2;
const result = pipe(add, mul)(3);
console.log(result);
Answer
8
Let's trace through the pipe implementation:
pipe(add, mul)→fns = [add, mul].reduce((f, g) => (...args) => g(f(...args))):- First iteration:
f = add,g = mul→ returns(...args) => mul(add(...args))
- First iteration:
- So
result = (...args) => mul(add(...args)) result(3)→mul(add(3))→mul(4)→8
The lesson: this pipe implementation applies functions left-to-right (like a pipeline). add runs first (input: 3 → output: 4), then mul runs (input: 4 → output: 8). This is the opposite of compose, which applies right-to-left.