Every codebase eventually grows its own version of a reduce()-based grouping helper — usually duplicated across three or four files with slightly different bugs in each copy. Replacing those helpers with the native Object.groupBy() and Map.groupBy() static methods looks like a one-line swap, and mostly is, except for the detail that trips up nearly every quick-start post on the topic: the object these methods return has no prototype. That means hasOwnProperty, toString, and instanceof Object all behave differently than they do on a plain {} — a distinction that won't show up at compile time, only in production.
Prerequisites
- Node.js 21.0.0 or later for server-side code, or a Baseline 2024 browser target: Chrome/Edge 117+, Firefox 119+, Safari 17.4+. Confirm the exact minor versions for your support matrix against the MDN compatibility table for Object.groupBy() — do not assume ranges in a blog post are current.
- TypeScript 5.4 or later if you want
groupBytype definitions without aliboverride. - An existing codebase with at least one
reduce()-based grouping helper to refactor. If you don't have one handy, the example below is representative enough to follow along with directly. - No transpilation step is required if you're only targeting the runtime versions above — this is a language feature, not a syntax addition, so Babel/SWC won't help you ship it to older engines. You'd need a polyfill instead, covered in Step 4.
Step 1: Identify the Pattern You're Replacing
Most codebases converge on some version of this helper, often duplicated three or four times with minor variations:
function groupBy(items, keyFn) {
return items.reduce((acc, item) => {
const key = keyFn(item);
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
}, {});
}
const inventory = [
{ name: "asparagus", type: "vegetables", quantity: 9 },
{ name: "bananas", type: "fruit", quantity: 5 },
{ name: "goat", type: "meat", quantity: 23 },
];
const byType = groupBy(inventory, (item) => item.type);
This works, but it's carrying three liabilities that native grouping removes: the accumulator starts as {}, which means any group key that collides with a built-in Object.prototype property ("constructor", "toString", "__proto__") silently misbehaves; the mutation-based accumulation pattern is easy to get subtly wrong under refactors (forgetting to return acc, mutating instead of reassigning); and every file that needs grouping either imports this utility or reimplements it, which is exactly the kind of boilerplate a language-level primitive is supposed to absorb.
Expected output: Running the snippet above logs byType as { vegetables: [...], fruit: [...], meat: [...] } — a normal object you can freely mutate, JSON.stringify, or pass to hasOwnProperty.
Step 2: Swap in Object.groupBy()
Replace the call site directly. The callback signature is compatible with the old keyFn in the common case — one argument in, one string or symbol key out — so this is usually a mechanical find-and-replace rather than a logic rewrite.
const byType = Object.groupBy(inventory, (item) => item.type);
One behavioral difference matters immediately: Object.groupBy()'s callback receives (element, index), not just (element), so if your old keyFn relied on closure state instead of an index parameter, you now have direct access to position without threading it through manually.
const byParityAndPosition = Object.groupBy(inventory, (item, index) => index % 2 === 0 ? "even-position" : "odd-position", );
Checkpoint: console.log(byType.fruit) should return the same array of matching items your reduce() version produced. If it throws Object.groupBy is not a function, your runtime predates the versions listed in Prerequisites — jump to Step 4 before continuing.
Step 3: Handle the Null-Prototype Difference
This is the step most refactor guides skip, and it's the one that will actually page someone. Object.groupBy() does not return a plain {} — per the TC39 Array Grouping specification, the result is built with OrdinaryObjectCreate(null), meaning it has no prototype chain. That was a deliberate committee decision: an earlier version of this proposal shipped as Array.prototype.groupBy() and then Array.prototype.group(), and both names broke real production sites — the Sugar.js library monkey-patched Array.prototype with an incompatible method of the same name, and other sites used arrays as ad hoc hashmaps in ways that collided with a group property. Moving to a null-prototype static method sidestepped both collision classes for good, but it changes how the result behaves downstream.
const byType = Object.groupBy(inventory, (item) => item.type);
byType.hasOwnProperty("fruit"); // TypeError: byType.hasOwnProperty is not a function
byType instanceof Object; // false
Object.keys(byType); // still works — Object.* static methods don't need a prototype
Any code downstream that calls prototype methods directly on the grouped result — hasOwnProperty, toString, valueOf — needs to switch to the static equivalents: Object.hasOwn(byType, "fruit") instead of byType.hasOwnProperty("fruit"), Object.keys(byType) and Object.entries(byType) for iteration, which both work unchanged. Search your codebase for .hasOwnProperty( calls on any variable that flows from a grouping utility before you consider the refactor complete — this is a compile-time-invisible, runtime-only failure.
Checkpoint: Run Object.keys(byType) and confirm it returns your expected group names as an array. If any downstream code calls .hasOwnProperty() or similar directly on the result, you'll see a TypeError here rather than a silent bug — treat that as the refactor working correctly, not breaking.
Step 4: Use Map.groupBy() for Non-String Keys
Object.groupBy() vs. Map.groupBy(): use Object.groupBy() when group keys are strings or symbols; use Map.groupBy() when the key is an object reference, a number you don't want coerced to a string, or anything where key identity — not string equality — determines the group.
Object.groupBy() coerces every callback return value to a property key, which means numeric and object keys get silently stringified. If your grouping key is a Date, a class instance, or anything where two different values could produce the same string, Map.groupBy() avoids the coercion entirely and preserves the original key type:
const restockThreshold = { label: "needs restock" };
const sufficientStock = { label: "sufficient" };
const byStockStatus = Map.groupBy(inventory, (item) =>
item.quantity < 10 ? restockThreshold : sufficientStock,
);
byStockStatus.get(restockThreshold); // [{ name: "asparagus", ... }, { name: "bananas", ... }]
Because the key is the actual object reference rather than a stringified version of it, two visually identical objects won't collapse into the same group unless they're the same reference — which is usually what you want when the key represents an entity, not a label.
If you need this behavior on a runtime older than the versions in Prerequisites, the core-js polyfill for array grouping is referenced directly from the proposal repository's implementation notes, and installs both Object.groupBy and Map.groupBy without behavioral drift from the spec.
Common Errors
Object.groupBy is not a function— the runtime doesn't support it yet. Check your actual deployed Node version, not your local dev version; serverless platforms frequently lag behind current LTS. Fix: confirm against the Prerequisites version matrix, or use the polyfill from Step 4.byType.hasOwnProperty is not a functiondownstream of a refactor — you migrated the grouping call but not every consumer of its output. Fix: replace.hasOwnProperty()withObject.hasOwn(), or run a codebase-wide search for prototype method calls on grouped results before merging.- Groups merging unexpectedly with
Object.groupBy()— two different key values coerced to the same string (e.g., the number1and the string"1"both become"1"). Fix: switch toMap.groupBy()if key identity needs to be preserved.





