Mert Özen Further With Every Line
Frontend JavaScript Intermediate — #02

JavaScript Array Methods in Depth: map, filter, reduce, toSorted and Object.groupBy

Mert Özen Nov 3, 2026 9 min 6 views
JavaScript Array Methods in Depth: map, filter, reduce, toSorted and Object.groupBy

We make sense of map, filter, reduce, the find family, some/every and flatMap with an assembly line analogy, then try the non-mutating toSorted and with (ES2023) and grouping with Object.groupBy (ES2024).

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

Let's start with one innocent-looking line. We want to turn an array of strings into numbers:

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

What does this print to the console? Make a guess before you read on. Most people say [1, 2, 3]. We will talk about the answer once we have seen how map calls its callback.

Think of an Assembly Line: map

We already walked through arrays with a for loop and with forEach. The methods in this post do the same job with a different mindset: instead of "how do I loop," you focus on "what happens to each element." Picture an assembly line in a factory. Items pass along the belt one by one, each station does something to them, and a new box comes out at the end. The old box stays exactly as it was.

We'll use the same small product list throughout the post. Paste it into your browser console:

const products = [
  { name: "Pen", category: "stationery", price: 25, stock: 120 },
  { name: "Notebook", category: "stationery", price: 60, stock: 0 },
  { name: "Headphones", category: "electronics", price: 900, stock: 8 },
  { name: "Mouse", category: "electronics", price: 450, stock: 15 },
  { name: "Thermos", category: "kitchen", price: 380, stock: 4 }
];

const names = products.map((p) => p.name);
console.log(names); // ["Pen", "Notebook", "Headphones", "Mouse", "Thermos"]

const withTax = products.map((p) => ({ ...p, price: p.price * 1.2 }));
console.log(withTax[0].price, products[0].price); // 30 25

map calls the function you give it once for each element and puts whatever the function returns at the same position in a new array. The result is always an array with the same length as the original. In the second example the arrow function returns an object, so we wrapped the object in parentheses; otherwise the engine would read the curly brace as a function body. Thanks to spread, we didn't touch the original products either.

Now back to the opening question. The answer is [1, NaN, NaN]. That's because map passes the callback three arguments, not just the element: the element, its index and the array itself. parseInt, meanwhile, treats its second argument as the radix. So the engine actually runs parseInt("1", 0), parseInt("2", 1) and parseInt("3", 2). A radix of 0 means "use the default," 1 is not a valid radix, and there is no digit "3" in binary. The fix is to say exactly what you want: .map((s) => parseInt(s, 10)) or simply .map(Number).

map, filter, reduce, some and every are actually old friends; they arrived with ES5 back in 2009. Together with arrow functions (ES2015), they got the short, readable form we use today.

filter, find and Friends

filter is a quality-control station. If the callback returns true (or anything truthy), the element moves on to the new array; otherwise it is dropped. The result is always an array, an empty one if nothing passes.

const inStock = products.filter((p) => p.stock > 0);
console.log(inStock.length); // 4

const expensive = products.find((p) => p.price > 400);
console.log(expensive.name); // "Headphones"

console.log(products.findIndex((p) => p.price > 400));      // 2
console.log(products.findLast((p) => p.price > 400).name);  // "Mouse"
console.log(products.findLastIndex((p) => p.price > 400));  // 3
console.log(products.find((p) => p.price > 5000));          // undefined

find stops at the first match and returns the element itself, or undefined if nothing matches. findIndex does the same search but returns the index, or -1. This pair came with ES2015, and ES2023 added findLast and findLastIndex, which search from the end. You used to have to reverse the array for that; now you can go straight to the most recent failed record or the last message.

some and every: Yes or No?

Sometimes you don't need the elements, just a single answer. some asks "does at least one meet the condition?" and every asks "do they all?" Both stop as soon as they know the answer.

console.log(products.some((p) => p.stock === 0));  // true
console.log(products.every((p) => p.price > 10)); // true

The includes method we saw earlier looks for a specific value; some checks a condition. In an array of objects you can't ask "is there one that costs 900?" with includes, but you can with some.

reduce: Boiling an Array Down to One Value

reduce is usually the most feared method, but the idea is simple. Picture a snowball rolling through snow. It grows a little with every element it passes over, and at the end you are holding a single ball. That ball is called the accumulator. At each step the callback receives what has been accumulated so far plus the next element, and returns the new accumulated value.

const totalValue = products.reduce((sum, p) => sum + p.price * p.stock, 0);
console.log(totalValue); // 18470

// step by step:
// sum = 0     + 25 * 120  = 3000
// sum = 3000  + 60 * 0    = 3000
// sum = 3000  + 900 * 8   = 10200
// sum = 10200 + 450 * 15  = 16950
// sum = 16950 + 380 * 4   = 18470

The 0 at the end is the initial value, and you should almost always write it. The accumulator doesn't have to be a number either; it can be an object, an array, anything you like. To count the votes in a poll:

const votes = ["yes", "no", "yes", "yes"];
const counts = votes.reduce((acc, vote) => {
  acc[vote] = (acc[vote] ?? 0) + 1;
  return acc;
}, {});
console.log(counts); // { yes: 3, no: 1 }

In a body with curly braces, don't forget the return acc line; if you do, the accumulator is undefined on the next step. And an honest note: reduce is powerful, but it isn't the tool for every job. If you are doing three different things inside one callback, a plain for...of loop is often more readable. For precise sums of decimal numbers, remember Math.sumPrecise, the ES2026 addition we saw earlier.

flat, flatMap and Array.from

When you run into nested arrays, flat flattens them. By default it goes one level deep; pass a number to go deeper, or Infinity to go all the way:

const nested = [1, [2, 3], [4, [5, 6]]];
console.log(nested.flat());         // [1, 2, 3, 4, [5, 6]]
console.log(nested.flat(Infinity)); // [1, 2, 3, 4, 5, 6]

const orders = [
  { id: 1, items: ["Pen", "Notebook"] },
  { id: 2, items: ["Thermos"] }
];
console.log(orders.flatMap((o) => o.items)); // ["Pen", "Notebook", "Thermos"]

flatMap does a map followed by a one-level flat. It is a perfect fit when you want to produce zero, one or several elements from each element; return an empty array from the callback and that element simply doesn't appear in the result. Both came with ES2019.

Array.from, around since ES2015, turns things that aren't arrays but behave like them into real arrays. It can also take a mapping function as its second argument:

console.log(Array.from("cat"));                          // ["c", "a", "t"]
console.log(Array.from({ length: 5 }, (_, i) => i * 2)); // [0, 2, 4, 6, 8]

const paragraphs = Array.from(document.querySelectorAll("p"));
console.log(paragraphs.map((el) => el.textContent.length));

Try the last example in the console of any page. The NodeList returned by querySelectorAll has forEach but no map or filter. Turn it into a real array with Array.from and every method in this post is at your service.

Chaining Methods

Since most of these methods return a new array, you can feed one's output straight into the next. It's like adding stations to the assembly line:

const report = products
  .filter((p) => p.stock > 0)
  .map((p) => `${p.name}: ${p.price} USD`)
  .join(", ");

console.log(report);
// "Pen: 25 USD, Headphones: 900 USD, Mouse: 450 USD, Thermos: 380 USD"

Putting each method on its own line makes the chain read like a recipe: take what's in stock, turn it into text, join it. Every step creates a new array in between; for arrays of a few thousand elements, that's nothing to worry about. If the chain grows long and hard to follow, giving the intermediate results names is always a good idea.

Changing an Array Without Breaking It: toSorted, toReversed, toSpliced, with

We saw earlier that sort changes the array in place and that this leads to surprising bugs. reverse and splice have the same habit. ES2023 brought copying siblings for all three, plus with, which replaces one element and returns a copy:

const prices = [380, 25, 900, 60];

console.log(prices.toSorted((a, b) => a - b)); // [25, 60, 380, 900]
console.log(prices.toReversed());               // [60, 900, 25, 380]
console.log(prices.toSpliced(1, 2, 999));       // [380, 999, 60]
console.log(prices.with(0, 400));               // [400, 25, 900, 60]
console.log(prices.with(-1, 70));               // [380, 25, 900, 70]

console.log(prices); // [380, 25, 900, 60], untouched

We used to get the same safety by copying first and sorting afterwards, as in [...prices].sort(). The new methods state the intent directly. When you want to show a sorted version of a list on screen but keep the underlying data intact, these should be your choice:

const cheapestFirst = products
  .toSorted((a, b) => a.price - b.price)
  .map((p) => p.name);
console.log(cheapestFirst); // ["Pen", "Notebook", "Thermos", "Mouse", "Headphones"]

with counts negative indexes from the end; give it an index outside the array's bounds and it throws a RangeError.

Grouping: Object.groupBy and Map.groupBy

"Group the products by category" used to be a classic reduce exercise. Object.groupBy, which arrived in ES2024, brought it down to one line. The callback returns a group name for each element, and the result is an object that holds, under each group name, an array of the elements that fall into that group:

const byCategory = Object.groupBy(products, (p) => p.category);
console.log(Object.keys(byCategory)); // ["stationery", "electronics", "kitchen"]
console.log(byCategory.electronics.map((p) => p.name)); // ["Headphones", "Mouse"]

const byStock = Object.groupBy(products, (p) => (p.stock > 0 ? "available" : "soldOut"));
console.log(byStock.soldOut[0].name); // "Notebook"

Notice that this isn't an array method but a static function on Object. It went this way because adding it as an array method clashed with code on older websites. The returned object has no prototype either, so it contains only your groups; no inherited property sneaks in.

If you want the group key to be something other than a string, say a boolean or an object, its sibling Map.groupBy is there. It returns a Map:

const byPrice = Map.groupBy(products, (p) => p.price >= 400);
console.log(byPrice.get(true).map((p) => p.name)); // ["Headphones", "Mouse"]

We will cover Map in detail in a later post. Both functions are at Baseline "widely available," so you can use them with confidence in every current browser.

Common Pitfalls

Opening curly braces and forgetting return. products.map((p) => { p.name }) returns five undefined values, because the braces open a function body and nothing is returned from it. Either drop the braces or write return.

reduce without an initial value. On an empty array, [].reduce((a, b) => a + b) throws a TypeError right away. On an array of objects, the first object becomes the accumulator and the result can turn into a meaningless string like "[object Object]...".

Using map as a loop. If you aren't going to use its result, that is, you only want to do something for each element, write forEach or for...of. The reverse holds too: forEach always returns undefined, so there is no point assigning its result to a variable.

every on an empty array. [].every((x) => x > 100) returns true, while some returns false. Logically that's correct (there isn't a single element breaking the condition), but if you run an "are all orders paid?" check on an empty list, it can catch you off guard.

Mixing up find and filter. find returns a single element or undefined; filter always returns an array. if (products.filter(...)) always runs, because even an empty array is truthy.

A Small Experiment

★☆☆ From the products array, produce an array with only the names of products whose stock is below 10. Hint: filter first, then map.

★★☆ Predict before running: const a = [3, 1, 2]; const b = a.toSorted(); const c = a.sort(); console.log(a, b, c, a === c); Which variable points to which array?

★★☆ Use reduce to find the object of the most expensive product. Don't use Math.max or sorting.

★★★ Split the products into categories with Object.groupBy. Then produce an object with the total stock value (price × stock) per category, like { stationery: 3000, electronics: 13950, kitchen: 1520 }. Object.entries, map and Object.fromEntries, available since ES2019, will do the job.

In this post we handed callbacks to other functions all the time, and they reached variables outside of them, such as products, without any trouble. The rules that decide which variables a function can see, why variables sometimes seem to "exist" before they are declared, and how functions carry the environment they were born in are the topic of the next post: scope, hoisting and closures.