Group an array by a key
Buckets an array into an object keyed by a function you provide, built on Array.reduce for engines that predate Object.groupBy.
Last updated
function groupBy(items, keyFn) {
return items.reduce((groups, item) => {
const key = keyFn(item);
(groups[key] ??= []).push(item);
return groups;
}, {});
}
// groupBy(users, (u) => u.role) -> { admin: [...], user: [...] }How it works
This ships as a reduce-based groupBy rather than the newer Object.groupBy on purpose: Object.groupBy landed in ES2024 (Node 21+, current evergreen browsers only as of 2024), while reduce runs everywhere, including older Safari and any bundler target below ES2022. If your project's baseline is recent enough, Object.groupBy(items, keyFn) does the same job in one line and returns a null-prototype object instead of a plain {}.
groups[key] ??= [] reads as "create the bucket the first time this key is seen, otherwise reuse it" — the logical-nullish-assignment operator replaces the older `groups[key] = groups[key] || []` pattern, which would wrongly reset a falsy-but-valid bucket (it can't happen here since the value is always an array, but the shorter form is also just clearer intent).
Edge cases to know
- →Keys are coerced to strings (or symbols) because they become object property names — grouping by a Date or an object reference won't give you back the original key type.
- →Object.groupBy is a drop-in replacement once your target supports it, but note it groups into a null-prototype object, so hasOwnProperty and similar Object.prototype methods aren't available on the result the way they are here.
- →For very large arrays where insertion order across many keys matters, a Map keyed by keyFn(item) avoids the string-coercion issue above.
Part by part
- items.reduce
- Folds the array into a single accumulator object. reduce is the right tool because the result is one value built from many, not a one-to-one transform.
- keyFn(item)
- A function rather than a property name, so grouping can be computed — by the first letter of a name, or a date rounded to the month, not only by a field that already exists.
- (groups[key] ??= []).push(item)
- Nullish assignment creates the array only when the key is absent, then the surrounding parentheses let push run on it immediately. It replaces a three-line if.
Common mistakes
(groups[key] ||= []).push(item)Logical OR treats every falsy value as missing, so a group whose key is an empty string or 0 is recreated on each iteration and only ever holds one item. ??= tests for null and undefined alone.
(groups[key] ??= []).push(item)items.reduce((groups, item) => { groups[keyFn(item)] = item; return groups; }, {})Assigning rather than pushing keeps only the last item per key, which looks like grouping until a key repeats. The whole point is collecting several items under one key.
const groups = {}; items.forEach(...)Works identically and is arguably clearer, but building a plain object leaves it inheriting from Object.prototype — so a key of "constructor" or "__proto__" behaves strangely. Object.create(null) avoids that.
Questions
Is there a built-in for this now?
Yes — Object.groupBy and Map.groupBy are standard in current browsers and Node 21 and later. Prefer them where support allows; Map.groupBy in particular avoids the prototype problem entirely.
What happens to a key like __proto__?
Assigning to it on a plain object does not create an own property, so those items silently vanish. Using a Map, or Object.create(null) as the accumulator, removes the whole class of surprise.
Does it preserve the original order?
Within each group, yes — items are pushed in the order they were seen. The order of the keys themselves follows JavaScript's object rules, where integer-like keys sort numerically before string keys.
How do I group by more than one field?
Return a composite key from keyFn, such as a joined string. Choose a separator that cannot appear in the values, or two different combinations can collide into one group.