map, filter and reduce

Mental model: Stop writing loops. Describe the transformation: same length, fewer items, or one value.

Level: beginner · about 16 minutes

A loop: how it happens

const prices = [10, 20, 30];
const withTax = [];
for (let i = 0; i < prices.length; i++) {
  withTax.push(prices[i] * 1.2);
}

map: what it is

const prices = [10, 20, 30];
const withTax = prices.map((p) => p * 1.2);

Same result, and the second version has no counter, no bounds check and no accumulator to get wrong. The loop describes machinery; the method describes intent.

Three methods cover most of the array work you will ever do. **map transforms every element and gives you an array of the same length. filter selects elements and gives you a shorter (or equal) array. reduce** combines everything into one value, which can be a number, a string, an object or another array.

map: same length, new values

const names = ['ada', 'grace'];

console.log(names.map((name) => name.toUpperCase())); // ['ADA', 'GRACE']
console.log(names.map((name, index) => index + ':' + name)); // ['0:ada', '1:grace']
console.log(names.map((name, index, whole) => whole.length)); // [2, 2]
console.log(names); // ['ada', 'grace'], the original is untouched

The callback gets three arguments. You usually want the first.

filter: keep what the test likes

const users = [
  { name: 'ada', active: true },
  { name: 'alan', active: false },
  { name: 'grace', active: true },
];

const active = users.filter((u) => u.active);
console.log(active.length);                      // 2
console.log(active.map((u) => u.name));          // ['ada', 'grace']
console.log([0, 1, '', 'x', null].filter(Boolean)); // [1, 'x'], the truthy idiom

reduce: watch the accumulator move

const total = [5, 10, 20].reduce((acc, n, i) => {
  console.log('step', i, 'acc:', acc, 'n:', n, '=>', acc + n);
  return acc + n;
}, 0);

console.log('total:', total);
// step 0 acc: 0  n: 5  => 5
// step 1 acc: 5  n: 10 => 15
// step 2 acc: 15 n: 20 => 35
// total: 35

Log inside the reducer once in your life and it stops being mysterious.

  1. The initial value is the accumulator on step 0 Pass it as the second argument to reduce. Choose it to match the shape of your result: 0 for a sum, "" for a string, [] for an array, {} for a lookup.
  2. Whatever you return becomes the next accumulator Forget the return and the next step receives undefined. This is the most common reduce bug.
  3. Leave the initial value out and element 0 becomes it That is occasionally handy and always risky: on an empty array with no initial value, reduce throws.
  init 0
    │
    ▼
  [ 5 ] ──► acc 5
    │
  [ 10 ] ─► acc 15
    │
  [ 20 ] ─► acc 35
    │
    ▼
  result 35

Chaining reads like a sentence

const cart = [
  { item: 'pen', price: 2, qty: 3, inStock: true },
  { item: 'pad', price: 5, qty: 1, inStock: false },
  { item: 'ink', price: 8, qty: 2, inStock: true },
];

const total = cart
  .filter((line) => line.inStock)          // narrow first
  .map((line) => line.price * line.qty)    // then transform
  .reduce((sum, n) => sum + n, 0);         // then collapse

console.log(total); // 22

reduce is not only for sums

const people = [
  { name: 'ada', team: 'core' },
  { name: 'alan', team: 'infra' },
  { name: 'grace', team: 'core' },
];

const byTeam = people.reduce((acc, person) => {
  acc[person.team] ??= [];
  acc[person.team].push(person.name);
  return acc;
}, {});

console.log(byTeam); // { core: ['ada', 'grace'], infra: ['alan'] }

Group into an object: the accumulator is a lookup being filled in.

const words = ['ant', 'bee', 'ape', 'bat'];

console.log(Object.groupBy(words, (w) => w[0]));
// { a: ['ant', 'ape'], b: ['bee', 'bat'] }

console.log(Map.groupBy(words, (w) => w.length));
// Map(1) { 3 => ['ant', 'bee', 'ape', 'bat'] }
const nested = [[1, 2], [3], [4, 5]];
console.log(nested.reduce((acc, part) => acc.concat(part), [])); // [1,2,3,4,5]

const pipeline = [(n) => n + 1, (n) => n * 2, (n) => n - 3];
console.log(pipeline.reduce((value, fn) => fn(value), 5)); // 9

Two more shapes: flattening, and a pipeline of functions.

You wantMethodResult length
every element changedmapsame as input
some elements keptfilterzero to input length
one value out of manyreduceone
a side effect onlyforEachnothing (undefined)
a grouped objectObject.groupByone object
const doubled = [1, 2, 3].map((n) => { n * 2 });
console.log(doubled);

The braces make a function body, and the body has no return, so every call produces undefined. Write (n) => n * 2 for the implicit return, or (n) => { return n * 2; } if you want the braces.

Try it yourself

Build the chain one link at a time

const orders = [
  { id: 1, customer: 'ada', total: 40, paid: true },
  { id: 2, customer: 'alan', total: 15, paid: false },
  { id: 3, customer: 'ada', total: 60, paid: true },
];

console.log(orders.filter((o) => o.paid));
console.log(orders.filter((o) => o.paid).map((o) => o.total));

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

console.log('revenue:', revenue); // 100

Comment out the last line, then add links back one by one. Then rewrite the reduce as a plain loop and compare which you would rather read in six months.

Trace any reduce

function tracedReduce(arr, reducer, initial) {
  return arr.reduce((acc, value, i) => {
    const next = reducer(acc, value, i);
    console.log('i=' + i, 'acc=' + JSON.stringify(acc), 'value=' + JSON.stringify(value), '=>', JSON.stringify(next));
    return next;
  }, initial);
}

console.log('sum:', tracedReduce([1, 2, 3, 4], (a, b) => a + b, 0));
console.log('max:', tracedReduce([3, 9, 4], (a, b) => (b > a ? b : a), -Infinity));
console.log('join:', tracedReduce(['a', 'b'], (a, b) => a + b, ''));

Change the reducer and the initial value. Try building a string, then an object, then an array.

Exercises

Total the in-stock lines

Write totalInStock(items) where each item is { name, price, qty, inStock }. Sum price * qty for the items where inStock is true, and return 0 for an empty list. Use a filter, map and reduce chain.

Word frequencies with reduce

Write wordFrequencies(text) that returns an object mapping each word to how many times it appears. Lowercase everything, split on whitespace, and ignore empty pieces so extra spaces do not create a "" key. An empty string gives {}.

Implement groupBy

Write groupBy(items, keyFn) that returns a plain object whose keys come from keyFn(item) and whose values are arrays of the matching items, in their original order. An empty input gives {}.

Check yourself

What does this log?
6 — With no initial value, reduce starts with element 0 as the accumulator and element 1 as the first value, so it still sums to 6. The danger is the empty array: the same call on [] throws, which is why passing an explicit initial value is the habit worth having.
[1, 2, 3, 4].filter((n) => n > 2).map((n) => n * 10) produces…
[30, 40] — filter keeps 3 and 4, then map multiplies each by 10. Each link returns a new array, so the original [1, 2, 3, 4] is unchanged.
You want the sum of an array of numbers. Which is wrong?
nums.reduce((a, b) => { a + b; }, 0) — The braces need a return, so the reducer hands undefined to the next step and the whole thing collapses. The map in option three is pointless but harmless, and the forEach version works even if it is less declarative.

Common mistakes

  • Braces in an arrow callback without a return, giving an array of undefined.
  • Calling reduce without an initial value on a list that might be empty.
  • Using map for side effects. If you throw the result away, you wanted forEach.

Takeaways

  • map keeps the length, filter shrinks it, reduce collapses it to one value.
  • The reducer must return the next accumulator, every single time.
  • Always pass an initial value unless you have a reason not to.
  • reduce builds objects and arrays as happily as numbers, and Object.groupBy now covers the common grouping case.