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:
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?
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.