数组层级到对象层级的TS类型推导

前言

这是一个我在基于TS编写一种配置型组件模型时所想到的一种类型推导,个人觉得还是挺有意思的,在推导过程中对TS类型编程的理解又加深了不少;

一个思考题

已知有一个递归数据结构如下:

1
2
3
4
5
6
export interface Item {
key: {
[name: string]: string | number;
};
children: Item[];
}

然后最外层的结构也是一个数组,即:Item[]

希望通过一个TS泛型(即类型函数)将该递归数组结构转换为递归的对象结构,即树状结构;有以下满足上述数组结构的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const demo = [
{
key: {
name: ''
},
children: [
{
key: {
other: ''
},
children: [
{
key: {
age: 1
},
children: []
},
{
key: {
sex: ''
},
children: []
}
]
}
]
},
{
key: {
size: {}
},
children: [
{
key: {
width: 0
},
children: []
},
{
key: {
height: 0
},
children: []
}
]
},
{
key: {
sss: 12
},
children: []
}
]

然后有一个函数形状为:

1
function transfer<T extends any[]> (arr: T): Transfer<T>

通过该函数能够将变量demo转为树状层级结构的数据:

1
const target = transfer(demo)

因此期望得到的target TS推导结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
const target: {
name: {
other: {
age: number;
sex: string;
};
};
size: {
width: number;
height: number;
};
sss: number;
}

请思考如何实现Transfer泛型,使得推导结果满足上述要求。

:这个类型推导很有挑战性,不久前我还一度认为TS不可能推导出来,事实上是TS确实有这个能力;如果能够写出Transfer泛型,基本上对于TS类型编程也就不会太吃力了;

背景

因为在尝试一个配置式复杂组件的数据层抽象,这个配置文件的结构是一个带层级信息的递归数组结构,跟上面的描述差不多,只是还多了一些很多其他的属性;期望从这个配置数据能够得到一个树状的对象结构,该对象就是组件具体承载的数据,以便输出调用,甚至响应化;

所以希望TS能够从数组结构自动推导出该对象的数据结构,以更加方便地调用该数据,即数据结构透明化;由于输入的数组配置实际上是动态的,因此用TS推导起来还是颇具难度的。

推导过程

img

由于TS本身语法的限制,最后得到的推导路线确实是比较曲折的,其中有不少的细节还是值得注意的;

为何不能用带类型标注的类型结构

假如我们得到的层级数组结构一开始就被标记成自定义的类型,或者在参数传递过程中被推导成了自定义类型,那么后续的推导就变得不再具体起来,因为赋予了类型的结构在推导过程中会被TS默认识别成自定义类型,而非根据实际值来推导得到的具体键值对结构;

如何处理推导中出现的never和unknown

由于推导数组元素的联合类型时,空数组会被自动推导成never[],而never类型在跟其它类型进行组合时会得到意外不到的类型,因此需要特别处理never类型的出现;

unknown则会在使用一些自定义泛型时,TS得不到一个透明的推导结构,因此会用unknown来代替;其危害性在于会阻碍后续结构的推导,因此也需要特殊处理;

所以问题来了,那么怎样判断一个类型是neverunknown类型呢?当然本能地会想到使用extends语法来判断,用这个来判断never好像没啥问题,但是用这个来判断unknown类型就有点问题了;

img

这样使用extends来判断unknown类型就会永远只得到unknown;不过后面仔细想了想,这是由于unknownTS中的顶级类型,因此非any类型自然都会满足条件判断,因此换个位置就好了;不过,为了安全起见,想了一个泛型函数来进行同类型的判断:

1
type IsSame<A, B> = (A | B) extends (A & B) ? true : false

这里为啥要把A & B放在后面也是有点原因的,因为当两个原始类型不同时,进行交叉操作就会得到never类型,而never是一个底层类型,即never是所有类型的子类型,因此放在extends前面就会造成误判;

联合类型转交叉类型

联合类型转成相应的交叉类型,这也是一个技巧,这主要是利用了TS目前唯一可用的逆变:即函数参数的类型兼容性(实际上是双向协变);

1
2
3
4
5
/**
* 联合类型转交叉类型
*/
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

至于为啥要把联合类型转成交叉类型,是因为数组推导的是元素联合类型,一个成员类型只是对应目前对象中的一个属性,因此需要进一步合并每个属性得到对象;

infer和extends的注意点

  • infer只能用在extends语句的后面;
  • extends本质上是判断继承关系,和常用的相等判断是有区别的;

为何会有过滤可选属性这一步骤

因为TS在对数组结构进行推导时,会自动合并同层级元素对象的属性,将某个元素不存在的属性,但是其他元素中存在的属性,变成其可选属性:

1
2
3
4
5
6
7
8
9
10
11
12
const demo = [
{
key: {
a: ''
}
},
{
key: {
b: 1
}
}
]

上述数组结构的自动推导结构为:

img

可以看到,原本在元素对象中不存在的属性自动就合并成可选属性了,这样的推导结果并不太准确,因为最好把其中的可选属性过滤掉;那么问题来了,怎么过滤掉对象类型中的可选属性?

1
2
3
4
interface Demo {
a: string;
b?: number;
}

img

由于一旦给属性加上了可选标识,其类型值会自动被加上undefined类型,因此可以利用这个特点来简单地判断一个可选属性:

1
2
3
4
5
6
7
8
9
10
11
/**
* 获取对象类型中非可选的key
*/
type KeepRequiredKey<T> = {
[K in keyof T]: undefined extends T[K] ? never : K;
}[keyof T]

/**
* 过滤对象中可选的属性,如果对象类型为unknown,返回原值(降级处理)
*/
type KeepRequired<T> = IsSame<KeepRequiredKey<T>, unknown> extends true ? T : Pick<T, KeepRequiredKey<T>>

推导代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
/** 一个具有递归特性的数组层级结构 */
export interface ABC {
key: {
[name: string]: string | number;
};
children: ABC[];
}

/**
* 联合类型转交叉类型
*/
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

/**
* 获取对象类型中非可选的key
*/
type KeepRequiredKey<T> = {
[K in keyof T]: undefined extends T[K] ? never : K;
}[keyof T]

/**
* 过滤对象中可选的属性,如果对象类型为unknown,返回原值(降级处理)
*/
type KeepRequired<T> = IsSame<KeepRequiredKey<T>, unknown> extends true ? T : Pick<T, KeepRequiredKey<T>>

/**
* 交叉类型合并成对象类型
*/
type Compute<T> =
T extends Function
? T
: { [K in keyof T]: T[K] }

/**
* 判断两个类型是否为同一类型
*/
type IsSame<A, B> = (A | B) extends (A & B) ? true : false

/**
* 获取第一层以下所有层级的属性结构
*/
type ABCObjectUnion<T extends any[]> =
T extends Array<infer P> ?
(P extends {
key: infer K, // 推断得到当前元素的key
children: infer C // 推断得到当前元素的子元素数组
} ? (IsSame<C, never[]> extends true ? KeepRequired<K> : KeepRequired<{ // 包装成key:type对象,递归处理下一层级
[CK in keyof K]: C extends any[] ? KeepRequired<ABCObject<C>> : never
}>) : never) :
never

/**
* 合并 key:type 联合类型
*/
type ABCObject <T extends any[]> = Compute<UnionToIntersection<ABCObjectUnion<T>>>

/**
* 模拟输出对象结构的函数
* @param a 数组配置
*/
function test2<T extends any[]> (a: T): ABCObject<T> {
return a as any as ABCObject<T>
}

/** 一个满足指定类型的数组配置 */
const t1 = [
{
key: {
name: ''
},
children: [
{
key: {
other: ''
},
children: [
{
key: {
age: 1
},
children: []
},
{
key: {
sex: ''
},
children: []
}
]
}
]
},
{
key: {
size: {}
},
children: [
{
key: {
width: 0
},
children: []
},
{
key: {
height: 0
},
children: []
}
]
},
{
key: {
sss: 12
},
children: []
}
]

const c = test2(t1)

c.name.other.age
c.size.width

TS推导后的结构为:

img

显然是想要的目标对象结构,说明这种推导时可行的;因此在使用该推导结构时vscode都会有相应的提示,十分便利;

img

相关文档