Sign in

Libre University uses your GitHub account. Signing in is only needed to sit a final test, so the score is kept on your profile.

Map, filter and reduce

Nearly every loop over an array does one of three things, and a reader has to get to the tenth line before finding out which.

The previous lesson wrote all three by hand: a walk that combines everything into one value, a walk that keeps some of the elements, and a walk that replaces each element with something computed from it. This lesson gives them their names and their methods. If you have arrived here directly, what is assumed is an array, a for loop over one, and the fact from the functions lesson that a function is itself a value and can be passed as an argument.

A function passed to a function

All three methods work the same way: you hand them a function, and they call it for you, once per element. A function passed to another function like this is a callback.

const double = x => x * 2;
console.log([1, 2, 3].map(double));
// [2, 4, 6]

map did the walking, double did the deciding, and the two are separated. That separation is the whole idea. The walking is identical every time and is now written once, inside the language; the only part that varies from case to case is the small function you supply, and it sits in one place where a reader can see it.

The callback is usually written inline as an arrow function, since it is rarely wanted anywhere else:

console.log([1, 2, 3].map(x => x * 2));

Two facts about the call are worth knowing now, because both cause bugs. The callback is given three arguments, not one: the value, the index, and the whole array. You may ignore the ones you do not need, and almost always do. But a callback that accepts more parameters than you intended will receive them, which produces the single most quoted trap in the language:

console.log(["1", "2", "3"].map(Number));
// [1, 2, 3]
console.log(["1", "2", "3"].map(parseInt));
// [1, NaN, NaN]

Number takes one argument and ignores the rest. parseInt takes two, the text and the number base, so it is called as parseInt("2", 1), and base 1 is not a legal base. The fix is to write the callback you meant, x => parseInt(x, 10), and the general lesson is to be deliberate about how many arguments a callback accepts.

map: same length, each element transformed

map calls the function on every element and collects the results into a new array of the same length, in the same order. That length guarantee is the thing to hold on to: a map cannot drop an element and cannot add one.

const scores = [72, 85, 91, 60, 78];
console.log(scores.map(s => s + 5));
// [77, 90, 96, 65, 83]

The original array is untouched. map is not a mutating method, and scores still holds its five original values afterwards, which is a large part of why these methods are pleasant to use: a chain of them cannot quietly corrupt the data it started from.

The commonest misuse is trying to filter with it. A callback that returns nothing for the elements you want to skip does not skip them; it returns undefined for them, because map always produces one output per input, and you end up with an array full of holes. If the length should change, map is the wrong method.

filter: same elements, fewer of them

filter calls the function on every element and keeps the ones for which it returns a truthy value. The callback is a predicate: a function whose job is to answer yes or no.

console.log(scores.filter(s => s >= 70));
// [72, 85, 91, 78]

Elements are kept unchanged, so a filter can only shorten. If nothing passes, the result is [], not null, which is one of the reasons the empty array is a value worth being comfortable with: code downstream of a filter has to work when nothing matched, and an empty array flows through the rest of a pipeline without special handling.

The truthiness rules from the decisions lesson apply here in full, so a predicate that returns a number rather than a boolean will behave in ways you did not intend when that number is 0. Return a comparison.

Three relatives are worth knowing, because reaching for filter when you want one of them is wasteful and less clear. find returns the first matching element, or undefined. findIndex returns its position, or -1. some and every return booleans: whether at least one element passes, and whether all of them do. All four stop as soon as the answer is settled, while filter always walks the whole array.

Example. From scores, produce an array of the passing scores expressed as percentages of 120, rounded to one decimal place. What comes out?

const passing = scores.filter(s => s >= 70);
const asPercent = passing.map(s => Math.round((s / 120) * 1000) / 10);
console.log(asPercent);
// [60, 70.8, 75.8, 65]

Filter first, then map: the order matters for cost, since mapping first would convert the score of 60 that is about to be discarded. The arithmetic on the first element is 72/120=0.6, which is 60 per cent exactly, and on the second 85/120=0.70833 to five places, which rounds to 70.8.

Now you. From [3, 8, 12, 5, 20], produce the squares of the values above 6. What is the result, and what would you get if you mapped before filtering with the same predicate?

Answer
console.log([3, 8, 12, 5, 20].filter(v => v > 6).map(v => v * v));
// [64, 144, 400]

Mapping first gives [9, 64, 144, 25, 400], and filtering that with v > 6 keeps every one of them, since all five squares exceed 6. The result would be wrong, not merely slower: a predicate written for the original values does not mean the same thing applied to transformed ones.

reduce: many values into one

reduce is the general one, and the only one of the three that people find genuinely hard on first meeting. It walks the array carrying an accumulator, and its callback takes two arguments, the accumulator so far and the current element, and returns the new accumulator.

console.log([1, 2, 3, 4].reduce((total, v) => total + v, 0));
// 10

Compare it against the hand written version from the previous lesson and the pieces line up exactly:

let total = 0;
for (const v of [1, 2, 3, 4]) {
  total = total + v;
}

The 0 at the end of the reduce call is the initial value, corresponding to let total = 0. The arrow is the body. reduce supplies the loop. Nothing has been added, and the only thing removed is the mutable name.

Always pass the initial value. It is optional, and leaving it off makes reduce use the first element as the starting accumulator and begin at the second. That works for a sum of a non-empty array and fails in two ways. On an empty array it throws: [].reduce((a, b) => a + b) gives TypeError: reduce of empty array with no initial value, while the same call with , 0 returns 0. And when the accumulator is a different type from the elements, as when counting or building a string, the first element is not a valid accumulator at all.

Choosing the initial value is the same question as choosing an accumulator's starting value in a hand written loop: it is the identity for the operation. 0 for a sum, 1 for a product, "" for text, [] for a list. For a maximum there is no natural identity, so -Infinity serves, or you take the first element and guard the empty case as the previous lesson's largest did.

reduce can express map and filter too, since anything that walks an array carrying state can be written as a reduce. That is a fact about its generality and not a recommendation. xs.reduce((out, v) => [...out, v * 2], []) is a map written in a way that hides what it does and copies the whole array on every step, and code review exists partly to catch it.

Example. Compute the mean of the passing scores in [72, 85, 91, 60, 78] using filter and reduce, and check it by hand.

const passing = scores.filter(s => s >= 70);
const mean = passing.reduce((a, s) => a + s, 0) / passing.length;
console.log(mean);
// 81.5

The passing scores are 72, 85, 91 and 78, which sum to 326, and 326/4=81.5. The guard the previous lesson insisted on is still needed: if nothing passed, passing.length is 0 and the mean is NaN. A filter followed by a division is one of the places an empty array actually turns up in practice.

Now you. Using reduce, count how many values in [4, 7, 10, 3, 8] are greater than 5. What is the initial value and why?

Answer
console.log([4, 7, 10, 3, 8].reduce((n, v) => v > 5 ? n + 1 : n, 0));
// 3

The initial value is 0, because the accumulator is a count and the count of nothing is zero. Note that the accumulator is a number while the elements are also numbers, which makes it tempting to leave the initial value off; doing so would start the accumulator at 4, the first element, and give 7 instead of 3.

Pipelines

Because map and filter return arrays, they chain, and a sequence of steps can be written as a sequence of steps:

const total = orders
  .filter(o => o.paid)
  .map(o => o.amount)
  .reduce((sum, a) => sum + a, 0);

Read top to bottom: keep the paid orders, take their amounts, add them up. The equivalent loop is six lines with a mutable accumulator and an if, and it says the same thing in an order the reader has to reconstruct. The convention of one step per line, indented under the source, is worth adopting: it makes a chain of four steps as readable as a list of four instructions.

The habit to build alongside it is to name the intermediate results when the chain gets long or the steps are not obvious. Three anonymous steps are fine; six are a wall. const paid = orders.filter(o => o.paid); costs one line and gives the reader a foothold.

When a loop is still the right answer

These methods are not universally better, and treating them as a style rule rather than a tool produces its own kind of unreadable code. Four cases favour a plain loop.

Stopping early. map and filter always walk the whole array. If you want the first match, find is the right tool, and if you want something more complicated than a first match, a loop with a break is clearer than any chain.

Several results from one walk. Computing the minimum, the maximum and the sum with three chained calls walks the array three times and reads as three unrelated facts. One loop that computes all three is faster and says plainly that they belong together. The cost matters only for large arrays, and the clarity argument applies at any size.

Index arithmetic. Anything comparing an element to its neighbour, or stepping two at a time, or walking backwards, wants an index, and forcing it through reduce produces something nobody can read.

Side effects. If the point of the walk is to print, write or send, use for ... of. Using map and discarding the array it built is a misuse that misleads the reader about what the code is for, and forEach exists for exactly this case.

The honest summary is that map and filter earn their place almost always, reduce earns it for sums, counts and grouping and loses it for anything more elaborate, and a loop is never wrong, only sometimes noisier.

Example. Rewrite this loop as a pipeline, then say whether the rewrite is an improvement.

let count = 0;
for (const w of words) {
  if (w.length > 3) count = count + 1;
}
const count = words.filter(w => w.length > 3).length;

An improvement: it is one line, it has no mutable name, and it says "how many words are longer than three characters" in the order a person would say it. The one cost is an intermediate array that is built and then thrown away for its length, which is irrelevant for anything under a few hundred thousand words.

Now you. Rewrite this as a pipeline, and say why the loop might still be preferred if values is very large.

let sum = 0;
for (const v of values) {
  if (v > 0) sum = sum + v * v;
}
Answer
const sum = values.filter(v => v > 0).map(v => v * v).reduce((a, b) => a + b, 0);

The chain walks the data three times and allocates two intermediate arrays, while the loop walks it once and allocates nothing. For a few thousand values the difference is invisible and the chain is clearer. For tens of millions it is a real cost, and the loop, or a single reduce doing both steps, is the better trade.

A position is a poor name for a field

The three shapes have covered every example so far because every example has been a list of numbers, where an element's meaning is obvious from the array it is in. Real data is not like that. A person is a name, an age and an email, and storing them as ["ana", 34, "[email protected]"] means writing person[1] for the age and remembering forever that 1 means age. Insert a field at the front and every index in the program is wrong, silently.

What is wanted is a value whose parts are reached by name rather than by position. That is an object, and it is next.