TS类型体操 之 中级类型体操挑战收官之战

7/12/2022 TS

# 中级类型体操挑战收官之战

之前在写入门前置知识的时候就提到过 “有几道题目没解出来”,其实这 2 道题目和后面 “困难” 的部分题目题型很像
所以今天理解透这 2 道题目后中级类型应该就过关了,顺便还能为“困难”打下基础

分别是 : 20・Promise.all 12・可串联构造器

这 2 个题目我个人总结就是 “无中生有”,不存在的参数,就自己创建一个

# 热身 - 3196 · Flip Arguments

在讲上面 2 个题目时想先讲一下 3196・Flip Arguments ,一个 获取方法内参数,并且将他们反转 的一个题目(之前好像也没机会讲)

这个题目其实就是一个简单的 extends 的应用,难在一个小小的思维转变

需求如下:获取一个方法内的参数,并且将他们反转过来

type cases = [
  Expect<Equal<FlipArguments<() => boolean>, () => boolean>>,
  Expect<Equal<FlipArguments<(foo: string) => number>, (foo: string) => number>>,
  Expect<Equal<FlipArguments<(arg0: string, arg1: number, arg2: boolean) => void>, (arg0: boolean, arg1: number, arg2: string) => void>>,
]
1
2
3
4
5

在这个题目,需要理解几个点

  1. extends 其实就是一个包含关系,包括 方法 也可以 extends
  2. 说到反转内容,数组是最容易反转的,03192 就是 Reverse 的题目了
  3. 方法中的参数,基于 ES6 的知识,结合 ... 就能形成数组了,和 2 相呼应

所以解题重点就在于 extends

答案如下:

// Reverse 是 03192 题目已经做出来的,这里就直接复用不重复写了
type FlipArguments<T> = T extends (..._: infer args) => infer R ? (...arg0: Reverse<args>) => R : T
1
2
  • infer 是计算,是占位,之前有讲过, infer R 就是帮返回值占个位
  • ..._: infer args 这一块则是整个题目的灵魂所在,这里我们不能用 infer 占位,只能用一个变量来占(这个变量起什么名字都行),然后他的类型才是 infer args 这样的话,args 则代替了输入的参数的全部内容了
  • 包括最终返回的时候 (...arg0: Reverse<args>) 这个表达式中,args0并没有任何实际意义,也是一个参数占位而已

讲这个题目主要是为了 20・Promise.all 做个铺垫,因为 Promise.all 也有获取参数的场景

# TS 类型体操遇上 declare

declare 是一个声明,可以看到 TS 的源码中对 JS 的方法很多都用了声明,这也是为什么我们使用 parseInt 能有类型检查的原因。declare就不在这里展开,姑且理解为 为一个方法/函数约定参数和返回值


讲题 : 00020-medium-promise-all (opens new window)

要求:键入函数 PromiseAll,它接受 PromiseLike 对象数组,返回值应为 Promise<T>,其中 T 是解析的结果数组。

答案初始模版:

declare function PromiseAll(values: any): any
1

本以为只是一个简单的获取 values 然后包裹一层 Promise 的操作,可是由于各种语法问题,PromiseAll 本身就是一个方法而不是泛型参数,所以上面讲的套路行不通了(注意区分和对比)。

扣一下今天的主题无中生有

  • 在之前的题目中,比如我们要用到计数器,我们会新建一个 C extends unknown[] = []
  • 需要新的变量都会新建一个变量并且赋一个默认值去用,这也算无中生有

Q:思考一下在 declare 中如果想无中生有的话,要加那里?
A:方法想无中生有,加泛型!比如下面这样的

declare function PromiseAll<T>(values: any): any
1

在回到题目,返回值应为 Promise<T>,其中 T 是解析的结果数组。

  • 返回值是 Promise
  • Promise 里面的泛型类型 T 是一个数组(这里的 T,也是无中生有生出来的,不然原题模版哪里来的 T),所以我们直接约束 T 为一个数组
declare function PromiseAll<T extends any[]>(values: any): Promise<T>

// 测试用例
type TestPromise = typeof promiseAllTest1 // 这时候得到的是 Promise<any[]>
1
2
3
4

到这一步就已经成功了一半,返回 Promise,并且泛型 T 是数组,剩下的一半就是把 T 转换为具体的数组,而不是 any[]

错误示范,错误示范,错误示范

// 报错
// 'T' is declared but its value is never read.
// 'infer' declarations are only permitted in the 'extends' clause of a conditional type.
declare function PromiseAll<T extends any[]>(values: infer T): Promise<T>

// 或 错误示范2:
// 不报错可是也没效果
declare function PromiseAll<T extends any[]>(values: T): Promise<T>
1
2
3
4
5
6
7
8
  • 错误 1 意思是 infer 只能在 extends 表达式里面去占位,普通情况下不行
  • 错误示范 2 中,因为 T 本来就是 any[] ,values 也确实是数组,没毛病,可是也推导不出来结果

正确 25% 的答案:

declare function PromiseAll<T extends any[]>(values: [...T]): Promise<T>
1

利用错误示范 2 中的原理,反推 T,values 是数组,而我们要做的是获取这个数组里面的内容, 如果我们把 T 分散了([...T])这个类型依旧没报错的话,T 就和 values 完全相等了,这时候返回 T,测试用例第一个例子就 pass 了

这时候测试用例是过了,可是 3,4 行代码类型检查不过。
as const 这个也在 前置知识里面提到过,as const 的会把所有的值拿出来,而且变成 readonly。因为 const 确实是只读的标记。

正确 50% 的答案:

declare function PromiseAll<T extends any[]>(values: readonly [...T]): Promise<T>
1

加上 readonly 后,声明的等式成立了,就还差 2 个测试用例的情况,因为他们传入的数值里面包含 Promise.resolve 这种情况。我们需要从 Promise.resolve 中把参数取出来,那么就要从返回值 Promise<T> 去入手了

Promise<T> 注意这里的 <T>已经是 泛型 了,又回到熟悉的 type 体操的感觉了,这时候题目就变成了

正确 100% 的答案(复杂版):

declare function PromiseAll<T extends any[]>(values: readonly [...T]): Promise<PromiseRes<T>>

type PromiseRes<T extends any[], R extends any[] = []> = T extends [infer F, ...infer Rest] ? PromiseRes<Rest, [...R, F extends Promise<infer A> ? A : F]> : R
1
2
3

新建了一个 PromiseRes 为了处理 T 这个数组,F extends Promise<infer A> ? A : F 就是为了判断是不是 Promise 类型的,是的话把 A 提出来,最后存到一个数组里面返回。用例通过,木的问题


正确 100% 的答案(简单版):

declare function PromiseAll<T extends any[]>(values: readonly [...T]): Promise<{ [P in keyof T]: T[P] extends Promise<infer R> ? R : T[P] }>
1

这里有 2 个小知识点稍微在拓展下

# Promise<{[P in keyof]}> 里面为什么可以这样写

说再多还不如写段代码

// 写法1.
setTimeout(function () {
  console.log('定时器')
}, 1000)

// 写法2
var log = function () {
  console.log('定时器')
}
setTimeout(log, 1000)
1
2
3
4
5
6
7
8
9
10

2 种写法最后运行效果一模一样,同理,上面简单版写法还能写成这样的:

declare function PromiseAll<T extends any[]>(values: readonly [...T]): Promise<PromiseRes<T>>

type PromiseRes<T> = { [P in keyof T]: T[P] extends Promise<infer R> ? R : T[P] }
1
2
3

# {[P in keyof T]} 这不是对象的写法吗,为什么在这里最终会返回数组类型?

从 Pick 方法开始学体操的时候 {[P in keyof T]}确实返回的都是对象类型,毕竟 {} 在那里摆着

不过凡事都有例外,因为 T 是个数组类型,而 keyof T 得到的其实是 0,1,2,3... 数组长度的索引,和数组特有的属性方法。所以 P 对应的也是 0,1,2,3... + 特有的属性和方法

比如这个例子中

var aKeys:ArrKeys = ''
type Arr = ['Jioho', 'Promise']
type ArrKeys = keyof Arr
1
2
3

根据智能提示看到 ArrKeys 的取值范围全是数组的方法和属性

所以说,{[P in keyof T]} 的返回值还得看 T 到底是什么类型

# 12 · 可串联构造器

这一题的难度在用这是一个链式调用,而且还得把之前的记录动态累计下来

const result1 = a.option('foo', 123).option('bar', { value: 'Hello World' }).option('name', 'type-challenges').get()
1

之前接触的题目都是传入数据,得出结果。而且这个题目和 Promise.all 一样,没有给出很多的初始泛型,这就又得靠我们自己的 无中生有 技巧

解题的思路上面也有说了

  • 一个是调用 option 时 键值对 动态累计下来
  • 题目给给出一个 get 来获取结果

突破点首先在 get,因为这才是获取结果的位置,假设我们返回 T,T 包含了所有的键值对内容。

按作用域来看,T 肯定是作为累计的变量,所以 T 作用域应该在 getoption 之上,第一步的无中生有就给 Chainable 加一个泛型,然后记得补上默认值(根据返回结果,返回的应该是个对象)

第一步思考结果如下:

type Chainable<T = {}> = {
  option(key: string, value: any): any
  get(): T
}
1
2
3
4

第二步:链式调用和变量累计?

链式调用其实核心原理就是把 this 把当前对象作为调用的返回值。

Chainable(a)含有 option 方法,调用 option 后在返回一个 Chainable(a),这时候返回值就又能继续调用了

做过前面题目的其实都应该知道,想做到变量累积,那必须是递归(比如计数器的累积),不断的调用自身,并且不断追加新参数进去

结合上面说的 2 点:递归,返回自己,追加参数,得出下面的答案

这是有点错误的示范:

// 这是 527 题目的答案,为Object追加新的键值对。复用上了
type AppendToObject<T, U extends string, V> = { [P in keyof T | U]: P extends keyof T ? T[P] : V }

type Chainable<T = {}> = {
  // 错误地方: AppendToObject<T,key, value>
  option(key: string, value: any): Chainable<AppendToObject<T,key, value>>
  get(): T
}
1
2
3
4
5
6
7
8

以上的结果思路是对的,不过就是注释的地方有点问题 AppendToObject<T,key, value> 这时候的 key 和 value 还是 JS 变量,而不是 TS 的内容。

这时候可能就会想到 typeof 不是可以把 JS 的内容转换为 TS 吗?

转是可以转,不过转了之后返回值是 stirng,而不是我们想要的 123

var b = '123'
type Test = typeof b // string
1
2

到这一步,应该要想起 Promise.all 题目讲到的 变量倒推,复习下 Promise.all

declare function PromiseAll<T extends any[]>(values: readonly [...T]): ...
1

定义了泛型 T,直接 readonly [...T] 放入 values 中进行一个类似倒推的操作。对于链式调用来说同理

type AppendToObject<T, U extends string, V> = { [P in keyof T | U]: P extends keyof T ? T[P] : V }

type Chainable<T = {}> = {
  option<K extends string,V>(key: K, value: V): Chainable<AppendToObject<T,K,V>>
  get(): T
}
1
2
3
4
5
6

定义KV,这 2 个类型放入参数中,进行一个倒推,用 K 来代表 key 的值,V 代表 value 的值,在结合 AppendToObject,就可以为 T 动态添加参数了

与此同时 option 的返回值返回的则是最新的 Chainable 和当前链式调用后最全的 T,这时候在调用 get 就能把累积的变量都拿出来了

# 总结

这几个题目给了非常好的提示性效果,没有参数要学会自己创造参数,学会无中生有

如果能用 infer 就尽量用好 infer,不过 infer 只能在 extends 相关的表达式里面去用

像 Promise.all 和链式调用这种函数/方法里面用不了的,就用一个新变量进行一个类型的倒推,把对应的值反过来约束为对应的泛型

尤其是最后一题,一定要好好理解,因为我去探过路了,困难题 中会遇到很多这样的情况,需要学会 无中生有和并对象

至此中等题目就刷完了,我觉得比较有用的技巧也总结了好几篇笔记,欢迎到我的 TS 专栏翻一翻,点个赞支持下。祝你们也刷题愉快,困难题见!

Last Updated: 1/7/2024, 5:51:59 PM