用最简单的话讲解Utility Types-Parameters&ReturnType-上(硬核讲解infer)

575 阅读11分钟

背景

XDM好,我是Utility Types系列水文的作者梅利奥猪猪,其实今天在讲解的ParametersReturnType前,我会先讲解下infer,因为看了源码实现有个关键字infer,然后完全不知道它是干嘛用的(不会大家都知道吧,不知道的要么点赞跟我一起学习下,哈哈),于是决定先让它成为本文的MVP,简单讲解下infer概念(上篇),再去水Parameters和ReturnType(下篇),这就是我的水文计划!为了通俗易懂的讲解infer,我其实看了许多大佬的讲解infer,同时希望能产出笔记,以供自己和XDM一起学习,如有理解不对的地方,希望大佬们轻喷,并指导萌新们一起进步!

infer

类型间的关系

众所周知,2个类型之间的兼容性,他们之间的关系,一般有这么三种

  • A是B的子类型
  • A是B的超(父)类型
  • A和B不兼容

其实TS最大的特性是鸭子类型,它仅关注对象上的属性和方法,而不关注继承关系,具体什么是鸭子类型,请往后看!

鸭子类型

先来看代码吧

interface Duck {
    swim: true
} // 鸭子会游泳,没毛病
interface Dog {
    swim: true
    bark: true
}  // 注意这里没有写继承鸭子(让狗继承鸭子是不是很奇怪),狗也会游泳,然后狗还会吠叫,所以比鸭子多一个属性

const dog1: Dog = {
    swim: true,
    bark: true
} // 声明了dog1,它就是个会游泳又会吠叫的小可爱

const duck1: Duck = dog1 // 牛逼,注意这一行没有报错,我们把变量dog1,赋给了duck1,因为duck1,只要有swim的属性就可以了

const duck2: Duck = {
    swim: true
} // 来个可爱的小鸭子2号
// const dog2: Dog = duck2 // error 这里的赋值肯定会报错,因为狗狗还要狗吠的呀,你只会游泳不行的

上述的代码,注释已经大概讲明白了鸭子类型(只要你有我就不会报错),简单的说A 是 B 的子类型,那么类型为 A 的变量就可以赋给类型为 B 的变量了,这边在扯一句废话,子类型的字段肯定是比父类型多的,它不但有父类型上的所有字段,还有自己的字段。

理清两个类型运算的关系

接下来我们来玩下运算

type A = {name: string} // 这里是A类型
type B = {age: number} // 这里是B类型
type C = A | B // C可以是A类型(只有name)也可以是B类型(只有age),甚至name和age都包含也是可以的
type D = A & B // D包含了所有的A,B所有的字段,必须name和age都包含

const a: A = {
    name: '' // 不多做解释,就是写个a变量而已
}
const b: B = {
    age: 0 // 不多做解释,就是写个b变量而已
}
const c1: C = {
    name: '', 
} // 就只有name,ok
const c2: C = {
    age: 0 
} // 就只有age, ok
const c3: C = {
    name: '',
    age: 0
} // name和age都有,也ok
const d: D = {
    name : '',
    age: 0
} // d少一个name或者age就会报错

/**
 * 不会报错!a赋值给c4完全没问题,因为c4要的东西,a肯定有
 * 废话文学:a = c4肯定不行
 * 假设c4,我只有个age字段,但你a就是需要name字段,所以会报错,所以只能c4 = a
 * A(或者B) 有 A|B 上所有属性
 */
const c4: C = a 
/**
 * 不会报错!b赋值给c5完全没问题,因为c5要的东西,b肯定有
 * 废话文学:b = c5肯定不行
 * 假设c5,我只有个name字段,但你b就是需要age字段,所以会报错,所以只能c5 = b
 * A(或者B) 有 A|B 上所有属性
 */
const c5: C = b 
/**
 * 不会报错!d赋值给aTest完全没问题,因为aTest要的东西,d肯定有
 * 废话文学:d = a肯定不行
 * 因为d要所有的字段都要有,你就个a,只有name肯定不行
 * A & B 有 A(或者B)所有的属性
 */
const aTest: A = d 
/**
 * 不会报错!d赋值给bTest完全没问题,因为bTest要的东西,d肯定有
 * 废话文学:d = b肯定不行
 * 因为d要所有的字段都要有,你就个b,只有age肯定不行
 * A & B 有 A(或者B)所有的属性
 */
const bTest: B = d
/**
 * 课后题你们猜可不可以,会不会报错
 */
const cTest: C = d

所以结论如下

  • A(或者B) 有 A|B 上所有属性
  • A & B 有 A(或者B)所有的属性
  • A | B -> A or B -> A & B(父 -> 子 -> 孙)

协变和逆变

初步结论

协变和逆变的概念真的就是超级难理解的,我看了很久,希望能简单说明,让XDM学会,其实这两个词仅仅是很简单的定义,描述两个过程:

  • 协变(co-variant):类型推导到其子类型的过程,类型收敛
  • 逆变(contra-variant):类型推导到其超类型的过程,类型发散

是不是如同在讲天书,先讲结论

  • 在 TypeScript 中,对象、类、数组和函数的返回值类型都是协变关系,而函数的参数类型是逆变关系

代码吸猫-看题

如果协变和逆变之前所写的内容XDM都能理解看懂的话,很容易就能发现,一般类型的子类型非常容易判断,但函数的子类型就很有难度了,来蹭一波掘金活动,用代码来吸猫,先来看题

Cat => Cat 代表一个函数,它的参数是猫,返回值也是猫,请问以下哪个选项,是函数的子类型即能保证ts的类型安全?(子类型可以赋值给父类型,就像前面的狗狗变量狗类型可以赋值给鸭子变量鸭子类型,没有报错,所以类型是安全的)
A - Garfield => Garfield // 加菲猫 => 加菲猫
B - Animal => Animal // 动物 => 动物
C - Garfield => Animal // 加菲猫 => 动物
D - Animal => Garfield // 动物 => 加菲猫

题目讲解

在我还很菜的时候,没有理解怎么样才是类型安全的,第一反应,函数的子类型吗,那我直接把参数和返回值都取子类型,那选A。竟然是错的。那就继续猜,都子类型不对啊,难道全取父类型,选B,这个也是错的。我太难了(崩溃中),其实这里就是对协变和逆变的理解,也是难点

  • 讲解函数参数类型

函数的参数类型,要保证安全,其实类型应该是取Animal!为什么是这样,我们可以先带入Garfield类型来理解下,如果我们的传入的参数是加菲猫类型,相必我们可能会调用加菲猫一些特殊的属性和方法,而这些属性和方法,在Cat类型不一定有,因为加菲猫是子类型,他有的字段更多啊(不能保证安全)。那试想,如果我们传入的参数,类型是Animal的话,那可安全的很,因为我们Animal上的属性和方法随便调,Cat类型都有,这就是逆变,类型发散,猫类型推导到动物类型,这是类型推导到其超类型的过程

  • 讲解函数返回值类型

函数的返回值类型,要保证安全,这里又和前面是倒过来的,正确的类型应该是取Garfield!这里用同样的方法,我们先带入Animal看下,因为我们之前函数的返回类型是Cat,那我们有可能就会调用Cat的一些属性和方法,那我们用Animal会怎么样?是不是有可能正好没有Cat的这些属性和方法,但我们用了加菲猫类型,就不一样了,因为猫上面的属性和方法,加菲猫类型肯定有!这就是协变,类型收敛,猫类型推导到加菲猫类型,这是类型推导到其子类型的过程

  • 公布答案 所以最终的答案就是D, Animal => Garfield就是Cat => Cat的子类型,绝对安全

参数的传入者:只能保证传入 Cat 参数,所以当我们定义参数为 Animal 时,只能使用 Animal 上的属性和方法,而 Cat 肯定有,就能保证类型的正确。

使用返回值者:保证只使用 Cat 方法,所以当我们定义返回值为 Garfield,使用者只使用 Cat 上的属性和方法,而 Garfield 肯定有,就能保证类型的正确。 所以,Animal => Garfield 是 Cat => Cat的子类型 记住参数是逆变的,返回值是协变的,至于你为什么这么少看到协变和逆变的概念,只因为 TypeScript 只有一处逆变,就是参数

终于等到infer讲解

在讲解infer之前,允许我最后在说个很二的实现例子,请看代码和注释

/**
 * 这个GetArrElementType就是为了获取数组中元素是什么类型的,暂时只做了number数组和string数组的判断
 * 如果不是这2种类型的数组,就把本身泛型传进去的类型返回
 * 这种实现就很不灵活,如何可以让他自己灵活的推导泛型的类型呢
*/
type GetArrElementType<T> = T extends number[] ? number : T extends string[] ? string : T 

type NumberArrElementType = GetArrElementType<number[]> // number
type StringArrElementType = GetArrElementType<string[]> // string
type BooleanArrElementType = GetArrElementType<boolean[]> // boolean[]

于是infer就登场了,infer的本质就是推导泛型参数,ts官方文档(文末参考已贴链接)从2.8支持,并且有使用条件

Within the extends clause of a conditional type, it is now possible to have infer declarations that introduce a type variable to be inferred. Such inferred type variables may be referenced in the true branch of the conditional type. It is possible to have multiple infer locations for the same type variable.

简单的说就是,infer 只能在 extends 的右边使用,infer XXX 的 XXX 也只能在条件类型为 True 的一边使用,所以上面的这个二逼例子就可以改成

type GetArrElementType<T> = T extends Array<infer P> ? P : T // 注意Array<infer P>不能写成infer P[],别问我怎么知道的,试了以后查了半天发现不支持这么写

type NumberArrElementType = GetArrElementType<number[]> // number
type StringArrElementType = GetArrElementType<string[]> // string
type BooleanArrElementType = GetArrElementType<boolean[]> // boolean

接下来我们在来看个题

type TestType<T> = T extends (a: infer P,b: infer P) => void ? P : never;
// 提问AnswerType是什么类型,是联合类型呢还是交叉类型呢
type AnswerType = TestType<(a: {name: string}, b: {age: number})=> void>

我们慢慢分析下,首先看TestType中的T extends (a: infer P,b: infer P) => void,把extends的左右两侧分开来看,T肯定是子类型,而(a: infer P,b: infer P) => void就是T的父类型,它是个函数,参数有2项,在看AnswerType,在TestType传入了泛型,所以可以得出(a: {name: string}, b: {age: number})=> void(a: infer P,b: infer P) => void的子类型,根据前面的结论,infer占的位置是逆变位,所以P逆变(推导到父类型)到(a: {name: string}, b: {age: number}),也可以说(a: {name: string}, b: {age: number})协变(推导到子类型)到P,所以得出P就是交叉类型(是不是很难反应过来,子类型就是字段会比父类型多,交叉就是什么都有了,我是这么记的),最终答案就是{name: string} & {age: number},为了方便大家理解(怕大家纯文字看的头大,所以我们在梳理下)

// 以下的逻辑是最开始讲解
Cat => Cat -> 求子类型 -> ? => ? -> 第一个问号参数是逆变,第二个问号返回值是协变 -> Animal => Garfield
// 再次分析
(a: infer P,b: infer P) => void -> 子类型是(a: {name: string}, b: {age: number})=> void
所以(a: infer P,b: infer P)逆变到(a: {name: string}, b: {age: number})
为了求P,可以反推(a: {name: string}, b: {age: number})协变到P即推子类型
所以子类型是他们的交叉类型,答案是{name: string} & {age: number}

最后在贴出看到的大佬总结的推导公式,有四种情况:

  • P 只在一个位置占位:直接推出类型
  • P 都在协变位置占位:推出占位类型的联合
  • P 都在逆变位置占位:推出占位类型的交叉(目前只有参数是逆变)
  • P 既在顺变位置又在逆变位置:只有占位类型相同才能使 extends 为 true,且推出这个占位类型

我们的例子属于第三种情况,P 都在逆变位置占位,最终就推出两个类型的交叉,当 P 只在一个位置占位时,它推出来的类型就是一一对应的,比如 Parameter 和 ReturnType,这就是我们下一篇讲要讲的

参考