Grouping an array is done with Object.groupBy() and Map.groupBy(). Standardised in ES2024 and available in every modern browser since March 2024.
This article was published in 2024 and revised in September 2026. The code originally shown called array.groupBy()—an array method that does not exist. Running it throws a TypeError. It has been corrected, and the ES2023 non-mutating methods have been added.
Array.prototype.groupBy()does not exist. The proposal was originallyArray.prototype.group(), was withdrawn over web-compatibility concerns, and shipped instead as the static methodsObject.groupBy()andMap.groupBy()flatMap()(ES2019) andat()(ES2022) are not 2024 additions
Sponsored
When each method was added
| Method | Added | What it does |
|---|---|---|
flatMap() |
ES2019 | Map and flatten one level in one pass |
at() |
ES2022 | Index from the end with negative numbers |
findLast() / findLastIndex() |
ES2023 | Search from the end |
toSorted() / toReversed() / toSpliced() / with() |
ES2023 | Return a new array instead of mutating |
Object.groupBy() / Map.groupBy() |
ES2024 | Group by a key |
Array.fromAsync() |
ES2024 | Build an array from an async iterable |
Object.groupBy(): grouping by key
Pass the array first and a key function second. Note it is a static method on Object, not a method on the array.
const users = [
{ name: 'Alice', age: 28 },
{ name: 'Bob', age: 22 },
{ name: 'Charlie', age: 22 },
{ name: 'David', age: 28 },
];
// users.groupBy(...) throws a TypeError
const grouped = Object.groupBy(users, (user) => user.age);
console.log(grouped);
/*
{
22: [{ name: 'Bob', age: 22 }, { name: 'Charlie', age: 22 }],
28: [{ name: 'Alice', age: 28 }, { name: 'David', age: 28 }]
}
*/
What used to be a reduce() is now one line.
// before
const grouped = users.reduce((acc, user) => {
(acc[user.age] ||= []).push(user);
return acc;
}, {});
// ES2024
const grouped = Object.groupBy(users, (user) => user.age);
Three behaviours to know
1. Keys are always strings. Returning a number still produces a string object key.
const grouped = Object.groupBy(users, (user) => user.age);
grouped[22]; // works (coerced to "22")
grouped['22']; // the same thing
Object.keys(grouped); // ['22', '28'] — strings
2. The returned object has a null prototype. Unlike an object literal, it has no inherited methods.
const grouped = Object.groupBy(users, (u) => u.age);
grouped.hasOwnProperty('22'); // TypeError
Object.hasOwn(grouped, '22'); // true — use this
This is deliberate. With no prototype, a key like "constructor" or "__proto__" cannot cause surprises.
3. The callback receives the index as its second argument.
const chunked = Object.groupBy(items, (item, index) => Math.floor(index / 3));
// splits into groups of three
Sponsored
Map.groupBy(): when keys must not be strings
Use this when you need to keep the key’s type, or use an object as a key. It returns a Map.
const grouped = Map.groupBy(users, (user) => user.age);
grouped.get(22); // works — still a number
grouped.get('22'); // undefined
for (const [age, list] of grouped) {
console.log(age, list.length); // 22 2 / 28 2
}
Objects work as keys too.
const teamA = { id: 1, name: 'Team A' };
const teamB = { id: 2, name: 'Team B' };
const members = [
{ name: 'Alice', team: teamA },
{ name: 'Bob', team: teamB },
{ name: 'Carol', team: teamA },
];
const byTeam = Map.groupBy(members, (m) => m.team);
console.log(byTeam.get(teamA).length); // 2
Object.groupBy() |
Map.groupBy() |
|
|---|---|---|
| Returns | Null-prototype object | Map |
| Key type | Coerced to string | Preserved |
| JSON | JSON.stringify() works directly |
Needs conversion |
| Best for | API responses, display formatting | Object keys, preserving order |
ES2023: methods that do not mutate
These may matter more day to day. sort() and reverse() modify the original array, which causes real bugs with arrays you must not touch—React state, for example.
const scores = [3, 1, 4, 1, 5];
// mutates
scores.sort(); // scores itself is reordered
const copy = [...scores].sort(); // the old workaround
// returns a new array, original untouched
const sorted = scores.toSorted((a, b) => a - b); // [1, 1, 3, 4, 5]
const reversed = scores.toReversed(); // [5, 1, 4, 1, 3]
const replaced = scores.with(0, 99); // [99, 1, 4, 1, 5]
const spliced = scores.toSpliced(1, 2); // [3, 1, 5]
console.log(scores); // [3, 1, 4, 1, 5] — unchanged
| Mutating | Non-mutating |
|---|---|
sort() |
toSorted() |
reverse() |
toReversed() |
splice() |
toSpliced() |
arr[i] = v |
with(i, v) |
toSorted() still needs a comparator. Without one it compares as strings, exactly like sort(), so [10, 9, 100] becomes [10, 100, 9]. That has not changed.
Sponsored
findLast(): searching from the end
const logs = [
{ level: 'info', msg: 'start' },
{ level: 'error', msg: 'connection failed' },
{ level: 'info', msg: 'retrying' },
{ level: 'error', msg: 'timeout' },
];
// the most recent error
const lastError = logs.findLast((log) => log.level === 'error');
console.log(lastError.msg); // 'timeout'
const index = logs.findLastIndex((log) => log.level === 'error');
console.log(index); // 3
No more reversing the array first.
flatMap(): map and flatten together
An ES2019 feature, but worth covering because it comes up constantly. It flattens the result of map() by one level.
const customers = [
{ name: 'Alice', purchases: ['Book', 'Pen'] },
{ name: 'Bob', purchases: ['Notebook', 'Pencil'] },
];
const all = customers.flatMap((c) => c.purchases);
console.log(all); // ['Book', 'Pen', 'Notebook', 'Pencil']
A less well-known trick: returning an empty array drops the element, letting you filter and map in one pass.
const input = ['1', 'abc', '3', '', '5'];
// keep only what converts to a number
const numbers = input.flatMap((s) => {
const n = Number(s);
return s !== '' && !Number.isNaN(n) ? [n] : [];
});
console.log(numbers); // [1, 3, 5]
It flattens one level only. For deeper nesting use flat(Infinity).
at(): reading from the end
const fruits = ['apple', 'banana', 'cherry', 'date'];
fruits.at(-1); // 'date'
fruits[fruits.length - 1]; // the old way
'hello'.at(-1); // 'o' — works on strings too
Out-of-range indices return undefined rather than throwing, so at(-1) on an empty array is safe—use optional chaining afterwards.
const last = list.at(-1);
console.log(last?.name ?? 'none');
Putting them together
Group, aggregate, then sort—a very common sequence.
const orders = [
{ id: 1, category: 'book', price: 1200 },
{ id: 2, category: 'food', price: 800 },
{ id: 3, category: 'book', price: 2400 },
{ id: 4, category: 'game', price: 5800 },
{ id: 5, category: 'food', price: 450 },
];
const byCategory = Object.groupBy(orders, (o) => o.category);
const summary = Object.entries(byCategory)
.map(([category, items]) => ({
category,
count: items.length,
total: items.reduce((sum, o) => sum + o.price, 0),
}))
.toSorted((a, b) => b.total - a.total);
console.table(summary);
/*
| category | count | total |
| game | 1 | 5800 |
| book | 2 | 3600 |
| food | 2 | 1250 |
*/
Because this uses toSorted(), no intermediate array is mutated—safe to write inside a React render.
Support
| Feature | Chrome / Edge | Firefox | Safari | Node.js |
|---|---|---|---|---|
toSorted() etc. (ES2023) |
110 | 115 | 16 | 20 |
Object.groupBy() (ES2024) |
117 | 119 | 17.4 | 21 |
Both were available across modern browsers by March 2024. No polyfill needed.
Summary
array.groupBy()does not exist. UseObject.groupBy(array, fn)orMap.groupBy(array, fn)Object.groupBy()coerces keys to strings and returns a null-prototype object. UseObject.hasOwn()- Use
Map.groupBy()to preserve key types or use objects as keys - ES2023’s
toSorted(),toReversed(),toSpliced()andwith()do not mutate toSorted()still needs a comparator, or it sorts as stringsfindLast()searches from the end- Returning
[]fromflatMap()drops the element flatMap()(ES2019) andat()(ES2022) are not new
For async array work see how to use Promise.any(), and for WeakMap see what WeakMap is actually for.