DEV Community

mfaysal dev
mfaysal dev

Posted on Originally published at mfaysal.com

JavaScript map, filter and reduce Explained

In JavaScript, map() transforms every item in an array and returns a new array of the same length, filter() returns a new array with only the items that pass a test, and reduce() combines all the items into a single value, like a total or an object. None of them change the original array. Below I explain each one with practical examples, show how to chain them, and cover the mistakes that cost me the most debugging time.

Every example was run with Node.js 20 (and Node 22 for Object.groupBy), and the comments at the end of each block are the real output. The methods work the same way in every modern browser.

The sample data

Most of the examples use this small list of study tasks, saved as data.mjs, because real arrays are usually arrays of objects:

export const tasks = [
  { title: "Physics notes", subject: "Physics", minutes: 45, done: true },
  { title: "Math practice", subject: "Math", minutes: 60, done: false },
  { title: "Chemistry lab report", subject: "Chemistry", minutes: 90, done: false },
  { title: "Math revision", subject: "Math", minutes: 30, done: true },
  { title: "Physics problems", subject: "Physics", minutes: 50, done: false },
];
Enter fullscreen mode Exit fullscreen mode

map(): transform every item

map() calls your function once for each item and builds a new array from whatever you return. Use it when you want "the same list, but each item changed".

import { tasks } from "./data.mjs";

const prices = [100, 250, 80];
const doubled = prices.map((price) => price * 2);
console.log(doubled);

const titles = tasks.map((task) => task.title);
console.log(titles);

// map also passes the index as the second argument
const numbered = titles.slice(0, 2).map((title, i) => `${i + 1}. ${title}`);
console.log(numbered);

console.log(prices); // the original array is unchanged

// Output:
// [ 200, 500, 160 ]
// [
//   'Physics notes',
//   'Math practice',
//   'Chemistry lab report',
//   'Math revision',
//   'Physics problems'
// ]
// [ '1. Physics notes', '2. Math practice' ]
// [ 100, 250, 80 ]
Enter fullscreen mode Exit fullscreen mode

The callback gets the item first and the index second, which is handy for numbering. The input and output always have the same length. If you need fewer items, that's a job for filter().

filter(): keep only some items

filter() calls your function for each item and keeps the item if the function returns something truthy. Use it for "only the items where...".

import { tasks } from "./data.mjs";

const todo = tasks.filter((task) => !task.done);
console.log(todo.map((t) => t.title));

const longMath = tasks.filter((t) => t.subject === "Math" && t.minutes >= 45);
console.log(longMath.length, "long math task(s)");

const nothing = tasks.filter((t) => t.minutes > 500);
console.log(nothing); // no matches gives an empty array, not undefined

// Output:
// [ 'Math practice', 'Chemistry lab report', 'Physics problems' ]
// 1 long math task(s)
// []
Enter fullscreen mode Exit fullscreen mode

When nothing matches, you get an empty array, not undefined or null. That means you can safely call .length or .map() on the result without checking first.

reduce(): combine everything into one value

reduce() is the one that confused me the longest. It walks through the array carrying an accumulator, the running result, and your function returns the new accumulator each time. The second argument to reduce() is the starting value.

import { tasks } from "./data.mjs";

const numbers = [5, 10, 15];
const sum = numbers.reduce((total, n) => total + n, 0);
console.log(sum);

// Total minutes across all tasks
const totalMinutes = tasks.reduce((total, task) => total + task.minutes, 0);
console.log(`${totalMinutes} minutes`);

// Count tasks per subject: the accumulator is an object
const perSubject = tasks.reduce((counts, task) => {
  counts[task.subject] = (counts[task.subject] ?? 0) + 1;
  return counts;
}, {});
console.log(perSubject);

// Output:
// 30
// 275 minutes
// { Physics: 2, Math: 2, Chemistry: 1 }
Enter fullscreen mode Exit fullscreen mode

What helped me was logging each step:

[5, 10, 15].reduce((total, n) => {
  console.log(`total=${total}, n=${n} -> ${total + n}`);
  return total + n;
}, 0);

// Output:
// total=0, n=5 -> 5
// total=5, n=10 -> 15
// total=15, n=15 -> 30
Enter fullscreen mode Exit fullscreen mode

The accumulator doesn't have to be a number. In the perSubject example it's an object that collects counts. The ?? 0 handles the first time a subject appears, when its count doesn't exist yet. Always return the accumulator, otherwise the next step gets undefined.

Grouping: reduce vs Object.groupBy

Grouping items by a key used to be the classic reduce() exercise. Modern JavaScript has a built-in for it:

import { tasks } from "./data.mjs";

// Object.groupBy: Node 21+, and all current major browsers
const bySubject = Object.groupBy(tasks, (task) => task.subject);
console.log(Object.keys(bySubject));
console.log(bySubject.Math.map((t) => t.title));

// Output:
// [ 'Physics', 'Math', 'Chemistry' ]
// [ 'Math practice', 'Math revision' ]
Enter fullscreen mode Exit fullscreen mode

Object.groupBy is available in Node.js 21 and later and in current versions of the major browsers. It isn't in Node 20, which I confirmed by running it there, so if your code has to run on older environments, the reduce() version above still works everywhere.

Chaining map, filter and reduce

Because map() and filter() return arrays, you can chain them. Each step reads like a sentence:

import { tasks } from "./data.mjs";

// Hours of study left on unfinished tasks
const hoursLeft = tasks
  .filter((task) => !task.done)               // keep unfinished tasks
  .map((task) => task.minutes)                 // turn each into its minutes
  .reduce((total, minutes) => total + minutes, 0) / 60; // add them up

console.log(`${hoursLeft.toFixed(2)} hours left`);

// Titles of unfinished tasks, longest first, without changing the original
const plan = tasks
  .filter((t) => !t.done)
  .toSorted((a, b) => b.minutes - a.minutes)
  .map((t) => `${t.title} (${t.minutes} min)`);
console.log(plan);

// Output:
// 3.33 hours left
// [
//   'Chemistry lab report (90 min)',
//   'Math practice (60 min)',
//   'Physics problems (50 min)'
// ]
Enter fullscreen mode Exit fullscreen mode

I put each method on its own line so it's easy to comment out a step while debugging. The second example uses toSorted(), which returns a sorted copy. The older sort() sorts the array in place, which can quietly change data other code depends on.

This style of small, pure functions, where the same data in always gives the same result out, is how I wrote the planner in my study app. Each task gets a score and the plan is built from the sorted list. I wrote about it in building an offline study planner with no backend. Pure functions like these are also very easy to test.

Which one should I use?

When I'm not sure, I ask what I want to get back:

  • An array of the same length, with each item changed? Use map().
  • A shorter array with some items removed? Use filter().
  • A single value, like a number, string or object? Use reduce().

Real tasks usually need more than one. Here's a small report from a list of exam results that uses all three, each for its own job:

const results = [
  { name: "Rafi", mark: 72 },
  { name: "Nusrat", mark: 88 },
  { name: "Tanvir", mark: 35 },
  { name: "Mim", mark: 64 },
];

const passed = results.filter((r) => r.mark >= 40);
const average = passed.reduce((sum, r) => sum + r.mark, 0) / passed.length;
const names = passed.map((r) => r.name).join(", ");

console.log(`${passed.length} passed: ${names}`);
console.log(`Average of passing marks: ${average.toFixed(1)}`);

// Output:
// 3 passed: Rafi, Nusrat, Mim
// Average of passing marks: 74.7
Enter fullscreen mode Exit fullscreen mode

You could do all of this inside one big reduce(), but splitting it up keeps each line simple. I'd rather loop over four items three times than read one clever line that nobody understands a week later. Notice that the average divides by passed.length. If nobody passed, that would be zero and the result would be NaN, so real code should check for an empty list first.

Related methods worth knowing

Sometimes filter() is more than you need:

import { tasks } from "./data.mjs";

console.log(tasks.find((t) => t.subject === "Chemistry").title); // first match
console.log(tasks.some((t) => t.minutes > 60));   // at least one?
console.log(tasks.every((t) => t.minutes >= 30)); // all of them?
console.log(tasks.findIndex((t) => !t.done));     // index of first match

// Output:
// Chemistry lab report
// true
// true
// 1
Enter fullscreen mode Exit fullscreen mode
  • find() returns the first matching item itself (or undefined), not an array.
  • some() and every() return true or false and stop as soon as they know the answer.
  • findIndex() returns the position, or -1 if nothing matches.

If you write filter(...)[0] or filter(...).length > 0, there's a clearer method for it.

Common mistakes with map, filter and reduce

1. Forgetting return in an arrow function with braces

const nums = [1, 2, 3];

const broken = nums.map((n) => { n * 2 });       // braces need a return
const fixed = nums.map((n) => { return n * 2; });
const short = nums.map((n) => n * 2);            // or drop the braces

console.log(broken, fixed, short);

// Returning an object without braces needs parentheses
const objs = nums.map((n) => ({ value: n }));
console.log(objs);

// Output:
// [ undefined, undefined, undefined ] [ 2, 4, 6 ] [ 2, 4, 6 ]
// [ { value: 1 }, { value: 2 }, { value: 3 } ]
Enter fullscreen mode Exit fullscreen mode

With curly braces, the arrow function has a body and needs an explicit return. Without braces, the expression is returned automatically. To return an object literal without braces, wrap it in parentheses, or JavaScript reads the braces as a function body.

2. Passing a function that takes extra arguments

const inputs = ["10", "10", "10"];

console.log(inputs.map(parseInt));                 // index becomes the radix!
console.log(inputs.map((s) => parseInt(s, 10)));   // pass only what you mean
console.log(inputs.map(Number));

// Output:
// [ 10, NaN, 2 ]
// [ 10, 10, 10 ]
// [ 10, 10, 10 ]
Enter fullscreen mode Exit fullscreen mode

map() passes the item, the index and the array. parseInt takes a second argument, the radix, so it receives the index as the base. parseInt("10", 1) is NaN and parseInt("10", 2) is 2. Wrap it in an arrow function, or use Number.

3. Calling reduce on an empty array without a starting value

const empty = [];

try {
  empty.reduce((a, b) => a + b);   // no initial value
} catch (error) {
  console.log(error.constructor.name + ":", error.message);
}

console.log(empty.reduce((a, b) => a + b, 0)); // initial value makes it safe

// Output:
// TypeError: Reduce of empty array with no initial value
// 0
Enter fullscreen mode Exit fullscreen mode

Without an initial value, reduce() uses the first item as the start, and with no first item it throws. I always pass an initial value. It also makes the type of the result obvious to anyone reading.

4. Mutating objects inside map

const users = [{ name: "Rafi", active: false }];

// Wrong: this changes the original objects
const activated = users.map((u) => { u.active = true; return u; });
console.log(users[0].active); // true - the original was modified

// Right: return a new object with spread
const users2 = [{ name: "Rafi", active: false }];
const activated2 = users2.map((u) => ({ ...u, active: true }));
console.log(users2[0].active, activated2[0].active);

// Output:
// true
// false true
Enter fullscreen mode Exit fullscreen mode

map() creates a new array, but the objects inside are the same objects. Changing them changes the original data. Spread into a new object instead.

5. Using map just to loop

If you don't use the returned array, use forEach() or a for...of loop. A map() whose result is thrown away tells the reader the wrong thing.

FAQ

What's the difference between map and forEach?

map() returns a new array built from your return values. forEach() returns undefined and is only for side effects, like logging or updating the page.

Do map, filter and reduce change the original array?

No, they return new values. But if the items are objects and your callback modifies them, those objects are changed, because both arrays point to the same objects.

Is chaining slow because it loops several times?

Each step is a separate loop, but for the arrays most apps deal with (hundreds or a few thousand items) the difference is too small to notice. Readability wins. If you ever process huge datasets, measure before optimising.

Can I use these methods on objects?

Not directly, but Object.entries(obj) turns an object into an array of [key, value] pairs you can map and filter, and Object.fromEntries() turns it back.

Conclusion

Use map() to change every item, filter() to keep some items and reduce() to boil them down to one value, and chain them to describe a data pipeline step by step. Always give reduce() a starting value, return from braced arrow functions, and avoid mutating objects inside callbacks.

MDN's reference for Array.prototype.reduce() has more examples, and the pages for map and filter are linked from there.

Originally published at mfaysal.com.

Top comments (0)