Grouping with Object.groupBy & Map.groupBy
You have a flat list and you need it sorted into piles. Orders by status. Users by country. Log lines by severity. Files by extension. This is one of the most common shapes of work in everyday code, and for years JavaScript made you write the plumbing by hand every single time.
const orders = [
{ id: 1, status: "shipped" },
{ id: 2, status: "pending" },
{ id: 3, status: "shipped" },
{ id: 4, status: "cancelled" },
];
// The ritual: an accumulator, a lookup, a lazy-init, a push.
const byStatus = orders.reduce((acc, order) => {
(acc[order.status] ??= []).push(order);
return acc;
}, {});
That reduce works, but look at how much of it is ceremony. You have to seed an empty object, remember the ??= (or the older acc[key] = acc[key] || [] dance), push, and — the part everyone forgets at least once — return acc at the end. Miss that return and the whole thing quietly collapses to undefined. It’s four moving parts to express one idea: put each order in the pile named by its status.
ES2024 gave that idea its own verb. Two of them, actually.
const byStatus = Object.groupBy(orders, (order) => order.status);
One line. No accumulator, no return, no lazy-init. You hand it the list and a function that answers “which pile?” for each item, and it hands back the piles.
The mental model: a sorting machine
Picture a conveyor belt. Records ride along it one at a time. Above the belt sits a function — the key selector — that reads each record and calls out a label. Below, a row of buckets. Each record drops into the bucket matching its label, and if no bucket with that label exists yet, one is created on the spot.
That’s the whole idea, and it maps exactly onto the API. The list is the belt. The callback is the label-caller. The result is the row of buckets, each one an array holding its members in the order they arrived.
The signature
Both methods are static — you call them on the Object or Map constructor, not on the array. They take the same two arguments.
Object.groupBy(items, callbackFn)
Map.groupBy(items, callbackFn)
itemsis any iterable — an array, aSet, aNodeList, a generator, anything you canfor..ofover. It doesn’t have to be an array.callbackFn(element, index)runs once per element and returns the key for that element’s group.
The callback receives two arguments, and the second one is easy to overlook: the index of the current element. That’s handy when the pile depends on position, not just content.
Here’s the index pulling its weight — splitting a list into alternating rows for a striped layout:
const rows = ["a", "b", "c", "d", "e"];
const striped = Object.groupBy(rows, (_, i) => (i % 2 === 0 ? "even" : "odd"));
// { even: ["a", "c", "e"], odd: ["b", "d"] }
Retiring the reduce
Let’s put the old way and the new way side by side, because seeing them together is the fastest way to feel why this landed in the language.
The reduce still has a place — it shines when you’re accumulating a summary (a sum, a max, a running object). But when the accumulator is always “an object of arrays keyed by something,” groupBy says exactly that and nothing more. Less code, and less code that can go wrong.
Two flavors: Object vs Map
The two methods do the same grouping. They differ only in what container they hand back — and that difference is entirely about what kinds of keys you need.
Object.groupBy — string keys, plain output
Reach for Object.groupBy when your key is naturally a string (a status, a category, a first letter, a country code) and you want a plain object you can destructure, serialize, or pass around like data.
const files = ["notes.md", "app.js", "readme.md", "index.html", "utils.js"];
const byExt = Object.groupBy(files, (name) => name.split(".").at(-1));
// {
// md: ["notes.md", "readme.md"],
// js: ["app.js", "utils.js"],
// html: ["index.html"]
// }
const { js = [], md = [] } = byExt; // destructuring reads clean
Try it live. Edit the filename list, group it, and watch the buckets rearrange — each bucket is keyed by the extension the callback returned.
There’s one wrinkle to internalize: object keys are always strings or symbols. If your callback returns a number, a boolean, null, or undefined, the key gets coerced to its string form before it becomes a property name. This is just how object properties work, but it surprises people the first time.
The practical trap: if some elements key to 1 (a number) and others to "1" (a string), they land in the same bucket, because both become the property "1". If keeping numbers and strings apart matters, that’s your cue to switch to Map.groupBy.
Add items with either a number 1 key or a string "1" key and watch the difference: Object.groupBy merges them into one "1" bucket, while Map.groupBy keeps two distinct keys.
Map.groupBy — key by anything
Map keys aren’t restricted to strings. A Map can key on a number, a boolean, or — the real superpower — an object reference. So Map.groupBy lets you group by a whole object, not just a stringified label.
const teamA = { name: "Platform" };
const teamB = { name: "Growth" };
const people = [
{ name: "Ada", team: teamA },
{ name: "Ben", team: teamB },
{ name: "Cy", team: teamA },
];
const byTeam = Map.groupBy(people, (p) => p.team);
byTeam.get(teamA); // [{ name: "Ada"… }, { name: "Cy"… }]
byTeam.get(teamB); // [{ name: "Ben"… }]
Notice you retrieve a group with .get(teamA) — passing the actual object as the key. This is also the gotcha: Map keys match by identity, not by shape. A different object with identical properties is a different key.
The demo below groups three people by their team object. Looking up with the original teamA reference finds the group; looking up with a freshly built object of the same shape returns undefined — proof that Map keys match by identity, not by contents.
You can also bucket by a computed non-string value cleanly — no coercion collisions:
const nums = [1, 5, 12, 3, 40, 8];
// key by tens-digit-ish bracket, kept as real numbers
const byBracket = Map.groupBy(nums, (n) => Math.floor(n / 10));
// Map(3) { 0 => [1,5,3,8], 1 => [12], 4 => [40] }
byBracket.get(0); // [1, 5, 3, 8] ← 0 is a real number key
The null-prototype detail
Object.groupBy doesn’t return a normal {} object. It returns an object with no prototype — Object.create(null). That has two consequences worth knowing.
First: iteration is clean. Because nothing is inherited, a for..in over the result sees only your groups — there’s no risk of stray inherited enumerable properties, and no hasOwnProperty guard needed. It’s a pure bag of keys.
Second: it’s immune to prototype pollution. With a normal object, a key of "__proto__" is a landmine — assigning to it can mangle the object’s prototype chain. Since the groupBy result has no prototype, a group literally named __proto__ is just an ordinary own property, safe and boring. That matters when you’re grouping by user-supplied strings.
const tricky = [{ g: "__proto__" }, { g: "constructor" }, { g: "__proto__" }];
const grouped = Object.groupBy(tricky, (x) => x.g);
grouped.__proto__; // your array [ {g:"__proto__"}, {g:"__proto__"} ] — a real key!
grouped.constructor; // your array — not the Object constructor
The flip side: because there’s no prototype, the usual object methods aren’t there. grouped.hasOwnProperty is undefined. If you need those, borrow them (Object.hasOwn(grouped, key) is the modern, safe check) or spread into a plain object with { ...grouped }.
Walk one through, step by step
To make the accumulation concrete, here’s Object.groupBy building its result one element at a time. Watch the buckets appear as new keys show up.
The order of the keys is not random: it’s the order each key was first encountered while walking the input. "fruit" appears before "veg" because an apple came before any kale. Map.groupBy preserves the same first-seen order in its key iteration.
Real patterns you’ll actually reach for
A few groupings that come up constantly, so you have the shapes ready.
Index a list by id for O(1) lookup — when ids are unique, each group is a one-element array, which is a small tax for a huge convenience:
const users = [{ id: "a1", name: "Ada" }, { id: "b2", name: "Ben" }];
const byId = Object.groupBy(users, (u) => u.id);
byId.a1[0].name; // "Ada"
If you truly want a single value per key rather than an array, that’s a different tool — Map from entries, or Object.fromEntries — but groupBy is specifically the many-per-key answer.
Bucket numbers into named ranges:
const scores = [92, 55, 78, 88, 40, 99, 63];
const grades = Object.groupBy(scores, (s) =>
s >= 90 ? "A" : s >= 70 ? "B" : s >= 50 ? "C" : "F"
);
// { A: [92, 99], C: [55, 63], B: [78, 88], F: [40] }
Partition into pass/fail — grouping with a boolean-ish key is the readable way to split a list in one pass:
const [passing = [], failing = []] =
Object.values(Object.groupBy(scores, (s) => (s >= 60 ? "pass" : "fail")));
(For a strict two-way split you can also lean on Map.groupBy with real true/false keys — no string coercion, and .get(true) reads nicely.)
Support and how to reach for it safely
Both methods are ES2024, and both are Baseline: Newly available — meaning they work in the current versions of every major browser but are recent enough that you can’t yet assume they’re everywhere your users are.
If you ship to older Safari, older Node, or long-tail devices, you have two easy outs. A polyfill (the core-js implementations are spec-accurate) makes the real methods available everywhere. Or, if you’d rather not add a dependency, the fallback is the same reduce you were writing before — three lines you can wrap once and forget:
const groupBy =
Object.groupBy ??
((items, fn) =>
[...items].reduce((acc, el, i) => {
(acc[fn(el, i)] ??= []).push(el);
return acc;
}, {}));
Summary
Object.groupBy(items, fn)andMap.groupBy(items, fn)sort any iterable into buckets by a key your callback computes — replacing the classicreduce/forgrouping boilerplate with one intention-revealing call.- The callback receives
(element, index)and returns the key. Both methods are static — call them onObject/Map, not on the array. Object.groupByreturns a null-prototype object; keys are coerced to strings (or symbols). Best for string-shaped keys and plain, serializable, destructurable output.Map.groupByreturns aMap; keys can be objects, numbers, or booleans, matched by identity. Reach for it to key by an object reference or to keep numeric/boolean keys distinct.- Grouped arrays hold the same element references as the source — a rearrangement, not a copy. Key order is first-seen order.
- The null prototype makes results clean to iterate and immune to
__proto__prototype-pollution — but the usual instance methods (hasOwnProperty) aren’t there; useObject.hasOwninstead. - Both are ES2024, Baseline: Newly available (Chrome/Edge 117, Firefox 119, Safari 17.4, Node 21, since March 2024). For older targets, polyfill or drop in a tiny
reducefallback.