12k
All articles

Grouping Arrays in JavaScript With Object.groupBy

Object.groupBy in JavaScript groups arrays by key, compares reduce and Map.groupBy, and explains string coercion plus null-prototype results.

OpenReplay Team
OpenReplay Team
Grouping Arrays in JavaScript With Object.groupBy

Object.groupBy(items, callback) groups an array in one call: it runs the callback once per element, uses the returned value as the group name, and returns an object holding one array of matching elements under each name.

Plenty of codebases still carry a hand-rolled reduce for this, with the same few lines of accumulator boilerplate copied between files, or a Lodash import kept alive for a single groupBy call. Neither is wrong, and both still work; with the native method available, neither is required.

This article shows the native call on a concrete problem (orders by status), sets it beside the reduce it replaces, explains when Map.groupBy is the right tool instead, and works through the two behaviours that trip people up in production: keys silently turning into strings, and a result object that has no hasOwnProperty.

Key Takeaways

  • Object.groupBy calls its callback with two arguments, (element, index), and uses the return value as the group key.
  • Replacing a reduce accumulator with Object.groupBy is a readability change, not a performance one; there is no reason to expect it to run faster.
  • Use Map.groupBy when the grouping key is not a string: an object, a Date, or a number you need to keep as a number.
  • Grouping by a boolean or number with Object.groupBy produces the string keys "true" and "40", because every key is coerced to a property key.
  • The object Object.groupBy returns has a null prototype, so result.hasOwnProperty(...) throws a TypeError; use Object.hasOwn or copy the result with spread.

Grouping Orders by Status in Three Lines

Given an array of order objects, Object.groupBy produces a status-keyed object in a single expression, with no accumulator and no existence check.

const orders = [
  { id: 1, status: "shipped",  total: 40 },
  { id: 2, status: "pending",  total: 15 },
  { id: 3, status: "shipped",  total: 60 },
  { id: 4, status: "refunded", total: 22 },
];

const byStatus = Object.groupBy(orders, (order) => order.status);

console.log(Object.keys(byStatus));   // ["shipped", "pending", "refunded"]
console.log(byStatus.shipped.length); // 2

The Object.groupBy() reference on MDN describes the contract: the first argument is any iterable, not only an array, and the result carries one property per distinct key. Groups appear in the order their first member was encountered. The elements inside each group are the original objects, not copies, so mutating byStatus.shipped[0] also mutates orders[0].

How Does the Object.groupBy Callback Work?

The callback receives two arguments, the current element and its index, and whatever it returns becomes the group key for that element. The ECMA-262 definition of Object.groupBy specifies exactly those two arguments; there is no third “whole array” argument as there is with map or filter.

Because the key is computed, the callback is not limited to reading a field. Any expression that yields a string works, including a comparison or a bucket derived from the index:

const bySize = Object.groupBy(orders, (order) => (order.total >= 50 ? "large" : "small"));
// { small: [order 1, order 2, order 4], large: [order 3] }

const byHalf = Object.groupBy(orders, (_, index) => (index < 2 ? "first" : "second"));
// { first: [order 1, order 2], second: [order 3, order 4] }

Every element lands in exactly one group. If two elements produce the same key, they share an array in insertion order.

JavaScript Group By With reduce vs Object.groupBy

Replacing a reduce accumulator with Object.groupBy removes the seed object, the per-element existence check, the array allocation, the push, and the return; what remains is the single line that decides which group an element belongs to.

Here is the version most codebases already contain, written as compactly as logical nullish assignment allows:

const byStatusReduce = orders.reduce((acc, order) => {
  acc[order.status] ??= [];
  acc[order.status].push(order);
  return acc;
}, {});

And the same result with the native method:

const byStatus = Object.groupBy(orders, (order) => order.status);

Both produce equivalent groupings. The difference is what the reader has to hold in their head. In the reduce form, the grouping intent is spread across an accumulator seed, a conditional allocation, a mutation, and a return value, and any of those four can be subtly wrong. In the native form, the only thing left to review is the key function.

Object.groupBy is a readability change, not a performance one. Both approaches iterate the input once and allocate one array per group, and there is no reason to expect the native call to be faster than a well-written reduce. Choose it because it removes boilerplate, not because of a benchmark.

When Should You Use Map.groupBy Instead?

Use Map.groupBy when the grouping key is not a string: an object, a Date, or a number you need to keep as a number. Map.groupBy() takes the same two-argument callback and differs only in its return type, a Map whose keys are the exact values the callback returned.

const byTotal = Map.groupBy(orders, (order) => order.total);

console.log([...byTotal.keys()]);           // [40, 15, 60, 22]
console.log(typeof [...byTotal.keys()][0]); // "number"
console.log(byTotal.get(40).length);        // 1

The keys come back as numbers, in insertion order, and are read with .get(). MDN’s own example on the Map.groupBy page groups by object identity, which is the case an object literal cannot handle at all: two distinct objects with identical contents stay distinct as Map keys.

Object.groupByMap.groupBy
Key typeCoerced to string or symbolAny value, kept as-is
Return typeNull-prototype objectMap
Read a groupresult.shippedresult.get(key)
Key iteration orderInsertion order, except integer-like keys sort ascendingInsertion order

Why Does Object.groupBy Turn Number Keys Into Strings?

Grouping by a boolean or a number with Object.groupBy produces the string keys "true" and "40", not the original true and 40; Map.groupBy preserves the original values as Map keys. Whatever the callback hands back has to end up as a property key, so anything that is not already a string or a symbol gets converted to a string on the way in. That is ordinary object behaviour, but it still catches out anyone expecting a boolean-keyed result.

const byPaid = Object.groupBy(orders, (order) => order.status === "shipped");

console.log(Object.keys(byPaid));             // ["true", "false"]
console.log(typeof Object.keys(byPaid)[0]);   // "string"
console.log(byPaid[true] === byPaid["true"]); // true (lookup coerces too)

const byTotalObj = Object.groupBy(orders, (order) => order.total);
console.log(Object.keys(byTotalObj));         // ["15", "22", "40", "60"]

Two things are happening in the numeric case. The totals became strings, and they came back sorted ascending rather than in insertion order, because integer-like property keys are enumerated in ascending numeric order before other string keys. Code that iterates the result expecting the original sequence will render groups in the wrong order without throwing anything.

Session replays of grouping bugs frequently show this exact shape: a category header that reads true instead of “Shipped”, or buckets appearing sorted when the data was not. The console is clean, the data shape looks correct in a log because { true: [...] } prints identically whether the key is a boolean or a string, and only seeing the rendered UI beside the code path makes the coercion obvious.

Why Does hasOwnProperty Throw on an Object.groupBy Result?

The object returned by Object.groupBy has a null prototype, so result.hasOwnProperty("shipped") throws a TypeError; use Object.hasOwn(result, "shipped") or copy the result with spread syntax if downstream code expects an ordinary object. MDN documents the return value as a null-prototype object, which means nothing from Object.prototype is reachable through it: no hasOwnProperty, no toString, no valueOf.

const byStatus = Object.groupBy(orders, (o) => o.status);

byStatus.hasOwnProperty("shipped");
// TypeError: byStatus.hasOwnProperty is not a function

Object.hasOwn(byStatus, "shipped"); // true
"shipped" in byStatus;              // true
Object.keys(byStatus);              // ["shipped", "pending", "refunded"]
JSON.stringify(byStatus);           // works normally

const plain = { ...byStatus };      // ordinary object with Object.prototype
plain.hasOwnProperty("shipped");    // true

MDN points to Object.hasOwn as the modern stand-in for hasOwnProperty, and it has been Baseline Widely available since March 2022, so you can reach for it directly. Object.keys, Object.entries, the in operator, JSON.stringify, and spread all work on the null-prototype result because none of them depend on the prototype chain. The failure only appears when a helper, often deep inside a utility library or a template engine, calls a method on the object itself. A group that silently never renders, or a TypeError thrown from inside a render loop, is the typical symptom.

Which Browsers Support Object.groupBy and Map.groupBy?

Object.groupBy and Map.groupBy share the same support line: both are marked Baseline Widely available on MDN, available across browsers since March 2024, so neither needs a polyfill for current browser targets. The decision between them comes down to the key: if the group name is naturally a string (a status, a category, a team name), Object.groupBy gives you a plain-looking object you can index with dot notation. If the key is an object, a Date, a number you will do arithmetic on, or a boolean you want to compare as a boolean, Map.groupBy keeps it intact and avoids both traps above.

Replacing the Accumulator

A reduce with an ??= [] line can become a one-line Object.groupBy call whenever the grouping key is a string and nothing downstream calls hasOwnProperty on the result. When the key is anything else, reach for Map.groupBy and read groups back with .get(). Either way, the logic that decides group membership is the only code left to test.

FAQs

Does Object.groupBy work in TypeScript, and what type does it return?

Yes. TypeScript 5.4 added type declarations for Object.groupBy and Map.groupBy, available when the tsconfig target or lib includes es2024 or esnext; older lib settings report that groupBy does not exist on ObjectConstructor. Object.groupBy is typed as a Partial Record, so every group is possibly undefined and needs a check before you index into it. Map.groupBy is typed as a Map from the key type to an array of elements.

What happens if the Object.groupBy callback returns undefined or null?

The element lands in a group keyed by the string 'undefined' or 'null', because Object.groupBy converts every callback result to a property key. Nothing is skipped and no error is thrown, so a missing field silently produces an extra group. Map.groupBy keeps the actual undefined or null value as the Map key. To exclude those elements, filter the array first or return a fallback key such as 'unknown'.

What is the difference between Object.groupBy and Lodash groupBy?

Lodash groupBy returns an ordinary object that inherits from Object.prototype, so hasOwnProperty works on it; Object.groupBy returns a null-prototype object. Lodash accepts a property-name shorthand such as groupBy(orders, 'status') and calls a function iteratee with one argument, the value, while Object.groupBy requires a function and passes the element and its index. Lodash also accepts plain objects as input; Object.groupBy accepts any iterable. Both coerce keys to strings.

How do I group by multiple fields with Object.groupBy?

Return one composite string from the callback, for example joining status and a size bucket with a separator to produce keys like 'shipped:large'. Object.groupBy has no multi-key mode; each element receives exactly one property key. If you need the fields separately, nest the calls: group by status first, then run Object.groupBy on each group's array for the second field, which yields a two-level structure read as result.shipped.large.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.