intermediateCommon5 min read · Updated Jul 19, 2026

Implement groupBy — and Meet Object.groupBy / Map.groupBy

Implement groupBy from scratch in JavaScript, then the ES2024 built-ins: the null-prototype result, Map.groupBy for object keys, and the reduce-vs-loop debate.


The problem

Implement groupBy(items, callback): bucket items by the key the callback returns — groupBy([6.1, 4.2, 6.3], Math.floor){ 6: [6.1, 6.3], 4: [4.2] }.

For a decade this was "implement lodash's _.groupBy" — one of the most-used utilities in existence. Since ES2024 it's built in as Object.groupBy and Map.groupBy, which upgraded the interview: implement it, then show you know the built-ins and their two surprises. That arc — utility → its standardization → the differences — is exactly the shape of the modern-JS questions senior loops now favor (Promise.withResolvers followed the same path).

Implementation

function groupBy(items, callback) {
  const groups = Object.create(null); // ← deliberate; see surprise #1
  let index = 0;
  for (const item of items) {
    const key = String(callback(item, index++)); // property keys are strings
    (groups[key] ??= []).push(item);
  }
  return groups;
}
const people = [
  { name: "Ada",   role: "eng" },
  { name: "Grace", role: "eng" },
  { name: "Lin",   role: "design" },
];
 
groupBy(people, (p) => p.role);
// { eng: [Ada, Grace], design: [Lin] }
 
groupBy([1, 2, 3, 4, 5], (n) => (n % 2 ? "odd" : "even"));
// { odd: [1, 3, 5], even: [2, 4] }

Three choices worth narrating:

  • (groups[key] ??= []).push(item) — initialize-if-missing in one expression; the ??= (ES2021) version of the if (!groups[key]) groups[key] = [] dance. Shows current-language fluency in four characters.
  • String(key) coercion — object properties are strings/symbols; Math.floor's 6 becomes "6". Making the coercion explicit documents a behavior that surprises people later (groups[6] still works — index access coerces the same way).
  • The callback gets the index — matching the built-in's (element, index) signature; costs nothing, and mismatched signatures are the kind of drift reviewers catch.

For a for...of + accumulator vs reduce debate: reduce with (acc[k] ??= []).push(x); return acc is fine; reduce with spread-per-item ({ ...acc, [k]: [...(acc[k] ?? []), x] }) is the O(n²) allocation trap flagged in the reduce article — this question is where reviewers most often see it in the wild.

Surprise #1: the built-in returns a null-prototype object

const groups = Object.groupBy(people, (p) => p.role);
 
groups.eng;                    // [Ada, Grace] — normal access works
groups.hasOwnProperty;         // undefined (!) — there IS no Object.prototype
Object.getPrototypeOf(groups); // null

Object.groupBy returns Object.create(null) — so grouping user data by arbitrary strings can never collide with "constructor", "toString", or "__proto__" (grouping by "__proto__" on a normal object literal silently drops or corrupts the group — a genuine prototype-pollution-shaped bug). This is the event-emitter storage lesson standardized into the platform, and it's why the implementation above uses Object.create(null) too. Consequence: call borrowed methods (Object.prototype.hasOwnProperty.call(groups, k)) or use Object.hasOwn(groups, k) (ES2022) — method borrowing, again.

Surprise #2: Map.groupBy exists because keys aren't always strings

const byTeam = Map.groupBy(players, (p) => p.teamObject);
// Map { teamA-object → [...], teamB-object → [...] }

Object.groupBy stringifies keys — grouping by an object collapses every group into "[object Object]". Map.groupBy keys by SameValueZero identity: object keys stay distinct, NaN works (the equality-algorithms thread). The decision rule to recite: string-ish keys → Object.groupBy; identity or non-string keys → Map.groupBy. Implementing the Map variant from scratch is a two-line delta (new Map(), map.get/set) — offer it.

Edge cases interviewers probe

  • Grouping by "__proto__" — the null-prototype rationale; the input interviewers plant to see if you know surprise #1.
  • Empty input → empty object/Map, no groups — falls out of the loop; no special case.
  • Keys that collide after coerciongroupBy(items, (x) => x.id) where ids are 1 and "1" merge into one group under Object.groupBy but stay separate under Map.groupBy — the two surprises are one design tension viewed twice.
  • Callback returning undefined/null — legal: groups named "undefined"/"null" appear. Usually a caller bug; a defensive wrapper may throw instead — policy, stated.
  • Stability — items within each group preserve input order (the loop guarantees it; the spec guarantees it for the built-ins). Downstream code will rely on this.

Common mistakes

  • Spread-accumulator reduce (quadratic).
  • Plain {} result and no answer for the __proto__ probe.
  • Grouping by objects with Object.groupBy and shipping the "[object Object]" mega-group.
  • Reimplementing in 2026 without mentioning the built-ins exist — the update is the question now.
  • Array.prototype.group — the earlier proposal name that got renamed (prototype-method web-compat issues); citing it as current dates your knowledge to 2022.

Follow-up questions

  • "Implement Map.groupBy from scratch." — the two-line delta above; then discuss when identity keys earn the Map's ergonomic cost (no dot access, .get() everywhere).
  • "Group by multiple keys (role AND city)?" — composite string keys (`${role}|${city}` — delimiter collision caveat), nested groupBy, or a Map keyed by tuple-encoding; each has a failure mode worth naming.
  • "countBy / partition from groupBy?"countBy is groupBy with += 1 instead of push; partition is groupBy with exactly two known keys — recognizing the family shows the abstraction stuck.
  • "How would you type it?" — the built-in's TS type: Object.groupBy<K extends PropertyKey, T>(items: Iterable<T>, fn: (item: T, index: number) => K): Partial<Record<K, T[]>> — the Partial is load-bearing (not every key exists), and explaining why is a nice types-model-reality moment.
  • "Streaming/lazy version?" — grouping is inherently terminal (you can't finish any group until the input ends) — contrast with the composable lazy stages in iterator helpers; knowing which operations can't stream is the deeper systems point.

  • map, filter, take, and drop directly on iterators (ES2025): lazy evaluation without loading everything into an array.

  • Object.groupBy, Promise.withResolvers, Set methods, iterator helpers, and the other additions worth knowing in interviews.

  • Debounce

    beginner

    Implement debounce from scratch, with leading/trailing options and the classic search-input use case.