如何在typescript中构建一个类型安全的多重属性groupBy



我已经有了一个groupBy函数,它接收一个对象数组和一个键。它能够按单个属性进行分组。

const groupBy = <T extends Record<string, unknown>, U extends keyof T>(
objArr: T[],
key: U,
): { [key: string]: T[] }

因为我得到一个depth=1对象作为结果,我很满意返回类型是{ [key: string]: T[] }

但是现在我需要扩展这个函数,以便能够按多个属性分组,并使用depth=keys.length创建一个嵌套对象。

groupBy函数:

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> });

groupByMulti函数,它只是递归地调用groupBy函数,直到它到达最后一个分组键。

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;
}

我想知道是否有可能建立一个对象类型固定深度:

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[]
}
}
}

我知道这可能只有在键是ReadonlyArray时才有可能,但我可以忍受。如果不可能,我如何在这里实现任何类型安全?

转换为JS的示例:

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));

我将假设您从调用者的方面更关心类型安全,特别是因为您现有的groupBy()函数在其实现中至少有一个as any类型断言。

假设groupByMulti()的呼叫签名应该是这样的:

<T extends Record<K[number], {}>, K extends readonly (keyof T)[]>(
arr: readonly T[], 
keys: readonly [...K],
propIndex?: number
) => GroupByMulti<T, K>

给出GroupByMulti的适当定义。

在我们进入GroupByMulti之前,让我解释一下呼叫签名的其余部分:

我们希望K是键的元组类型,我们希望T是具有K属性的对象类型,这些属性的值可分配给{},即所谓的空对象类型,它(尽管名称)接受所有原语(如stringnumber),只拒绝nullundefined。我这样做是因为你在这些属性上调用了.toString(),我们不希望可能-undefinednull属性在那里。

如果你想知道readonly XXX[],这是因为readonly数组和readonly元组比"normal"限制少。数组。(这些名字具有欺骗性;这里的readonly表示"肯定可以读,但也可以不可以写",而常规数组是"肯定可以读,也肯定可以写"。

最后,keys[...K]而不仅仅是K,这是使用可变元组类型给编译器一个提示,我们希望它跟踪该数组的确切长度,因为它有很大的不同。


好的,现在我们来定义GroupByMulti:

type GroupByMulti<T, K extends readonly any[]> =
K extends readonly [any, ...infer KR] ?
Record<string, GroupByMulti<T, KR>> : readonly T[];

这是一个遍历K元组的递归条件类型。如果K为空,我们只需要readonly T[],也就是说,不按键分组只给您一个数组。否则,我们想要Record<string, GroupByMulti<T, KR>>,其中KR是一个比K短1的数组。(这是K数组的Rest,所以我称之为KR。🤷‍♂️)。

实现中充满了类型断言,我就不花时间解释了:

const groupByMulti = <
T extends Record<K[number], {}>,
K extends readonly (keyof T)[]
>(arr: readonly T[], keys: readonly [...K], propIndex = 0) => {
var grouppedObj: any = groupBy(arr, keys[propIndex]);
Object.keys(grouppedObj).forEach((key) => {
if (propIndex < keys.length - 1) {
grouppedObj[key] = groupByMulti<any, any[]>(
grouppedObj[key], keys, propIndex + 1);
} else {
grouppedObj[key] = grouppedObj[key]
}
});
return grouppedObj as GroupByMulti<T, K>;
}

唯一重要的一点是我们返回了一个类型为GroupByMulti<T, K>的值。让我们测试一下:


const result = groupByMulti(cars, ['car', 'year', 'model']);
/* const result: Record<string, Record<string, Record<string, readonly {
car: string;
model: string;
style: string;
year: string;
}[]>>> */
Object.keys(result).forEach(k =>
Object.keys(result[k]).forEach(l =>
Object.keys(result[k][l]).forEach(m => {
result[k][l][m].forEach(c => console.log(c.car));
})
)
)

看起来不错。result的类型是具有string键的三层嵌套对象,其深层对象类型是T对象的an。这允许我们以某种类型安全的方式处理结果(编译器知道result[k][l][m]是数组)。

Playground链接到代码

相关内容

最新更新