I already had a groupBy function which receives an array of objects and a key. It is capable to group by a single property.
const groupBy = <T extends Record<string, unknown>, U extends keyof T>(
objArr: T[],
key: U,
): { [key: string]: T[] }
Since I get a depth=1
object as a result, I was pretty satisfied with the return type being { [key: string]: T[] }
But now I need to extend this function to be able to group by multiple properties and create a nested object with depth=keys.length
.
the groupBy
function:
const groupBy = <T extends Record<string, unknown>, U extends keyof T>(
objArr: T[],
key: U,
): { [key: string]: T[] } => objArr
.reduce((memo, x) => {
if (x[key]) {
const value = (x[key] as any).toString();
if (!memo[value]) {
memo[value] = [];
}
memo[value].push(x);
}
return memo;
}, {} as { [key: string]: Array<T> });
The groupByMulti
function, that just calls the groupBy
function recursively until it reaches the last grouping key.
const groupByMulti = <T extends Record<string, unknown>, U extends keyof T>(
arr: T[],
keys: U[],
propIndex = 0,
) => {
const grouppedObj = groupBy(arr, keys[propIndex]);
Object.keys(grouppedObj).forEach((key) => {
if (propIndex < keys.length - 1) {
grouppedObj[key] = groupByMulti(grouppedObj[key], keys, propIndex 1);
}
});
return grouppedObj;
}
I was wondering if it is possible to build an object type with a fixed depth like:
groupByMulti(someArray, ['key1', 'key2']): {
[key: string]: {
[key: string]: T[]
}
}
// Depending on the keys length, for example
groupByMulti(someArray, ['key1', 'key2', 'key3']): {
[key: string]: {
[key: string]: {
[key: string]: T[]
}
}
}
I know that this might be possible only if the keys are a ReadonlyArray, but I could live with that. If not possible, How can I achieve any amount of type-safety here?
Example converted to JS
, to exemplify:
const cars = [{
car: 'Audi',
model: 'A6',
style: 'Sedan',
year: '2005',
},
{
car: 'Audi',
model: 'A4',
style: 'Sedan',
year: '2018',
},
{
car: 'Toyota',
model: 'Corola',
style: 'Sedan',
year: '2006',
},
{
car: 'Toyota',
model: 'Camry',
style: 'Sedan',
year: '2006',
},
]
const groupBy = (
objArr,
property,
) => objArr
.reduce((memo, x) => {
if (x[property]) {
const value = (x[property]).toString();
if (!memo[value]) {
memo[value] = [];
}
memo[value].push(x);
}
return memo;
}, {});
const groupByMulti = (arr, keys, propIndex = 0) => {
var grouppedObj = groupBy(arr, keys[propIndex]);
Object.keys(grouppedObj).forEach((key) => {
if (propIndex < keys.length - 1) {
grouppedObj[key] = groupByMulti(grouppedObj[key], keys, propIndex 1);
}
});
return grouppedObj;
}
console.log(JSON.stringify(groupByMulti(cars, ['car', 'year', 'model']), null, 2));
CodePudding user response:
I'm going to assume you care more about type safety from the caller's side, especially since your existing groupBy()
function has at least one as any
type assertion inside its implementation.
Let's say that the call signature of groupByMulti()
should look like:
<T extends Record<K[number], {}>, K extends readonly (keyof T)[]>(
arr: readonly T[],
keys: readonly [...K],
propIndex?: number
) => GroupByMulti<T, K>
for some appropriate definition of GroupByMulti
.
Before we get to GroupByMulti
, let me explain the rest of that call signature:
We want K
to be a tuple type of keys, and we want T
to be an object type with properties at K
whose values at those properties are assignable to {}
, the so-called empty object type which (despite the name) accepts all primitives (like string
or number
) and only rejects null
and undefined
. I did this because you call .toString()
on those properties, and we don't want possibly-undefined
or null
properties to be in there.
If you're wondering about readonly XXX[]
, it's because readonly
arrays and readonly
tuples are less restrictive than "normal" arrays. (The names are deceptive; readonly
here means "can definitely be read, but may or may not be written", while regular arrays are "can definitely be read, and can definitely be written").
And finally the fact that keys
is [...K]
instead of just K
is using variadic tuple types to give the compiler a hint that we want it to keep track of the exact length of that array, since it makes a big difference.
Okay, now let's define GroupByMulti
:
type GroupByMulti<T, K extends readonly any[]> =
K extends readonly [any, ...infer KR] ?
Record<string, GroupByMulti<T, KR>> : readonly T[];
That's a recursive conditional type that walks through the K
tuple. If K
is empty, we just want readonly T[]
, that is, grouping by no keys just gives you an array. Otherwise, we want Record<string, GroupByMulti<T, KR>>
where KR
is an array that's shorter by one than K
. (It's the R
est of the K
array so I call it KR
.