TS 类型体操 - lodash.get

一天, 在某个八卦群里, E 君突然抛出这样一个问题:

打开源题目这个链接, 看一下需求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 笔者: 为了便于讲解, 下文将该类型称为 "original type"/"源类型"
type ElementProps = {
button: {
disabled: boolean;
};
input: {
disabled: boolean;
value: string;
};
img: {
src: string;
};
};

// 笔者: 为了便于讲解, 下文将该类型称为 "target type"/"目标类型"
type ElementPropsMap = {
'button.disabled': boolean;
'input.disabled': boolean;
'input.value': string;
'img.src': string;
};

群友们纷纷建言献策, 最后大致给出了以下几类答案. 笔者记录到本文, 并给出解析.

为优化阅读体验, 我们约定:

  • 笔者会在的 ts 代码中添加注释用于解释, 这类注释会以 // 笔者: 开头
  • NESTED-PROPERTY-NAME 指代(means) a.b.c.d 这类用于多级访问的的属性 key
  • NESTED-PROPERTY-NAME 指向(refer to)的 type 称为 NESTED-PROPERTY-TYPEVALUE, 如对于 {a: {b: c: {d: string}}}, a.b.c.d 指向 string

注意 这个需求很显然可以用递归的思想来解决, 请注意解析中关于递归的部分

第一类: 以 Nested Key 驱动

方案一的原理

  1. 先编写一个类型 IGetNestedKeys<TSRC>, 用于提取出所有可能的由 nested key 组成的深度访问 NESTED-PROPERTY-NAME
  2. 然后编写另一个类型 IGetNestedValues<TSRC, NESTED-PROPERTY-NAME>, 消费 NESTED-PROPERTY-NAME, 用每一个 NESTED-PROPERTY-NAME 提取源类型对应的 value
  3. 组合为:
1
2
3
{
[P in IGetNestedKeys<TSRC>]: IGetNestedValues<TSRC, P>
}

Ver.1

Skyline 君转载了一位朋友的解法

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
type ElementProps = {
button: {
disabled: boolean;
};
input: {
disabled: boolean;
value: string;
dataset: {
foo: number;
bar: string;
};
};
img: {
src: string;
};
};

type ElementPropsMap = FlatElementProps<ElementProps>;

// 笔者: 以下开始实现 FlatElementProps

// 笔者: FlatPropKeys 用于将 nested object 的所有 property-chain 提取出来, 组成一个
// Literal String Union.
type FlatPropKeys<T> = T extends Record<string, unknown>
? {
[P in keyof T]: P extends string ? `${P}.${FlatPropKeys<T[P]>}` : never;
}[keyof T]
: '';

type TrimLastDot<T> = T extends `${infer U}.` ? U : never;

// 笔者: 需要注意的是, 在该版本的实现中,
// 如果你希望准确访问到 nested object 中的 leaf property key, 则 K 应该是有一个 '.' 结尾的
type GetPropType<T, K> = K extends `${infer P}.${infer R}`
? P extends keyof T
? GetPropType<T[P], R>
: never
: T;

type FlatElementProps<T> = {
[P in FlatPropKeys<T> as TrimLastDot<P>]: GetPropType<T, P>;
};

Ver.1 的 IGetNestedKeys

type FlatPropKeys<T>

用于先提取出所有可能的由 nested key 组成的深度访问 NESTED-PROPERTY-NAME

如源类型 ElementPropsinput.dataset.foo 这样的访问路径, 通过 FlatPropKeys<ElementProps>, 则返回的 string union type 中则会包含 'input.dataset.foo.'

image

Ver.1 的 IGetNestedValues

type GetPropType<T, K>

它接受 NESTED-PROPERTY-NAME 为参数, 去源类型中提取 nested value. 例如, 同样对于类型

1
2
3
4
5
6
7
type Foo = {
foo1: {
foo2: {
foo3: string;
};
};
};
  • GetPropType<Foo, 'foo1.'> 等价于 Foo['foo1']
  • GetPropType<Foo, 'foo1.foo2.'> 等价于 Foo['foo1']['foo2']

我们细致地分析下其实现

1
2
3
4
5
type GetPropType<T, K> = K extends `${infer P}.${infer R}`
? P extends keyof T
? GetPropType<T[P], R>
: never
: T;

该实现解释为, 对于一个 nested object 的入参 T

  • 如果 K 不是 NESTED-PROPERTY-NAME, 那么, 直接返回 T 本身
  • 如果 K 是 NESTED-PROPERTY-NAME, 那么, 从其第一个 . 分割为两部分 P, R; 返回 GetPropType<T[P], R>
    • 如 P 刚好是 T 的 property name, T[P] 作为 GetPropType 入参
    • 如果 T[P] 此时本身也是一个 nested object, 那么 GetPropType<T[P], R> 会导致进一步提取 T 中再下一层级的值

很显然这是一个有中止条件的递归, 以下情形都是可能的递归终止:

  1. K 中不包含 .
  2. K 中包含 ., 但 R 等价于 "", 触发 GetPropType<T[P], R>, 到下一级时, 回到上一种情况, 递归终止

如何理解情形 2? 以 GetPropType<Foo, 'foo1.foo2.foo3.'> 为例, 解析过程如下:

1
2
3
4
5
--- `GetPropType<Foo, 'foo1.foo2.foo3.'>`
--- → `GetPropType<Foo['foo1'], 'foo2.foo3.'>`
--- → `GetPropType<Foo['foo1']['foo2'], 'foo3.'>` (情形 2)
--- → `GetPropType<Foo['foo1']['foo2']['foo3'], ''>`
--- → `Foo['foo1']['foo2']['foo3']`

Ver.1 的组合

现在, 结合 FlatPropKeys<T>GetPropType<T, P>, 可以写出

1
2
3
type FlatElementProps<T> = {
[P in FlatPropKeys<T> as TrimLastDot<P>]: GetPropType<T, P>;
};

注意 直接使用 FlatPropKeys<T> 得到的 NESTED-PROPERTY-NAME 末尾会多一个 ., 因此当将其作为 interface key 时, 我们需要使用 as TrimLastDot<P> 对其做一次 remap

image

Ver1. 总结

该版本能够实现需求, 不过, 我们注意到:

  • FlatPropKeys<T> 末尾的 . 给人感觉不够干净利落
  • GetPropType<T, K> 中, 要准确的获取 T 中的 leaf property, 则 K 被预期以 . 结尾

这两点是互相配合的. 但我们能尝试去掉”末尾.“么? 来看一下 Ver.2

Ver.2

很快, M 君提出一个 Ver.1 的改进版本

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
type ElementProps = {
button: {
disabled: boolean;
};
input: {
disabled: boolean;
value: string;
dataset: {
foo: number;
bar: string;
};
};
img: {
src: string;
};
};

type ElementPropsMap = {
'button.disabled': boolean;
'input.disabled': boolean;
'input.value': string;
'input.dataset.foo': number;
'input.dataset.bar': string;
'img.src': string;
};

type GetNestedPropType<T, U extends string> = T extends Record<string, unknown>
? U extends `${infer Left}.${infer Right}`
? GetNestedPropType<T[Left], Right>
: T[U]
: never;

type GetNestedPropsName<T> = T extends Record<string, unknown>
? {
[K in keyof T]: T[K] extends Record<string, any>
? `${string & K}.${GetNestedPropsName<T[K]>}`
: K;
}[keyof T]
: never;

type FlatPropsType<T extends Record<string, any>> = {
[K in string & GetNestedPropsName<T>]: GetNestedPropType<T, K>;
};

type Result = FlatPropsType<ElementProps>;

该版本改进了 Ver.1 “尾 . 不掉”的问题.

Ver.2 的 IGetNestedKeys

和 Ver.1 的 FlatPropKeys<T> 相比, GetNestedPropsName<T> 求出的 NESTED-PROPERTY-NAME 末尾没有 .

image

Ver.2 的 IGetNestedValues

相应的, GetNestedPropType<T, K> 和 Ver.1 中 GetPropType<T, K> 的工作方式没有区别, 只是适配了 GetNestedPropsName<T>

  • GetPropType<T, K>: K 来自于 FlatPropKeys<T>
  • GetNestedPropType<T, K>: K 来自于 GetNestedPropsName<T>

Ver.2 的组合

1
2
3
type FlatPropsType<T extends Record<string, any>> = {
[K in string & GetNestedPropsName<T>]: GetNestedPropType<T, K>;
};

由于在取 K 时不必再特意为了尾部的 . 做一次 remmap, 这个组合看起来比 Ver.1 更干净

第二类: Key-Value 同时递归

第一类方案的思想在于让 NESTED-PROPERTY-NAME 驱动求出目标类型各个 value 的过程, 这个过程中, 递归先发生在求 nested key 时, 尔后发生在通过 nested key 求 nested value 时. 这两个过程是串行的.

另一类方案是, 将这两个过程在一次递归中完成: 同时求 nested key 和 nested value.

这类方案有两个实现版本:

  1. Y 君的版本
  2. 笔者的版本
    两个方案写法略偶有差异, 这里请读者主要参考 Y 君的版本 来理解该方案, 笔者认为这个版本更加简洁, 且更有助于读者体会 TS inference 中 type Union, type Intersection 的奇妙之处

方案二的原理

  1. 预期一个递归过程 Flatten<T, Prefix = "">
    • T: nested value
    • Prefix: 指该递归层级的父层级的 property name, 如果 Prefix"", 当前层级必然为顶层,
    • K: 递归每一层中, 希望处理的当前层级的所有 property name, 一般来说就是 keyof T
  2. type Ret = Flatten<T, Prefix = "">, 对于 K = keyof T
    • 如果 T[K] 是对象, 说明可以继续往下递归, 返回 type Ret = Flatten<T[K], `${Prefix}${K}`>
    • 如果 T[K] 非对象, 说明已经到了 leaf of nested value, 返回 type Ret = { [P in K as `${Prefix}.${k}`]: T[K] }

显然, 这是最常见的递归过程.

Y 君的版本

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
type ElementProps = {
button: {
disabled: boolean;
};
input: {
disabled: boolean;
value: string;
};
img: {
src: string;
};
};
type ElementPropsMap = Omit<UnionToIntersection<ToUnion<ElementProps>>, never>;

type UnionToIntersection<U extends any> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;

// 笔者注: 如果要命名得更完整, 也许可以使用 "FlattenToNestedUnion" :)
type ToUnion<T, Pre extends string = '', K = keyof T> = K extends keyof T
? T[K] extends Record<string, any>
? ToUnion<T[K], Pre extends '' ? K : `${Pre}.${K & string}`>
: { [k in string & K as `${Pre}.${k}`]: T[K] }
: never;

ToUnion 是这个方案的核心, 请读者参照原理来理解其解析过程.

注意 ToUnion<ElementProps> 会得到 { [NESTED-PROPERTY-NAME]: [NESTED-PROPERTY-TYPEVALUE] } 的 Union, 如图所示:

image

因此, 我们需要对 ToUnion<ElementProps> 再做一次将 Union 到 Intersection 的转换, 这是老生常谈了, 最终得到

image

笔者的版本

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
type ElementProps = {
button: {
disabled: boolean;
};
input: {
disabled: boolean;
value: string;
};
img: {
src: string;
};
};
type ElementPropsMap = Omit<UnionToIntersection<ToUnion<ElementProps>>, never>;

type KeyLike = string | number;
type UseKeyLike<T> = Extract<T, KeyLike>;

type U2I<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never;

type PrependPK<K extends KeyLike, PK extends KeyLike> = PK extends '' ? `${K}` : `${PK}.${K}`;
type FlattenKey<T extends { [k: string]: any }, PK extends KeyLike = ''> = U2I<
{
[P in UseKeyLike<keyof T>]: T[P] extends object
? FlattenKey<T[P], PrependPK<P, PK>>
: {
[K in PrependPK<P, PK>]: T[P];
};
}[UseKeyLike<keyof T>]
>;

显然, 笔者的版本Y 君的版本 没有本质的区别. 在写法上的区别:

  • 笔者的版本 在递归的过程中使用 UseKeyLike<T> 提前排除了 string | number 以外的 property (比如 symbol)
  • 笔者的版本 中将 prepend Prefix 的责任单独交给了 PrependPK<K, PK>(现在回过头看其实大可不必 :P

总结

  • 方案一的思路更直观, 更易于理解
  • 方案二思路更简单但也抽象, 能指导写出更简洁的实现

笔者认为, 如果将该题目用于面试. 如果面试者对此题完全没有思路, 出于交流的目的, 使用 Ver.2 作为答案, 将 方案一的原理 用来讲给面试者听较为合适.

其它

延伸思考

如果将题目改为这样, 该如何实现?

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
type ElementProps = {
button: {
disabled: boolean;
};
input: {
disabled: boolean;
value: string;
};
img: {
src: string;
};
};

type ElementPropsMap = {
button: {
disabled: boolean;
};
input: {
disabled: boolean;
value: string;
};
img: {
src: string;
};
'button.disabled': boolean;
'input.disabled': boolean;
'input.value': string;
'img.src': string;
};

实际上, 如果实现了这一类型, 就实现了 lodash.get(target, path)targetExclude<object, Array | Function> 时的类型.

TS template literals 的前向匹配

1
T extends `${infer P}.${infer R}`? ... : never

这样的表达式而言, Condition 中的 inference 采取的是前向匹配, 即,

若 T 为 a.b.c.d, 则 Pa, R 为 b.c.d

关键特性引入点

  • Conditional Types 引入于 TS 2.8
  • Template Literals 引入于 TS 4.1
首页归档简历