Skip to content

JavaScript Array Grouping: Object.groupBy Explained

Object.groupBy Replaces Manual reduce() Grouping

Object.groupBy and Map.groupBy end the reduce()-based grouping pattern developers have hand-written for years. Here's how they work and when to use each.

· · 4 min read

Quick Take

I've written the same reduce() grouping snippet in more codebases than I can count, always slightly different, always worth double-checking. Object.groupBy is that snippet, built in, and I don't have to write it again.

Every grouping snippet I'd written before this used the same shape: reduce(), an accumulator object, a check for whether the key already existed, then push or initialize. It's not hard code, it's just code I shouldn't still be writing by hand for something this common.

TL;DR: Object.groupBy(items, keyFn) groups an array into a null-prototype object keyed by whatever keyFn returns. Map.groupBy(items, keyFn) does the same but returns a Map, preserving non-string key types and avoiding prototype collisions entirely. Both replace the hand-written reduce() grouping pattern, available in Node.js 21+ and all evergreen browsers as of 2024-2025, Baseline by 2026.

The Pattern Everyone Wrote by Hand

const products = [
  { name: 'Widget', category: 'hardware', price: 12 },
  { name: 'Gadget', category: 'hardware', price: 24 },
  { name: 'Plan', category: 'software', price: 99 },
];

// The reduce() version, written a thousand times across a thousand codebases
const grouped = products.reduce((acc, product) => {
  const key = product.category;
  if (!acc[key]) {acc[key] = [];}
  acc[key].push(product);
  return acc;
}, {});

// { hardware: [...], software: [...] }

Six lines that do one thing, and every codebase's version differs slightly, sometimes it's acc[key] ??= [], sometimes a ternary, sometimes a Map instead of an object for no particular reason. None of the variation was meaningful, it was just restating the same idea each time.

The Same Thing With Object.groupBy()

const grouped = Object.groupBy(products, product => product.category);
// { hardware: [...], software: [...] }

One line. Object.groupBy takes the array and a callback that returns the group key for each item, and builds the grouped object internally, correctly, every time.

When Map.groupBy() Is the Better Choice

const orders = [
  { customerId: 101, total: 40 },
  { customerId: 102, total: 15 },
  { customerId: 101, total: 22 },
];

// Object.groupBy coerces numeric keys to strings
const byCustomerObj = Object.groupBy(orders, o => o.customerId);
console.log(Object.keys(byCustomerObj)); // ['101', '102'], strings, not numbers

// Map.groupBy preserves the original key type
const byCustomerMap = Map.groupBy(orders, o => o.customerId);
console.log([...byCustomerMap.keys()]); // [101, 102], still numbers
console.log(byCustomerMap.get(101));    // the two orders for customer 101

If downstream code does byCustomerMap.get(101) with a number literal, Map.groupBy is the version that keeps that lookup working correctly. Object.groupBy would silently require byCustomerObj['101'] or byCustomerObj[101] (JS coerces the bracket-access key to a string either way, so both technically work, but it's easy to introduce a bug comparing typeof key === 'number' against a key that's actually now a string).

Grouping by a Computed, Non-Trivial Key

The key function isn't limited to a single property, it can compute anything:

const scores = [92, 78, 85, 61, 95, 73];

const byGrade = Object.groupBy(scores, score => {
  if (score >= 90) {return 'A';}
  if (score >= 80) {return 'B';}
  if (score >= 70) {return 'C';}
  return 'F';
});

// { A: [92, 95], B: [85], C: [78, 73], F: [61] }

This is the pattern that used to be the most error-prone to hand-write, a grading bucket function embedded inside a reduce() accumulator, easy to get an off-by-one boundary wrong while also managing the accumulator logic. Separating the two, Object.groupBy handles the accumulation, your callback handles only the classification, makes the classification logic easier to unit test in isolation.

Old Way vs Modern Way

TaskBeforeNow
Group array items by a keyreduce() with manual accumulatorObject.groupBy(items, keyFn)
Group with non-string keys preservedMap + manual has()/set()/get() logicMap.groupBy(items, keyFn)
Third-party dependencylodash _.groupBy()Native, zero install

A Note on the Null Prototype

const grouped = Object.groupBy(['a', 'b'], () => 'toString');
console.log(grouped.toString); // ['a', 'b'], not Function.prototype.toString!
console.log(Object.getPrototypeOf(grouped)); // null

Because Object.groupBy returns an object with Object.create(null) as its prototype, a group key that happens to match a built-in property name like toString or hasOwnProperty doesn't collide with anything. If you're iterating the result, use Object.keys(), Object.entries(), or for...in with a hasOwnProperty guard as you normally would, not direct property access assuming standard Object.prototype behavior, since a null-prototype object doesn't have the usual inherited methods either.

Conclusion

Object.groupBy and Map.groupBy don't do anything a reduce() call couldn't already do, they do it without you writing the accumulator logic yourself, and they do it consistently instead of however your team happened to write it that particular week. Reach for Map.groupBy the moment your grouping key isn't naturally a string, otherwise Object.groupBy is the simpler default.

Frequently Asked Questions

What's the difference between Object.groupBy() and Map.groupBy()?
Object.groupBy() returns a plain object with null prototype (Object.create(null)) whose keys are the group values, coerced to strings or symbols. Map.groupBy() returns a Map, which preserves the original key types (a number stays a number, an object reference stays that reference) and avoids the string-coercion surprises of plain object keys. Use Map.groupBy() when your grouping key isn't naturally a string, or when key order and type fidelity matter.
Why does Object.groupBy() return a null-prototype object?
So that a group value that happens to collide with a built-in Object.prototype property name, like 'toString' or 'constructor', doesn't silently shadow or conflict with it. A null-prototype object has no inherited properties at all, group[key] behaves purely as data with no risk of colliding with prototype chain internals.
Do I still need lodash's groupBy for anything Object.groupBy() can't do?
For the core grouping operation, no, Object.groupBy() and Map.groupBy() cover it natively. lodash's groupBy() still has a slight ergonomic edge for chaining with other lodash utilities in a single fluent pipeline, but for a standalone grouping operation, pulling in a dependency for it in 2026 is hard to justify when the native version does the same job with zero install cost.