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 whateverkeyFnreturns.Map.groupBy(items, keyFn)does the same but returns aMap, preserving non-string key types and avoiding prototype collisions entirely. Both replace the hand-writtenreduce()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
| Task | Before | Now |
|---|---|---|
| Group array items by a key | reduce() with manual accumulator | Object.groupBy(items, keyFn) |
| Group with non-string keys preserved | Map + manual has()/set()/get() logic | Map.groupBy(items, keyFn) |
| Third-party dependency | lodash _.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.