当前期刊数: 248

解决 TS 问题的最好办法就是多练,这次解读 type-challenges Medium 难度 33~40 题。

精读

MinusOne

用 TS 实现 MinusOne 将一个数字减一:

1
2
type Zero = MinusOne<1> // 0
type FiftyFour = MinusOne<55> // 54

TS 没有 “普通” 的运算能力,但涉及数字却有一条生路,即 TS 可通过 ['length'] 访问数组长度,几乎所有数字计算都是通过它推导出来的。

这道题,我们只要构造一个长度为泛型长度 -1 的数组,获取其 ['length'] 属性即可,但该方案有一个硬伤,无法计算负值,因为数组长度不可能小于 0:

1
2
3
4
5
6
7
// 本题答案
type MinusOne<T extends number, arr extends any[] = []> = [
...arr,
''
]['length'] extends T
? arr['length']
: MinusOne<T, [...arr, '']>

该方案的原理不是原数字 -1,而是从 0 开始不断加 1,一直加到目标数字减一。但该方案没有通过 MinusOne<1101> 测试,因为递归 1000 次就是上限了。

还有一种能打破递归的思路,即:

1
type Count = ['1', '1', '1'] extends [...infer T, '1'] ? T['length'] : 0 // 2

也就是把减一转化为 extends [...infer T, '1'],这样数组 T 的长度刚好等于答案。那么难点就变成了如何根据传入的数字构造一个等长的数组?即问题变成了如何实现 CountTo<N> 生成一个长度为 N,每项均为 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
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
type CountTo<
T extends string,
Count extends 1[] = []
> = T extends `${infer First}${infer Rest}`
? CountTo<Rest, N<Count>[keyof N & First]>
: Count

type N<T extends 1[] = []> = {
'0': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T]
'1': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1]
'2': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1]
'3': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1]
'4': [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, 1, 1, 1, 1]
'5': [
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
1,
1,
1,
1,
1
]
'6': [
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
1,
1,
1,
1,
1,
1
]
'7': [
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
1,
1,
1,
1,
1,
1,
1
]
'8': [
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
1,
1,
1,
1,
1,
1,
1,
1
]
'9': [
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
...T,
1,
1,
1,
1,
1,
1,
1,
1,
1
]
}

也就是该方法可以高效的实现 CountTo<'1000'> 产生长度为 1000,每项为 1 的数组,更具体一点,只需要遍历 <T> 字符串长度次数,比如 1000 只要递归 4 次,而 10000 也只需要递归 5 次。

CountTo 函数体的逻辑是,如果字符串 T 非空,就拆为第一个字符 First 与剩余字符 Rest,然后拿剩余字符递归,但是把 First 一次性生成到了正确的长度。最核心的逻辑就是函数 N<T> 了,它做的其实是把 T 的数组长度放大 10 倍再追加上当前数量的 1 在数组末尾。

keyof N & First 也是神来之笔,此处本意就是访问 First 下标,但 TS 不知道它是一个安全可访问的下标,而 keyof N & First 最终值还是 First,也可以被 TS 安全识别为下标。

CountTo<'123'> 举例:

第一次执行 First='1'Rest='23'

1
2
CountTo<'23', N<[]>['1']>
// 展开时,...[] 还是 [],所以最终结果为 ['1']

第二次执行 First='2'Rest='3'

1
2
CountTo<'3', N<['1']>['2']>
// 展开时,...[] 有 10 个,所以 ['1'] 变成了 10 个 1,追加上 N 映射表里的 2 个 1,现在一共有 12 个 1

第三次执行 First='3'Rest=''

1
2
CountTo<'', N<['1', ...共 12 个]>['3']>
// 展开时,...[] 有 10 个,所以 12 个 1 变成 120 个,加上映射表中 3,一共有 123 个 1

总结一下,就是将数字 T 变成字符串,从最左侧开始获取,每次都把已经积累的数组数量乘以 10 再追加上当前值数量的 1,实现递归次数极大降低。

PickByType

实现 PickByType<P, Q>,将对象 P 中类型为 Q 的 key 保留:

1
2
3
4
5
6
7
8
9
type OnlyBoolean = PickByType<
{
name: string
count: number
isReadonly: boolean
isEnable: boolean
},
boolean
> // { isReadonly: boolean; isEnable: boolean; }

本题很简单,因为之前碰到 Remove Index Signature 题目时,我们用了 K in keyof P as xxx 来对 Key 位置进行进一步判断,所以只要 P[K] extends Q 就保留,否则返回 never 即可:

1
2
3
4
// 本题答案
type PickByType<P, Q> = {
[K in keyof P as P[K] extends Q ? K : never]: P[K]
}

StartsWith

实现 StartsWith<T, U> 判断字符串 T 是否以 U 开头:

1
2
3
type a = StartsWith<'abc', 'ac'> // expected to be false
type b = StartsWith<'abc', 'ab'> // expected to be true
type c = StartsWith<'abc', 'abcd'> // expected to be false

本题也比较简单,用递归 + 首字符判等即可破解:

1
2
3
4
5
6
7
8
9
10
11
// 本题答案
type StartsWith<
T extends string,
U extends string
> = U extends `${infer US}${infer UE}`
? T extends `${infer TS}${infer TE}`
? TS extends US
? StartsWith<TE, UE>
: false
: false
: true

思路是:

  1. U 如果为空字符串则匹配一切场景,直接返回 true;否则 U 可以拆为以 US(U Start) 开头、UE(U End) 的字符串进行后续判定。
  2. 接着上面的判定,如果 T 为空字符串则不可能被 U 匹配,直接返回 false;否则 T 可以拆为以 TS(T Start) 开头、TE(T End) 的字符串进行后续判定。
  3. 接着上面的判定,如果 TS extends US 说明此次首字符匹配了,则递归匹配剩余字符 StartsWith<TE, UE>,如果首字符不匹配提前返回 false

笔者看了一些答案后发现还有一种降维打击方案:

1
2
3
4
// 本题答案
type StartsWith<T extends string, U extends string> = T extends `${U}${string}`
? true
: false

没想到还可以用 ${string} 匹配任意字符串进行 extends 判定,有点正则的意思了。当然 ${string} 也可以被 ${infer X} 代替,只是拿到的 X 不需要再用到了:

1
2
3
4
// 本题答案
type StartsWith<T extends string, U extends string> = T extends `${U}${infer X}`
? true
: false

笔者还试了下面的答案在后缀 Diff 部分为 string like number 时也正确:

1
2
3
4
// 本题答案
type StartsWith<T extends string, U extends string> = T extends `${U}${number}`
? true
: false

说明字符串模板最通用的指代是 ${infer X}${string},如果要匹配特定的数字类字符串也可以混用 ${number}

EndsWith

实现 EndsWith<T, U> 判断字符串 T 是否以 U 结尾:

1
2
3
type a = EndsWith<'abc', 'bc'> // expected to be true
type b = EndsWith<'abc', 'abc'> // expected to be true
type c = EndsWith<'abc', 'd'> // expected to be false

有了上题的经验,这道题不要太简单:

1
2
3
4
// 本题答案
type EndsWith<T extends string, U extends string> = T extends `${string}${U}`
? true
: false

这可以看出 TS 的技巧掌握了就非常简单,但不知道就几乎无解,或者用很笨的递归来解决。

PartialByKeys

实现 PartialByKeys<T, K>,使 K 匹配的 Key 变成可选的定义,如果不传 K 效果与 Partial<T> 一样:

1
2
3
4
5
6
7
interface User {
name: string
age: number
address: string
}

type UserPartialName = PartialByKeys<User, 'name'> // { name?:string; age:number; address:string }

看到题目要求是不传参数时和 Partial<T> 行为一直,就应该能想到应该这么起头写个默认值:

1
type PartialByKeys<T, K = keyof T> = {}

我们得用可选与不可选分别描述两个对象拼起来,因为 TS 不支持同一个对象下用两个 keyof 描述,所以只能写成两个对象:

1
2
3
4
5
type PartialByKeys<T, K = keyof T> = {
[Q in keyof T as Q extends K ? Q : never]?: T[Q]
} & {
[Q in keyof T as Q extends K ? never : Q]: T[Q]
}

但不匹配测试用例,原因是最终类型正确,但因为分成了两个对象合并无法匹配成一个对象,所以需要用一点点 Magic 行为合并:

1
2
3
4
5
6
7
8
9
10
// 本题答案
type PartialByKeys<T, K = keyof T> = {
[Q in keyof T as Q extends K ? Q : never]?: T[Q]
} & {
[Q in keyof T as Q extends K ? never : Q]: T[Q]
} extends infer R
? {
[Q in keyof R]: R[Q]
}
: never

将一个对象 extends infer R 再重新展开一遍看似无意义,但确实让类型上合并成了一个对象,很有意思。我们也可以将其抽成一个函数 Merge<T> 来使用。

本题还有一个函数组合的答案:

1
2
3
4
5
6
7
// 本题答案
type Merge<T> = {
[K in keyof T]: T[K]
}
type PartialByKeys<T, K extends PropertyKey = keyof T> = Merge<
Partial<T> & Omit<T, K>
>
  • 利用 Partial & Omit 来合并对象。
  • 因为 Omit<T, K>K 有来自于 keyof T 的限制,而测试用例又包含 unknown 这种不存在的 Key 值,此时可以用 extends PropertyKey 处理此场景。

RequiredByKeys

实现 RequiredByKeys<T, K>,使 K 匹配的 Key 变成必选的定义,如果不传 K 效果与 Required<T> 一样:

1
2
3
4
5
6
7
interface User {
name?: string
age?: number
address?: string
}

type UserRequiredName = RequiredByKeys<User, 'name'> // { name: string; age?: number; address?: string }

和上题正好相反,答案也呼之欲出了:

1
2
3
4
5
6
type Merge<T> = {
[K in keyof T]: T[K]
}
type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge<
Required<T> & Omit<T, K>
>

等等,一个测试用例都没过,为啥呢?仔细想想发现确实暗藏玄机:

1
2
3
4
5
Merge<{
a: number
} & {
a?: number
}> // 结果是 { a: number }

也就是同一个 Key 可选与必选同时存在时,合并结果是必选。上一题因为将必选 Omit 掉了,所以可选不会被必选覆盖,但本题 Merge<Required<T> & Omit<T, K>>,前面的 Required<T> 必选优先级最高,后面的 Omit<T, K> 虽然本身逻辑没错,但无法把必选覆盖为可选,因此测试用例都挂了。

解法就是破解这一特征,用原始对象 & 仅包含 K 的必选对象,使必选覆盖前面的可选 Key。后者可以 Pick 出来:

1
2
3
4
5
6
type Merge<T> = {
[K in keyof T]: T[K]
}
type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge<
T & Required<Pick<T, K>>
>

这样就剩一个单测没通过了:

1
Expect<Equal<RequiredByKeys<User, 'name' | 'unknown'>, UserRequiredName>>

我们还要兼容 Pick 访问不存在的 Key,用 extends 躲避一下即可:

1
2
3
4
5
6
7
// 本题答案
type Merge<T> = {
[K in keyof T]: T[K]
}
type RequiredByKeys<T, K extends PropertyKey = keyof T> = Merge<
T & Required<Pick<T, K extends keyof T ? K : never>>
>

Mutable

实现 Mutable<T>,将对象 T 的所有 Key 变得可写:

1
2
3
4
5
6
7
interface Todo {
readonly title: string
readonly description: string
readonly completed: boolean
}

type MutableTodo = Mutable<Todo> // { title: string; description: string; completed: boolean; }

把对象从可写变成不可写:

1
2
3
type Readonly<T> = {
readonly [K in keyof T]: T[K]
}

从不可写改成可写也简单,主要看你是否记住了这个语法:-readonly

1
2
3
4
// 本题答案
type Mutable<T extends object> = {
-readonly [K in keyof T]: T[K]
}

OmitByType

实现 OmitByType<T, U> 根据类型 U 排除 T 中的 Key:

1
2
3
4
5
6
7
8
9
type OmitBoolean = OmitByType<
{
name: string
count: number
isReadonly: boolean
isEnable: boolean
},
boolean
> // { name: string; count: number }

本题和 PickByType 正好反过来,只要把 extends 后内容对调一下即可:

1
2
3
4
// 本题答案
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K]
}

总结

本周的题目除了 MinusOne 那道神仙解法比较难以外,其他的都比较常见,其中 Merge 函数的妙用需要领悟一下。

讨论地址是:精读《MinusOne, PickByType, StartsWith…》· Issue ##430 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证


本站由 钟意 使用 Stellar 1.28.1 主题创建。
又拍云 提供CDN加速/云存储服务
vercel 提供托管服务
湘ICP备2023019799号-1
总访问 次 | 本页访问