Implement groupBy
Group array items by a key function or property name. Stable insertion of keys, and why mutating the source objects is a hostile API.
- javascript
- arrays
- interview
You have a list of orders and you need { paid: [...], open: [...], void: [...] } for a panel. Someone reduces into an object, then writes item.group = key on each source row so they can “see the bucket.” React sees mutated props. The next render groups twice and duplicates rows. The grouping was fine. The mutation was not.
groupBy takes a list and a key (function or property name) and returns a map from key to the items that produced it, in first-seen key order, items in input order. The language now has Object.groupBy. Interviews still want you to write the loop, because the interesting parts are string coercion, Map vs object, and not touching the source objects.
- Each item is pushed onto a list stored at its key. Items keep input order inside a bucket.
Mapkeys stay in first-seen order for any key. Plain objects only do that for non-index string keys; integer-index keys ("1","2") enumerate in numeric order.- Object keys stringify.
1and'1'collide on{}and do not collide onMap.- Do not write on the source items. Allocate new arrays.
- A property name is
item[name]. A function isfn(item, index). Don’t mix the signatures silently.
A map from key to a list, in first-seen order
Walk the list once. Compute the key. If that key has no bucket yet, create an empty array and record the key. Push the item. That is the whole algorithm. reduce is a fine vehicle; a for loop is clearer when you also branch on function vs string.
If the interviewer wants first-seen order for numeric keys (groupBy(ids, x => x) on [2, 1, 2]), use a Map. An object will print 1 then 2 because those keys are array indices.
function groupBy(list, keyOrFn) {
const keyFn =
typeof keyOrFn === 'function'
? keyOrFn
: (item) => item[keyOrFn];
const result = Object.create(null);
for (let i = 0; i < list.length; i++) {
const item = list[i];
const key = keyFn(item, i);
const name = String(key);
if (!Object.hasOwn(result, name)) result[name] = [];
result[name].push(item);
}
return result;
}
const rows = [
{ type: 'fruit', name: 'apple' },
{ type: 'veg', name: 'carrot' },
{ type: 'fruit', name: 'pear' },
];
groupBy(rows, 'type');
// { fruit: [apple, pear], veg: [carrot] }
groupBy(rows, (row) => row.name.length);
// Map insertion: 5 (apple), 4 (pear), 6 (carrot)
// Object integer-index order: '4' (pear), '5' (apple), '6' (carrot)
Object.create(null) avoids a __proto__ key punching Object.prototype. 'hasOwnProperty' as a group name is then just a field, not a stolen method. If you return a plain {}, never call result.hasOwnProperty; use Object.hasOwn.
Object vs Map
Object.create(null) |
Map |
|
|---|---|---|
| Key types | strings (and symbol if you used those, which String(key) will not) |
any value, reference identity for objects |
1 vs '1' |
same bucket | different buckets |
NaN keys |
"NaN" |
one NaN key (SameValueZero) |
| Insertion order | yes, for string keys | yes |
| JSON | works | empty {} if you stringify |
function groupByMap(list, keyFn) {
const result = new Map();
for (const item of list) {
const key = keyFn(item);
if (!result.has(key)) result.set(key, []);
result.get(key).push(item);
}
return result;
}
const users = [{ id: 1 }, { id: '1' }];
Object.keys(groupBy(users, (u) => u.id)); // ['1'] (one bucket)
groupByMap(users, (u) => u.id).size; // 2
Use an object when the output is a JSON payload or a view model with string labels. Use a Map when keys are objects, numbers you must not coerce, or NaN. Map and Set is the longer comparison.
Object.groupBy(items, callbackFn) (ES2024) returns a null-prototype object and stringifies keys. If the interviewer wants “the built-in,” name it, then still write the loop so you can talk about Map.
Stable keys, stable items
First time you see 'fruit', that key is created. Later 'veg' does not jump in front. Items inside 'fruit' stay in the order they appeared in the input, which is what a push gives you. Sorting buckets is a different function. Don’t unshift. Don’t sort inside groupBy.
Hostile APIs
// Don't: mutates caller data
function groupByMutating(list, keyFn) {
return list.reduce((acc, item) => {
const key = keyFn(item);
item._group = key;
(acc[key] ??= []).push(item);
return acc;
}, {});
}
The objects in each bucket are the source objects (same references). That part is normal and cheap. Writing _group onto them is not. If the caller needs a tag, map to { item, group } wrappers, or clone. See pure functions and array map/filter/reduce.
reduce without an initial value throws on []. Always seed {} or a Map.
groupBy footguns
- Sparse arrays. A
foroverlengthvisits holes asundefined.for...ofdoes too.list.mapwould skip holes and drop those slots. Prefer dense index walks and letkeyFn(undefined)throw if the caller passed a holey list. - Missing property.
item[name]isundefined;String(undefined)is'undefined'. You get a literal key'undefined', not “skip this row.” Filter first if skip is the product rule. - Functions as keys on objects.
String(fn)is the source text. Two functions with the same source collide.Mapuses identity. - Prototype pollution.
keyFnreturning'__proto__'on a plain{}can corrupt the prototype in older engines. Null-prototype objects orMapdodge this. - Shared bucket arrays. Callers who mutate
result.fruit.push(...)mutate your return value. Freeze if you are feeling strict; usually document “this is yours now.”
Interview out loud
“I walk the list once, compute a key from a function or a property name, stringify it if I am building a plain object, and push the item into that key’s array. Items stay in input order. I do not mutate the source objects. I use a Map when 1 and ‘1’ must differ, or when numeric keys must stay first-seen; objects sort integer-index keys. Empty input is an empty object or Map. The built-in is Object.groupBy, which stringifies like the object version. reduce needs an initial value or it throws on an empty list.”
Further reading
Related on this site
- Array map, filter, reduce
- Map and Set
- Functional array patterns
- Pure functions
- Chunk an array
- Deep equality in JavaScript
Related guides
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.flat PolyfillImplement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.
- Array.forEach PolyfillImplement Array.prototype.forEach: visit present elements, skip holes, ignore the callback return, and honor thisArg without using native forEach.
- Array.includes PolyfillImplement Array.prototype.includes with SameValueZero: NaN is found, +0 equals -0, and fromIndex can walk backwards from the end.