Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close