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.

(o) => o.statusthe key function reads each recordshippedpending“shipped”id: 1id: 3“pending”id: 2“cancelled”id: 4buckets are created the first time their label appears — insertion order preserved
Each record is read by the key function, which returns a label. The record drops into the bucket for that label, creating the bucket the first time a label appears.

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)
  • items is any iterable — an array, a Set, a NodeList, a generator, anything you can for..of over. 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.

elementthe current itemindexits position (0-based)callbackFnyou write thiskeythe bucket name
The callback runs once per element, receiving the value and its index, and returns the key that decides which bucket the element joins.

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.

before — reduceorders.reduce((acc, o) => {(acc[o.status] ??= []).push(o);return acc; // don’t forget!}, {});seed · lazy-init · push · return4 things to get rightafter — groupByObject.groupBy(orders,o => o.status);intent only1 thing to get right
The same grouping expressed two ways. The reduce version spends most of its lines on bookkeeping the runtime can do for you.

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.groupBykeys are coerced to strings/symbols“shipped”“pending”✓ plain, JSON-friendly output✓ destructure by name✗ can’t key by an object✗ number keys become “1”, “2”…→ null-prototype objectMap.groupBykeys are kept as-is, by identity{ team } ref42 (a number)✓ key by objects, numbers, booleans✓ preserves key type & order✓ no prototype surprises, ever✗ not directly JSON-serializable→ Map
Object.groupBy returns a null-prototype object whose keys are always strings (or symbols). Map.groupBy returns a Map whose keys can be anything, including live object references.

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.

interactiveGroup filenames by extension

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.

callback returnsobject key (a string)true42nullundefined“true”“42”“null”“undefined”
Whatever the callback returns, an object key becomes a string (symbols excepted). The number 1 and the string form of 1 collapse into one bucket.

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.

interactiveNumber key vs string key: collapse or keep apart

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.

interactiveMap keys match by reference, not by shape

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 prototypeObject.create(null). That has two consequences worth knowing.

{} literalshipped: […]pending: […]Object.prototypehasOwnProperty, toString…Object.groupBy resultshipped: […]pending: […]nullno inherited anything
A normal object literal inherits from Object.prototype, carrying methods like hasOwnProperty. A groupBy result has a null prototype — it's a bare bag of your keys, nothing inherited.

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.

Object.groupBy building buckets
1/5
Variables
result="{}"
Start with an empty null-prototype object.

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.

Chrome117Edge117Firefox119Safari17.4Node21Baseline: Newly available since March 2024 · ES2024 · covers both Object.groupBy and Map.groupByOlder targets (Safari < 17.4, Node < 21) need a polyfill or a two-line reduce fallback.
Landing versions for the grouping methods. Baseline turned green in March 2024; treat 'newly available' as production-ready in evergreen environments, with a fallback for older ones.

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) and Map.groupBy(items, fn) sort any iterable into buckets by a key your callback computes — replacing the classic reduce/for grouping boilerplate with one intention-revealing call.
  • The callback receives (element, index) and returns the key. Both methods are static — call them on Object/Map, not on the array.
  • Object.groupBy returns a null-prototype object; keys are coerced to strings (or symbols). Best for string-shaped keys and plain, serializable, destructurable output.
  • Map.groupBy returns a Map; 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; use Object.hasOwn instead.
  • 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 reduce fallback.