diff --git "a/TS \347\261\273\345\236\213\344\275\223\346\223\215/246.\347\262\276\350\257\273\343\200\212Permutation, Flatten, Absolute...\343\200\213.md" "b/TS \347\261\273\345\236\213\344\275\223\346\223\215/246.\347\262\276\350\257\273\343\200\212Permutation, Flatten, Absolute...\343\200\213.md" new file mode 100644 index 00000000..7a83efde --- /dev/null +++ "b/TS \347\261\273\345\236\213\344\275\223\346\223\215/246.\347\262\276\350\257\273\343\200\212Permutation, Flatten, Absolute...\343\200\213.md" @@ -0,0 +1,270 @@ +解决 TS 问题的最好办法就是多练,这次解读 [type-challenges](https://github.com/type-challenges/type-challenges) Medium 难度 17~24 题。 + +## 精读 + +### [Permutation](https://github.com/type-challenges/type-challenges/blob/main/questions/00296-medium-permutation/README.md) + +实现 `Permutation` 类型,将联合类型替换为可能的全排列: + +```ts +type perm = Permutation<'A' | 'B' | 'C'>; // ['A', 'B', 'C'] | ['A', 'C', 'B'] | ['B', 'A', 'C'] | ['B', 'C', 'A'] | ['C', 'A', 'B'] | ['C', 'B', 'A'] +``` + +看到这题立马联想到 TS 对多个联合类型泛型处理是采用分配律的,在第一次做到 `Exclude` 题目时遇到过: + +```ts +Exclude<'a' | 'b', 'a' | 'c'> +// 等价于 +Exclude<'a', 'a' | 'c'> | Exclude<'b', 'a' | 'c'> +``` + +所以这题如果能 “递归触发联合类型分配率”,就有戏解决啊。但触发的条件必须存在两个泛型,而题目传入的只有一个,我们只好创造第二个泛型,使其默认值等于第一个: + +```ts +type Permutation +``` + +这样对本题来说,会做如下展开: + +```ts +Permutation<'A' | 'B' | 'C'> +// 等价于 +Permutation<'A' | 'B' | 'C', 'A' | 'B' | 'C'> +// 等价于 +Permutation<'A', 'A' | 'B' | 'C'> | Permutation<'B', 'A' | 'B' | 'C'> | Permutation<'C', 'A' | 'B' | 'C'> +``` + +对于 `Permutation<'A', 'A' | 'B' | 'C'>` 来说,排除掉对自身的组合,可形成 `'A', 'B'`,`'A', 'C'` 组合,之后只要再递归一次,再拼一次,把已有的排除掉,就形成了 `A` 的全排列,以此类推,形成所有字母的全排列。 + +这里要注意两点: + +1. 如何排除掉自身?`Exclude` 正合适,该函数遇到 `T` 在联合类型 `P` 中时,会返回 `never`,否则返回 `T`。 +2. 递归何时结束?每次递归时用 `Exclude` 留下没用过的组合,最后一次组合用完一定会剩下 `never`,此时终止递归。 + +```ts +// 本题答案 +type Permutation = [T] extends [never] ? [] : T extends U ? [T, ...Permutation>] : [] +``` + +验证一下答案,首先展开 `Permutation<'A', 'B', 'C'>`: + +```ts +'A' extends 'A' | 'B' | 'C' ? ['A', ...Permutation<'B' | 'C'>] : [] +'B' extends 'A' | 'B' | 'C' ? ['B', ...Permutation<'A' | 'C'>] : [] +'C' extends 'A' | 'B' | 'C' ? ['C', ...Permutation<'A' | 'B'>] : [] +``` + +我们再展开第一行 `Permutation<'B' | 'C'>`: + +```ts +'B' extends 'B' | 'C' ? ['B', ...Permutation<'C'>] : [] +'C' extends 'B' | 'C' ? ['C', ...Permutation<'B'>] : [] +``` + +再展开第一行的 `Permutation<'C'>`: + +```ts +'C' extends 'C' ? ['C', ...Permutation] : [] +``` + +此时已经完成全排列,但我们还要处理一下 `Permutation`,使其返回 `[]` 并终止递归。那为什么要用 `[T] extends [never]` 而不是 `T extends never` 呢? + +如果我们用 `T extends never` 代替本题答案,输出结果是 `never`,原因如下: + +```ts +type X = never extends never ? 1 : 0 // 1 + +type Custom = T extends never ? 1 : 0 +type Y = Custom // never +``` + +理论上相同的代码,为什么用泛型后输出就变成 `never` 了呢?原因是 TS 在做 `T extends never ?` 时,会对联合类型进行分配,此时有一个特例,即当 `T = never` 时,会跳过分配直接返回 `T` 本身,所以三元判断代码实际上没有执行。 + +`[T] extends [never]` 这种写法可以避免 TS 对联合类型进行分配,继而绕过上面的问题。 + +### [Length of String](https://github.com/type-challenges/type-challenges/blob/main/questions/00298-medium-length-of-string/README.md) + +实现 `LengthOfString` 返回字符串 T 的长度: + +```ts +LengthOfString<'abc'> // 3 +``` + +破解此题你需要知道一个前提,即 TS 访问数组类型的 `[length]` 属性可以拿到长度值: + +```ts +['a','b','c']['length'] // 3 +``` + +也就是说,我们需要把 `'abc'` 转化为 `['a', 'b', 'c']`。 + +第二个需要了解的前置知识是,用 `infer` 指代字符串时,第一个指代指向第一个字母,第二个指向其余所有字母: + +```ts +'abc' extends `${infer S}${infer E}` ? S : never // 'a' +``` + +那转换后的数组存在哪呢?类似 js,我们弄第二个默认值泛型存储即可: + +```ts +// 本题答案 +type LengthOfString = S extends `${infer S}${infer E}` ? LengthOfString : N['length'] +``` + +思路就是,每次把字符串第一个字母拿出来放到数组 `N` 的第一项,直到字符串被取完,直接拿此时的数组长度。 + +### [Flatten](https://github.com/type-challenges/type-challenges/blob/main/questions/00459-medium-flatten/README.md) + +实现类型 `Flatten`: + +```ts +type flatten = Flatten<[1, 2, [3, 4], [[[5]]]]> // [1, 2, 3, 4, 5] +``` + +此题一看就需要递归: + +```ts +// 本题答案 +type Flatten = T extends [infer Start, ...infer Rest] ? ( + Start extends any[] ? Flatten]> : Flatten +) : Result +``` + +这道题看似答案复杂,其实还是用到了上一题的套路:**递归时如果需要存储临时变量,用泛型默认值来存储**。 + +本题我们就用 `Result` 这个泛型存储打平后的结果,每次拿到数组第一个值,如果第一个值不是数组,则直接存进去继续递归,此时 `T` 自然是剩余的 `Rest`;如果第一个值是数组,则将其打平,此时有个精彩的地方,即 `...Start` 打平后依然可能是数组,比如 `[[5]]` 就套了两层,能不能想到 `...Flatten` 继续复用递归是解题关键。 + +### [Append to object](https://github.com/type-challenges/type-challenges/blob/main/questions/00527-medium-append-to-object/README.md) + +实现 `AppendToObject`: + +```ts +type Test = { id: '1' } +type Result = AppendToObject // expected to be { id: '1', value: 4 } +``` + +结合之前刷题的经验,该题解法很简单,注意 `K in Key` 可以给对象拓展某些指定 Key: + +```ts +// 本题答案 +type AppendToObject = Obj & { + [K in Key]: Value +} +``` + +当然也有不用 `Obj &` 的写法,即把原始对象和新 Key, Value 合在一起的描述方式: + +```ts +// 本题答案 +type AppendToObject = { + [key in (keyof T) | U]: key extends U ? V : T[Exclude] +} +``` + +### [Absolute](https://github.com/type-challenges/type-challenges/blob/main/questions/00529-medium-absolute/README.md) + +实现 `Absolute` 将数字转成绝对值: + +```ts +type Test = -100; +type Result = Absolute; // expected to be "100" +``` + +该题重点是把数字转成绝对值字符串,所以我们可以用字符串的方式进行匹配: + +```ts +// 本题答案 +type Absolute = `${T}` extends `-${infer R}` ? R : `${T}` +``` + +为什么不用 `T extends` 来判断呢?因为 `T` 是数字,这样写无法匹配符号的字符串描述。 + +### [String to Union](https://github.com/type-challenges/type-challenges/blob/main/questions/00531-medium-string-to-union/README.md) + +实现 `StringToUnion` 将字符串转换为联合类型: + +```ts +type Test = '123'; +type Result = StringToUnion; // expected to be "1" | "2" | "3" +``` + +还是老套路,用一个新的泛型存储答案,递归即可: + +```ts +// 本题答案 +type StringToUnion = T extends `${infer F}${infer R}` ? StringToUnion : P +``` + +当然也可以不依托泛型存储答案,因为该题比较特殊,可以直接用 `|`: + +```ts +// 本题答案 +type StringToUnion = T extends `${infer F}${infer R}` ? F | StringToUnion : never +``` + +### [Merge](https://github.com/type-challenges/type-challenges/blob/main/questions/00599-medium-merge/README.md) + +实现 `Merge` 合并两个对象,冲突时后者优先: + +```ts +type foo = { + name: string; + age: string; +} +type coo = { + age: number; + sex: string +} + +type Result = Merge; // expected to be {name: string, age: number, sex: string} +``` + +这道题答案甚至是之前题目的解题步骤,即用一个对象描述 + `keyof` 的思维: + +```ts +// 本题答案 +type Merge = { + [K in keyof A | keyof B] : K extends keyof B ? B[K] : ( + K extends keyof A ? A[K] : never + ) +} +``` + +只要知道 `in keyof` 支持元组,值部分用 `extends` 进行区分即可,很简单。 + +### [KebabCase](https://github.com/type-challenges/type-challenges/blob/main/questions/00612-medium-kebabcase/README.md) + +实现驼峰转横线的函数 `KebabCase`: + +```ts +KebabCase<'FooBarBaz'> // 'foo-bar-baz' +``` + +还是老套路,用第二个参数存储结果,用递归的方式遍历字符串,遇到大写字母就转成小写并添加上 `-`,最后把开头的 `-` 干掉就行了: + +```ts +// 本题答案 +type KebabCase = S extends `${infer F}${infer R}` ? ( + Lowercase extends F ? KebabCase : KebabCase}`> +) : RemoveFirstHyphen + +type RemoveFirstHyphen = S extends `-${infer Rest}` ? Rest : S +``` + +分开写就非常容易懂了,首先 `KebabCase` 每次递归取第一个字符,如何判断这个字符是大写呢?只要小写不等于原始值就是大写,所以判断条件就是 `Lowercase extends F` 的 false 分支。然后再写个函数 `RemoveFirstHyphen` 把字符串第一个 `-` 干掉即可。 + +## 总结 + +TS 是一门编程语言,而不是一门简单的描述或者修饰符,很多复杂类型问题要动用逻辑思维来实现,而不是查查语法就能简单实现。 + +> 讨论地址是:[精读《Permutation, Flatten, Absolute...》· Issue #426 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/426) + +**如果你想参与讨论,请 [点击这里](https://github.com/dt-fe/weekly),每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。** + +> 关注 **前端精读微信公众号** + + + +> 版权声明:自由转载-非商用-非衍生-保持署名([创意共享 3.0 许可证](https://creativecommons.org/licenses/by-nc-nd/3.0/deed.zh)) + + diff --git a/readme.md b/readme.md index 68c8df0a..78d7a9fc 100644 --- a/readme.md +++ b/readme.md @@ -6,7 +6,7 @@ 前端界的好文精读,每周更新! -最新精读:245.精读《Promise.all, Replace, Type Lookup...》 +最新精读:246.精读《Permutation, Flatten, Absolute...》 素材来源:[周刊参考池](https://github.com/ascoders/weekly/issues/2) @@ -197,6 +197,7 @@ - 243.精读《Pick, Awaited, If》 - 244.精读《Get return type, Omit, ReadOnly...》 - 245.精读《Promise.all, Replace, Type Lookup...》 +- 246.精读《Permutation, Flatten, Absolute...》 ### 设计模式