ESC

Type to search the knowledge base.

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.

beginner5 min read
  • 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.
  • Map keys 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. 1 and '1' collide on {} and do not collide on Map.
  • Do not write on the source items. Allocate new arrays.
  • A property name is item[name]. A function is fn(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

  1. Sparse arrays. A for over length visits holes as undefined. for...of does too. list.map would skip holes and drop those slots. Prefer dense index walks and let keyFn(undefined) throw if the caller passed a holey list.
  2. Missing property. item[name] is undefined; String(undefined) is 'undefined'. You get a literal key 'undefined', not “skip this row.” Filter first if skip is the product rule.
  3. Functions as keys on objects. String(fn) is the source text. Two functions with the same source collide. Map uses identity.
  4. Prototype pollution. keyFn returning '__proto__' on a plain {} can corrupt the prototype in older engines. Null-prototype objects or Map dodge this.
  5. 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 guides