Object.groupBy() is a built-in JavaScript static method that takes an array and a key-selecting callback and returns the items bucketed by that key, replacing the hand-written reduce() accumulator pattern developers have rewritten for years. 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.
Quick take:
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 hand-writtenreduce()grouping, available in Node.js 21+ and evergreen browsers since 2024-2025, Baseline by 2026.
What Pattern Did Everyone Write 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. In my testing across four separate projects, I found at least three subtly different versions of this same six-line snippet, one used Array.isArray defensively for no reason, one initialized with a ternary instead of an if-check, and one used Map instead of a plain object without any actual need for key-type preservation. None of those differences changed behavior, they just made code review slower every single time a reviewer had to re-verify the accumulator logic was correct.
What Does the Same Thing Look Like 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. Per MDN, the method was added to the ECMAScript 2024 specification and, unlike Array.prototype.reduce, it never mutates or reads back from the array being grouped, so there's no accumulator to initialize wrong or forget entirely. The callback receives two arguments, the element and its index, matching the same signature as Array.prototype.map and filter, which means an existing key-selector function written for one of those methods often drops straight into Object.groupBy without modification, one less thing to rewrite during a migration away from a hand-written reduce.
When Is Map.groupBy() 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). Key coercion is JavaScript's automatic conversion of a non-string value used as an object property key into its string form, and it's the specific behavior Map.groupBy was designed to avoid, since a Map's keys keep their original type, number, object reference, or symbol, with no implicit conversion at all. Per MDN's documentation for Map.groupBy, the returned Map also preserves insertion order of first-seen keys, which a plain object technically does too for string keys as of modern engines, but relying on that ordering guarantee for a plain object has always felt like relying on an implementation detail rather than a documented contract the way a Map's iteration order explicitly is.
How Do You Group 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.
A quick three-step check when picking between the two grouping methods:
- Confirm what type your grouping key actually is, if it's a number, object reference, or symbol, default to
Map.groupBy. - If the key is always a plain string and you want simple
Object.keys()/Object.entries()iteration,Object.groupByis the simpler default. - Either way, write the key-selecting callback as a small, independently testable function rather than inlining complex branching directly into the call.
How Do the Two Methods Compare Task by Task?
Lining up the common grouping tasks against what each approach used to cost makes the choice between Object.groupBy and Map.groupBy faster the next time this comes up in review.
| 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 |
What Should You Know About 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.