Group an Array by a Key
Grouping records by a derived key is one of the most common data-shaping tasks in JavaScript: bucketing users by role, orders by status, logs by date. This snippet shows a portable `Map`-based helper, a plain-object variant, and the modern `Object.groupBy` API that landed in Node 22 and recent browsers. It also covers the multi-key composite-key trick for grouping by tuples like `[city, role]`.
335 views
5
function groupBy(array, keyFn) {
const groups = new Map();
for (const item of array) {
const key = keyFn(item);
const bucket = groups.get(key);
if (bucket) {
bucket.push(item);
} else {
groups.set(key, [item]);
}
}
return groups;
}
const users = [
{ name: 'Ada', role: 'admin' },
{ name: 'Bo', role: 'member' },
{ name: 'Cal', role: 'admin' },
{ name: 'Di', role: 'member' },
];
const byRole = groupBy(users, (u) => u.role);
console.log(byRole.get('admin').map((u) => u.name)); // ['Ada', 'Cal']
console.log(byRole.get('member').length); // 2
console.log([...byRole.keys()]); // ['admin', 'member']The function walks the input once and accumulates each item into a Map keyed by the result of keyFn(item). Using a Map instead of a plain object preserves insertion order, supports any key type (numbers, booleans, even objects), and avoids the __proto__ / constructor collision pitfalls of object-as-dictionary code. The whole pass is O(n) time and O(n) space, with one dictionary lookup and one push per element. Reach for this helper any time you need to bucket records before rendering a grouped list, computing per-group totals, or feeding a chart.
function groupByObject(array, keyFn) {
return array.reduce((acc, item) => {
const key = keyFn(item);
(acc[key] ||= []).push(item);
return acc;
}, Object.create(null));
}
const orders = [
{ id: 1, status: 'paid', total: 20 },
{ id: 2, status: 'open', total: 30 },
{ id: 3, status: 'paid', total: 12 },
{ id: 4, status: 'open', total: 5 },
];
const byStatus = groupByObject(orders, (o) => o.status);
console.log(Object.keys(byStatus)); // ['paid', 'open']
console.log(byStatus.paid.length); // 2
console.log(byStatus.open.reduce((s, o) => s + o.total, 0)); // 35When the consumer expects a plain object (JSON serialization, React state, structuredClone), use this reduce variant. The two key tricks are Object.create(null) to start with a prototype-free dictionary (so a key named toString or hasOwnProperty cannot collide with inherited methods) and the logical-assignment operator ||= to lazily create each bucket on first hit. The body stays a single expression and runs in O(n). The trade-off vs. the Map version is that all keys are coerced to strings, so a numeric key like 2 and the string "2" collide.
// Object.groupBy (Stage 4, available in Node 22+ and modern browsers).
const items = [
{ name: 'apple', kind: 'fruit' },
{ name: 'beet', kind: 'veg' },
{ name: 'banana', kind: 'fruit' },
];
const grouped = Object.groupBy(items, (it) => it.kind);
console.log(Object.keys(grouped)); // ['fruit', 'veg']
console.log(grouped.fruit.map((i) => i.name)); // ['apple', 'banana']
// Map.groupBy preserves non-string keys.
const nums = [1, 2, 3, 4, 5, 6];
const byParity = Map.groupBy(nums, (n) => (n % 2 === 0 ? 'even' : 'odd'));
console.log(byParity.get('even')); // [2, 4, 6]
console.log(byParity.get('odd')); // [1, 3, 5]TC39 finally standardized this pattern as Object.groupBy(iterable, keyFn) and Map.groupBy(iterable, keyFn). Both ship in Node 22 and all evergreen browsers from 2024 onward, so a project that drops Node 18 can delete its hand-rolled helper. Use Object.groupBy when you want a plain JSON-friendly object (string keys only) and Map.groupBy when keys are numbers, booleans, or object references. If you must support older runtimes, ship the polyfill from the previous accordions and feature-detect with if (!Object.groupBy) { ... } before assigning.
function groupByMany(array, ...keyFns) {
const groups = new Map();
for (const item of array) {
// Join keys with a delimiter that cannot appear in any field.
const composite = keyFns.map((fn) => fn(item)).join('\u0000');
const bucket = groups.get(composite);
if (bucket) {
bucket.push(item);
} else {
groups.set(composite, [item]);
}
}
return groups;
}
const employees = [
{ name: 'Ada', city: 'Paris', role: 'admin' },
{ name: 'Bo', city: 'Paris', role: 'member' },
{ name: 'Cal', city: 'Lyon', role: 'admin' },
{ name: 'Di', city: 'Paris', role: 'admin' },
];
const buckets = groupByMany(employees, (e) => e.city, (e) => e.role);
console.log(buckets.get('Paris\u0000admin').map((e) => e.name)); // ['Ada', 'Di']
console.log(buckets.get('Lyon\u0000admin').map((e) => e.name)); // ['Cal']
console.log(buckets.size); // 3Real reports rarely group by a single column. Instead of nesting maps two or three deep, encode the tuple of keys as a single composite string using a delimiter that cannot occur in real data, here the NUL byte \u0000. This keeps lookups O(1) without the boilerplate of a tree of Map<string, Map<string, T[]>>. The same approach scales to four or five keys with no extra code. The only gotcha is to pick a delimiter that genuinely never appears in your fields; if user input could contain control characters, use JSON.stringify(keys) instead.
