Lesson 11 / 17
Higher-Order Functions
Using a function as a value, the filter-map-reduce chain, functions that produce a comparator, composition, and behavior-adding wrappers.
Contents
Earlier lessons constantly used functions as values: passed as a callback, returned
from a factory, written as a property onto an object, a new one produced with bind.
All of this rests on a single fact — in JavaScript a function is an object; aside from
being callable, it is no different from a number or a string.
A function that takes a function as an argument or returns a function is called a higher-order function. The Programming Fundamentals course’s Introduction to Functional Programming lesson introduced the concept. This lesson applies it to measurement records and shows where the previous lessons’ binding rules touch this usage.
Giving a Function as an Argument
The most common form is an operation on a collection taking what to do from outside. The filter criterion, the transformation rule, and the accumulator operation can each be held in separately named functions.
const records = [ { sensor: "S-01", value: 21.4, time: 1000 }, { sensor: "S-02", value: 19.8, time: 1060 }, { sensor: "S-01", value: 25.1, time: 1120 }, { sensor: "S-03", value: 18.2, time: 1180 }, { sensor: "S-01", value: 22.0, time: 1240 }, ]; const aboveThreshold = (record) => record.value > 20; const getValue = (record) => record.value; const sum = (total, value) => total + value; const selected = records.filter(aboveThreshold); const values = selected.map(getValue); const total = values.reduce(sum, 0); console.log(selected.length); console.log(values.join(",")); console.log(total.toFixed(1)); console.log((total / values.length).toFixed(3)); const groupedBySensor = records.reduce((group, record) => { (group[record.sensor] ??= []).push(record.value); return group; }, Object.create(null)); console.log(Object.keys(groupedBySensor).join(",")); console.log(groupedBySensor["S-01"].join(",")); console.log(typeof aboveThreshold); console.log(aboveThreshold.length);
3 21.4,25.1,22 68.5 22.833 S-01,S-02,S-03 21.4,25.1,22 function 1
Filter, map, and reduce separate three distinct responsibilities: which records are of interest, which piece of information is taken from those records, and how that information gets combined. Each can be tested separately and reused in other contexts.
The grouping example shows the generality of reduce: the accumulator does not have to
be a number, it can also be an object. Building the accumulator with
Object.create(null) is deliberate — because sensor names come from the data source,
this removes any risk of colliding with names coming from the chain.
The last two lines confirm the function is an ordinary value: its type can be queried, its parameter count read, and it can be stored in a variable.
Functions That Return a Function
The second form is a function that produces a function. The produced function carries the producer’s parameters through a closure, giving a specialized function whose behavior is configured from outside.
function byKey(keyFn, reverse = false) { const direction = reverse ? -1 : 1; return (a, b) => { const leftKey = keyFn(a); const rightKey = keyFn(b); if (leftKey < rightKey) return -1 * direction; if (leftKey > rightKey) return 1 * direction; return 0; }; } const records = [ { sensor: "S-02", value: 19.8 }, { sensor: "S-01", value: 25.1 }, { sensor: "S-03", value: 18.2 }, { sensor: "S-01", value: 21.4 }, ]; const format = (list) => list.map((k) => `${k.sensor}:${k.value}`).join(" "); console.log(format([...records].sort(byKey((k) => k.value)))); console.log(format([...records].sort(byKey((k) => k.value, true)))); console.log(format([...records].sort(byKey((k) => k.sensor)))); console.log(format(records));
S-03:18.2 S-02:19.8 S-01:21.4 S-01:25.1 S-01:25.1 S-01:21.4 S-02:19.8 S-03:18.2 S-01:25.1 S-01:21.4 S-02:19.8 S-03:18.2 S-02:19.8 S-01:25.1 S-03:18.2 S-01:21.4
A single byKey function produces three separate sort criteria. The comparison logic
is written once; the only thing that changes is how the key is extracted. This is the
function-level counterpart of the compound-key idea from the Algorithms course’s
Choosing a Sorting Algorithm lesson.
The third line shows the sort’s stability: sorted by sensor name, the two S-01
records keep their relative order from the input. The last line confirms the source is
unchanged — because sort works in place, a copy was taken with spread on every call.
Composition
The second use of returning a function is composing several transformations into a single function. Each step is written on its own; the order is set up once.
function createCalibrator(offset, multiplier) { return (rawValue) => (rawValue + offset) * multiplier; } function compose(...fns) { return (start) => fns.reduce((value, fn) => fn(value), start); } const sensorS01 = createCalibrator(-0.4, 1.02); const sensorS02 = createCalibrator(0.15, 1.0); console.log(sensorS01(21.4).toFixed(3)); console.log(sensorS02(19.8).toFixed(3)); const round = (value) => Math.round(value * 10) / 10; const celsiusToFahrenheit = (value) => value * 1.8 + 32; const pipeline = compose(sensorS01, round, celsiusToFahrenheit, round); console.log(pipeline(21.4)); console.log(pipeline(19.8)); console.log(compose()(5));
21.420 19.950 70.5 67.6 5
compose is itself a reduce: the starting value is the input, the accumulator
operation is “apply the next function.” Called with no arguments, it produces the
identity function — no transformation applied — and this is the sign that the
composition is correctly defined.
createCalibrator and partial application done with bind give the same result. The
difference is in readability and context: the closure form uses no this, which
parameter was fixed is clear from its name, and it is subject to none of the binding
rules.
Passing a Method as a Value
When a method is given to a higher-order function, the previous lessons’ detachment problem returns: what is passed is the function, not the object it sits on.
class MeasurementRecord { constructor(sensor, value) { this.sensor = sensor; this.value = value; } label() { return `${this.sensor}:${this.value}`; } } const records = [new MeasurementRecord("S-01", 21.4), new MeasurementRecord("S-02", 19.8)]; try { console.log(records.map(MeasurementRecord.prototype.label).join(" ")); } catch (error) { console.log(`direct method: ${error.constructor.name}`); } console.log(records.map((record) => record.label()).join(" ")); console.log(records.map(Function.prototype.call, MeasurementRecord.prototype.label).join(" ")); function applyToAll(list, fn, context) { const result = []; for (const item of list) result.push(fn.call(context, item)); return result; } console.log(applyToAll([1, 2, 3], function (n) { return n * this.multiplier; }, { multiplier: 10 }).join(","));
direct method: TypeError S-01:21.4 S-02:19.8 S-01:21.4 S-02:19.8 10,20,30
When the prototype’s method is given directly, this is not bound. The most readable
fix is to wrap the call in an arrow function. The third line is a more indirect path
using map’s context parameter: the mapped function becomes call, and the context
becomes the actual method; every element is passed as call’s first argument. Short to
write, hard to read — a good example of trading readability for brevity.
The last section shows how to offer context support in your own higher-order
functions: the form fn.call(context, item) establishes the same contract as built-in
array methods’ second parameter.
Behavior-Adding Wrappers
The third form takes a function and returns a function that offers the same interface but with extended behavior. The memoization wrapper in the Closures lesson was an example of this; the pattern is general.
function once(fn) { let called = false; let result; return function (...args) { if (!called) { called = true; result = fn.apply(this, args); } return result; }; } function wrapValidated(fn, isValid, message) { return function (...args) { if (!args.every(isValid)) throw new RangeError(message); return fn.apply(this, args); }; } let setupCount = 0; const setup = once((sensor) => { setupCount += 1; return `${sensor} set up`; }); console.log(setup("S-01")); console.log(setup("S-02")); console.log(setupCount); const safeAverage = wrapValidated( (...values) => values.reduce((t, d) => t + d, 0) / values.length, (value) => typeof value === "number" && Number.isFinite(value), "all values must be finite numbers", ); console.log(safeAverage(21.4, 19.8, 25.1).toFixed(3)); try { safeAverage(21.4, "twenty"); } catch (error) { console.log(`${error.constructor.name}: ${error.message}`); } const record = { sensor: "S-01", summary: wrapValidated( function (prefix) { return `${prefix}-${this.sensor}`; }, (a) => typeof a === "string", "prefix must be a string", ), }; console.log(record.summary("measurement"));
S-01 set up S-01 set up 1 22.100 RangeError: all values must be finite numbers measurement-S-01
Both wrappers follow three rules. They accept the same parameters — this is why rest
parameters are used. They give the same return value. And they preserve context: their
bodies are function expressions, not arrow functions, and this is passed along in the
apply call. The last section is a consequence of this third rule — when the wrapped
function is called as an object’s method, this binds to the correct object.
Had the wrapper been written as an arrow function, this would have come from the
scope the wrapper was defined in, and it could not have been used as a method. The
boundary on arrow functions applies here too.
Summary
- A function is a value: it is stored in a variable, given as an argument, returned, and carries properties.
- Filter, map, and reduce separate responsibilities by taking a collection operation’s criterion from outside; reduce’s accumulator can also be an object.
- Functions that return a function produce specialized functions that carry their parameters through a closure; sort criteria and calibrators are typical examples.
- Composition is a reduce that applies transformations, each written on its own, in sequence.
- Giving a method directly to a higher-order function breaks its bond; an arrow function wrapping the call is the most readable fix.
- Wrappers must preserve the same parameters and return value and pass context along
with
apply; this is why they cannot be written as arrow functions.
Next Step
In this lesson, grouping was done with the accumulator built as Object.create(null);
the reason was that sensor names could collide with names coming from the chain. The
same precaution was needed in this course’s first lesson too, and in the closures’
cache. Using an object as a dictionary has this and other limits: keys are converted to
strings, order guarantees are restricted, and element count cannot be read directly.
The next topic opens by introducing the types the language sets aside for this job —
keyed and single-value collections.
To keep your progress and take notes, Log in
My notes
Log in to take notes.