Group by and sum on multiple keys maintaining type safety
Asked Answered
B

2

0

Given an array of objects, I'd like to group it by an arbitrary number of object keys, and sum the values of a second arbitrary number of keys.

For example, given:

const arr = [
   { shape: "square", color: "red", available: 1, ordered: 1 },
   { shape: "square", color: "red", available: 2, ordered: 1 },
   { shape: "circle", color: "blue", available: 0, ordered: 3 },
   { shape: "square", color: "blue", available: 4, ordered: 4 },
 ];

If I group by both shape and color and want the sum of the values of available and ordered, the result should be:

[
  { shape: "square", color: "red", available: 3, ordered: 2 },
  { shape: "circle", color: "blue", available: 0, ordered: 3 },
  { shape: "square", color: "blue", available: 4, ordered: 4 },
];

I've extensively gone through many similar SO threads [1, from which the example above is based on, 2, 3, 4, 5]. The issue is that none of them:

  • Provide a generic, ready-to-use function (they're based on custom object keys)
  • Are based on Typescript and provide a type-safe implementation
  • Deal adequately with additional keys in the object (e.g., if arr contained another property size not involved in the transformation it shouldn't contain bogus values)

How can I build a generic, type-safe groupBySum function that accepts multiple grouping and summing keys?

Bingle answered 17/5, 2023 at 12:48 Comment(0)
B
2

The following TypeScript function meets all the desired criteria:

/**
 * Sums object value(s) in an array of objects, grouping by arbitrary object keys.
 *
 * @remarks
 * This method takes and returns an array of objects.
 * The resulting array of object contains a subset of the object keys in the
 * original array.
 *
 * @param arr - The array of objects to group by and sum.
 * @param groupByKeys - An array with the keys to group by.
 * @param sumKeys - An array with the keys to sum. The keys must refer
 *    to numeric values.
 * @returns An array of objects, grouped by groupByKeys and with the values
 *    of keys in sumKeys summed up.
 */
const groupBySum = <T, K extends keyof T, S extends keyof T>(
  arr: T[],
  groupByKeys: K[],
  sumKeys: S[]
): Pick<T, K | S>[] => {
  return [
    ...arr
      .reduce((accu, curr) => {
        const keyArr = groupByKeys.map((key) => curr[key]);
        const key = keyArr.join("-");
        const groupedSum =
          accu.get(key) ||
          Object.assign(
            {},
            Object.fromEntries(groupByKeys.map((key) => [key, curr[key]])),
            Object.fromEntries(sumKeys.map((key) => [key, 0]))
          );
        for (let key of sumKeys) {
          groupedSum[key] += curr[key];
        }
        return accu.set(key, groupedSum);
      }, new Map())
      .values(),
  ];
};

The code snippet below uses the JavaScript equivalent to showcase a few examples based on your arr:

const arr = [
   { shape: "square", color: "red", available: 1, ordered: 1 },
   { shape: "square", color: "red", available: 2, ordered: 1 },
   { shape: "circle", color: "blue", available: 0, ordered: 3 },
   { shape: "square", color: "blue", available: 4, ordered: 4 },
 ];
 
const groupBySum = (arr, groupByKeys, sumKeys) => {
  return [
    ...arr
      .reduce((accu, curr) => {
        const keyArr = groupByKeys.map((key) => curr[key]);
        const key = keyArr.join("-");
        const groupedSum =
          accu.get(key) ||
          Object.assign(
            {},
            Object.fromEntries(groupByKeys.map((key) => [key, curr[key]])),
            Object.fromEntries(sumKeys.map((key) => [key, 0]))
          );
        for (let key of sumKeys) {
          groupedSum[key] += curr[key];
        }
        return accu.set(key, groupedSum);
      }, new Map())
      .values(),
  ];
};
console.log('groupBySum(arr, ["shape"], ["available"])')
console.log(groupBySum(arr, ["shape"], ["available"]))
console.log('\n\ngroupBySum(arr, ["color"], ["ordered"])')
console.log(groupBySum(arr, ["color"], ["ordered"]))
console.log('\n\ngroupBySum(arr, ["shape", "color"], ["available", "ordered"])')
console.log(groupBySum(arr, ["shape", "color"], ["available", "ordered"]))

The Typescript implementation is type-safe. For example, if we try to pass an invalid key...

groupBySum(arr, ["blah"], ["ordered"]);

... the compiler will complain:

Type '"blah"' is not assignable to type '"shape" | "ordered" | "color" | "available"'.ts(2322)

The returned object is also type-safe. For example, the type of ans in...

const ans = groupBySum(arr, ["shape"], ["ordered"])

...is:

Array<{ shape: string; ordered: number }>;

Finally, note that any keys not involved in the transformation are dropped. The example above doesn't contain color or available, which couldn't possibly contain meaningful values. This is built in the return type, so TypeScript knows not to expect them.

Bingle answered 17/5, 2023 at 12:48 Comment(1)
R
0

You can do:

const arr = [
  { shape: 'square', color: 'red', available: 1, ordered: 1 },
  { shape: 'square', color: 'red', available: 2, ordered: 1 },
  { shape: 'circle', color: 'blue', available: 0, ordered: 3 },
  { shape: 'square', color: 'blue', available: 4, ordered: 4 },
]

function groupAndSum(arr, groupKeys, sumKeys) {
  const groupedData = arr.reduce((acc, obj) => {
    const groupValues = groupKeys.map((key) => obj[key]).join('-')

    if (!acc.has(groupValues)) {
      acc.set(groupValues, { ...obj })
    } else {
      const existingObj = acc.get(groupValues)
      sumKeys.forEach((key) => (existingObj[key] += obj[key]))
    }

    return acc
  }, new Map())

  return Array.from(groupedData.values())
}

const groupKeys = ['shape', 'color']
const sumKeys = ['available', 'ordered']

const result = groupAndSum(arr, groupKeys, sumKeys)
console.log(result)
Robson answered 17/5, 2023 at 14:42 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.