Recursively group by key values (groups within groups)

Viewed 176

I would like to group by multiple keys such that a call like this:

groupBy(winners, ['country', 'athlete'])

On the following data:

[
  { athlete: "Michael Phelps", age: 19, country: "United States" },
  { athlete: "Michael Phelps", age: 27, country: "United States" },
  { athlete: "Kirsty Coventry", age: 24, country: "Zimbabwe" },
  { athlete: "Allison Schmitt", age: 22, country: "United States" },
]

Would produce (nested in order of key):

{
  'United States': {
    'Michael Phelps': [
      { athlete: "Michael Phelps", age: 19, country: "United States" },
      { athlete: "Michael Phelps", age: 27, country: "United States" }
    ],
    'Allison Schmitt': [
      { athlete: "Allison Schmitt", age: 22 country: "United States" }
    ]
  },
  'Zimbabwe': {
     'Kirsty Coventry': [
       { athlete: "Kirsty Coventry", age: 24, country: "Zimbabwe" }
     ]
  }
}

Grouping by one key is easy, but I'm stuck on getting it to recursively group each group with the next key. This just groups by each key all in one level:

const get = (obj: Record<string, any>, k: string) =>
  k.split(".").reduce((o, i) => (o ? o[i] : o), obj);

type GetValue<Item> = (item: Item) => string;

function groupBy<Item>(items: Item[], keys: (string | GetValue<Item>)[]) {
  return keys.reduce(
    (acc, key) => {
      return items.reduce((accc, item) => {
        const value =
          typeof key === "function" ? key(item) : get(item, key);
          (accc[value] = accc[value] || []).push(item);
        return accc;
      }, acc);
    },
    {} as Record<string, any>
  );
}

const r = groupBy<Athlete>(winners.slice(0, 50), [
  athlete => athlete.country,
  "athlete"
]);

Here is a runnable example: https://codesandbox.io/s/groupby-ebgly?file=/src/index.ts:244-929

Apologies the extra complexity is around making it easy to specify keys using dot notation or a function for something even more complex such as a value in an array.

Thanks

2 Answers

You could reduce the array and reduce the keys and take for the last key an array fro pushing the object.

const
    groupBy = (array, keys) => array.reduce((r, o) => {
        keys
            .reduce((q, k, i, { length }) => q[o[k]] = q[o[k]] || (i + 1 === length ? [] : {}), r)
            .push(o);
        return r;
    }, {}),
    winners = [{ athlete: "Michael Phelps", age: 19, country: "United States" }, { athlete: "Michael Phelps", age: 27, country: "United States" }, { athlete: "Kirsty Coventry", age: 24, country: "Zimbabwe" }, { athlete: "Allison Schmitt", age: 22, country: "United States" }];
    
console.log(groupBy(winners, ['country', 'athlete']));
.as-console-wrapper { max-height: 100% !important; top: 0; }

You can do recursion on groupBy by reducing the keys at each step.

Something like this:

const winners = [{
    athlete: "Michael Phelps",
    age: 19,
    country: "United States"
  },
  {
    athlete: "Michael Phelps",
    age: 27,
    country: "United States"
  },
  {
    athlete: "Kirsty Coventry",
    age: 24,
    country: "Zimbabwe"
  },
  {
    athlete: "Allison Schmitt",
    age: 22,
    country: "United States"
  },
]


const get = (obj, k) => k.split(".").reduce((o, i) => (o ? o[i] : o), obj);

function groupBy(items, keys) {

  const key = keys[0]

  const res = items.reduce((accc, item) => {
    const k =
      typeof key === "function" ? key(item) : get(item, key);
    if (typeof k === "string") {
      (accc[k] = accc[k] || []).push(item);
    }
    return accc;
  }, {});

  if (keys.length - 1 > 0)
    return Object.fromEntries(Object.entries(res).map(([key, val]) => [key, groupBy(val, keys.slice(1))]))
  else
    return res // recursion base

}

const r = groupBy(winners, ['country', 'athlete'])

console.log(r)
Related