Skip to the code
cf.

Object.groupBy and Map.groupBy

MediumPolyfills

Write Object.myGroupBy(items, callback) and Map.myGroupBy(items, callback), which work like the built-in Object.groupBy and Map.groupBy without calling them. Both put items into groups and return the groups.

items can be any iterable: an array, a string, a Set, a generator. For each item, in order, call callback(item, index), with exactly those two arguments. What it returns is the item's key. Items with the same key go into one array, in the order they came.

Object.myGroupBy returns an object with no prototype, with one property per key holding that key's array. Properties are created in the order their keys first appear. Because there's no prototype, keys like "constructor" and "__proto__" make ordinary groups.

Object.myGroupBy uses keys as property names, so they're converted to strings (symbols stay symbols): 1 and "1" share a group, and true becomes "true".

Map.myGroupBy returns a Map from each key to its array, with keys in the order they first appear. Keys aren't converted: 1 and "1" are different groups, and an object key groups items by identity. As in any Map, NaN matches NaN.

Throw a TypeError if items isn't iterable (for example null or a number), or if callback isn't a function, even when items is empty.

const words = ["apple", "banana", "avocado"];
const byLetter = Object.myGroupBy(words, (word) => word[0]);
// { a: ["apple", "avocado"], b: ["banana"] }
Object.getPrototypeOf(byLetter); // null
 
Object.myGroupBy([1, 2, 3, 4], (n, index) => (index < 2 ? "first" : "rest"));
// { first: [1, 2], rest: [3, 4] }
 
const byParity = Map.myGroupBy([1, 2, 3, 4], (n) => n % 2 === 0);
// Map { false => [1, 3], true => [2, 4] }
byParity.get(true); // [2, 4]

What the tests check

  1. Object.myGroupBy puts items with the same key into one array, in order
    const words = ["banana", "apple", "blueberry", "avocado", "cherry"];
    const groups = Object.myGroupBy(words, (word) => word[0]);
    expect(groups).toEqual({ b: ["banana", "blueberry"], a: ["apple", "avocado"], c: ["cherry"] });
    expect(Object.keys(groups)).toEqual(["b", "a", "c"]);
    
  2. Object.myGroupBy returns an object with no prototype, so any key makes a group
    const words = ["constructor", "__proto__", "toString", "toString"];
    const groups = Object.myGroupBy(words, (word) => word);
    expect(Object.getPrototypeOf(groups)).toBeNull();
    expect(Object.keys(groups)).toEqual(["constructor", "__proto__", "toString"]);
    expect(groups.constructor).toEqual(["constructor"]);
    expect(groups["__proto__"]).toEqual(["__proto__"]);
    expect(groups.toString).toEqual(["toString", "toString"]);
    
  3. Object.myGroupBy converts keys to strings, as property names
    const groups = Object.myGroupBy([1, "1", 2], (x) => x);
    expect(Object.keys(groups)).toEqual(["1", "2"]);
    expect(groups["1"]).toEqual([1, "1"]);
    const byParity = Object.myGroupBy([1, 2, 3], (n) => n % 2 === 0);
    expect(Object.keys(byParity)).toEqual(["false", "true"]);
    expect(byParity.false).toEqual([1, 3]);
    
  4. calls callback with each item and its index, and nothing else
    const callback = spy(() => "key");
    Object.myGroupBy(["a", "b"], callback);
    expect(callback.calls.map((call) => call.args)).toEqual([["a", 0], ["b", 1]]);
    const mapCallback = spy(() => "key");
    Map.myGroupBy(new Set(["x", "y"]), mapCallback);
    expect(mapCallback.calls.map((call) => call.args)).toEqual([["x", 0], ["y", 1]]);
    
  5. groups any iterable, not only arrays
    const letters = Object.myGroupBy("hello", (char) => ("aeiou".includes(char) ? "vowels" : "consonants"));
    expect(letters).toEqual({ consonants: ["h", "l", "l"], vowels: ["e", "o"] });
    expect(Object.myGroupBy(new Set([1, 2, 3]), (n) => n % 2)).toEqual({ 0: [2], 1: [1, 3] });
    function* numbers() {
      yield 1;
      yield 2;
      yield 3;
    }
    const sizes = Map.myGroupBy(numbers(), (n) => (n > 1 ? "big" : "small"));
    expect([...sizes]).toEqual([["small", [1]], ["big", [2, 3]]]);
    
  6. Map.myGroupBy returns a Map from each key to its items, in order
    const groups = Map.myGroupBy([1, 2, 3, 4, 5], (n) => (n % 2 === 0 ? "even" : "odd"));
    expect(groups).toBeInstanceOf(Map);
    expect([...groups.keys()]).toEqual(["odd", "even"]);
    expect(groups.get("odd")).toEqual([1, 3, 5]);
    expect(groups.get("even")).toEqual([2, 4]);
    
  7. Map.myGroupBy keeps keys as they are, objects by identity
    const red = { name: "red" };
    const blue = { name: "blue" };
    const players = [
      { name: "Ada", team: red },
      { name: "Bo", team: blue },
      { name: "Cy", team: red },
    ];
    const byTeam = Map.myGroupBy(players, (player) => player.team);
    expect(byTeam.size).toBe(2);
    expect(byTeam.get(red).map((player) => player.name)).toEqual(["Ada", "Cy"]);
    const mixed = Map.myGroupBy([1, "1", true, NaN, NaN], (x) => x);
    expect([...mixed.keys()]).toEqual([1, "1", true, NaN]);
    expect(mixed.get(NaN)).toEqual([NaN, NaN]);
    
  8. throws a TypeError for items that aren't iterable or a callback that isn't a function
    expect(() => Object.myGroupBy(null, (x) => x)).toThrow(TypeError);
    expect(() => Object.myGroupBy(42, (x) => x)).toThrow(TypeError);
    expect(() => Map.myGroupBy(undefined, (x) => x)).toThrow(TypeError);
    expect(() => Object.myGroupBy([], "key")).toThrow(TypeError);
    expect(() => Map.myGroupBy([], undefined)).toThrow(TypeError);
    
  9. works without the built-in Object.groupBy and Map.groupBy
    const builtIn = { object: Object.groupBy, map: Map.groupBy };
    Object.groupBy = function () {
      throw new Error("myGroupBy called the built-in Object.groupBy");
    };
    Map.groupBy = function () {
      throw new Error("myGroupBy called the built-in Map.groupBy");
    };
    let byObject;
    let byMap;
    try {
      byObject = Object.myGroupBy([1, 2, 3], (n) => (n > 1 ? "big" : "small"));
      byMap = Map.myGroupBy([1, 2, 3], (n) => n > 1);
    } finally {
      Object.groupBy = builtIn.object;
      Map.groupBy = builtIn.map;
    }
    expect(byObject).toEqual({ small: [1], big: [2, 3] });
    expect(byMap.get(true)).toEqual([2, 3]);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).