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 untouchedThe 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: 35Log inside the reducer once in your life and it stops being mysterious.
- 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:0for a sum,""for a string,[]for an array,{}for a lookup. - Whatever you return becomes the next accumulator Forget the
returnand the next step receivesundefined. This is the most common reduce bug. - 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,
reducethrows.
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)); // 9Two more shapes: flattening, and a pipeline of functions.
| You want | Method | Result length |
|---|---|---|
| every element changed | map | same as input |
| some elements kept | filter | zero to input length |
| one value out of many | reduce | one |
| a side effect only | forEach | nothing (undefined) |
| a grouped object | Object.groupBy | one 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,
reducestarts 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] —
filterkeeps 3 and 4, thenmapmultiplies 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 areturn, so the reducer handsundefinedto the next step and the whole thing collapses. Themapin option three is pointless but harmless, and theforEachversion works even if it is less declarative.
Common mistakes
- Braces in an arrow callback without a
return, giving an array ofundefined. - Calling
reducewithout an initial value on a list that might be empty. - Using
mapfor side effects. If you throw the result away, you wantedforEach.
Takeaways
mapkeeps the length,filtershrinks it,reducecollapses 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.groupBynow covers the common grouping case.