TypeScript 类型系统 协变与逆变的理解 函数类型的问题

2,476 阅读5分钟

前言

TypeScript 中有许多关于类型系统的概念,如果只知其一不知其二的话,那么就有可能被报错打的满地找牙。

这篇文章写的是关于类型系统中的协变与逆变的概念,了解协变和逆变是如何发生及运作的。

类型关系

理解一个新东西所需要的是一个良好且完善的上下文,所以需要先了解最基础的类型关系

在 TypeScript 中的类型只与值有关,即鸭子类型

父子类型

普通类型

假设有如下接口类型:

interface Animal {
  age: number
}

interface Dog extends Animal {
  bark(): void
}

Dog 继承于父类 Animal ,也就是说 DogAnimal 的子类型,我们可以称之为 Dog ≼ Animal

可以看到,子类相较于父类更具体,属性或行为更多。

同时可以看到因为鸭子类型而出现的一个现象(同时也被称为类型兼容性)。

let animal: Animal
let dog: Dog

animal = dog 
// √ 因为 animal 只需要 age 一个属性,而 dog 中含有 age 和 bark() 两个属性,赋值给 animal 完全没问题。
dog = animal 
// × Error: Property 'bark' is missing in type 'Animal' but required in type 'Dog'.

因为 animal 中缺少 dog 需要的 bark() 属性,因此赋值失败并报错。

总结

  1. 子类型比父类型描述的更具体,父类型相对于子类型是更广泛的,子类型相对于父类型是更精确的。
  2. 判断是否是子类型可以这么理解,子类型是一定可以赋值给父类型的。

联合类型

假设有如下类型:

type Parent = 'a' | 'b' | 'c'
type Son = 'a' | 'b'

let parent: Parent
let son: Son

son = parent 
// × Error: Type 'Parent' is not assignable to type 'Son'.
// Type '"c"' is not assignable to type 'Son'.
parent = son 
// √ 

Parent 可能是 'c' 但是 Son 类型并不包括 'c' 这个字面量类型,因此赋值失败并报错。

可以从这个案例看出 Son ≼ Parent 。因为 Parent广泛Son具体

可以这么理解:联合类型相当于集合,Son就是Prent子集。不过在这还是说SonParent的子类型。

协变和逆变

维基百科定义

依旧假设我们有依旧有上面的AnimalDog两个父子类型。

协变(Covariance)

协变的情况其实很简单就是上面说的类型兼容性,因此协变其实无处不在。

let animals: Animal[]
let dogs: Dog[]

animals = dogs

完全没问题,原因之前说了,就不再重复了。这就是协变现象。

逆变(Contravariance)

逆变现象只会在函数类型中的函数参数上出现。 假设有如下代码:

let haveAnimal = (animal: Animal) => {
  animal.age
}
let haveDog = (dog: Dog) => {
  dog.age
  dog.bark()
}

haveAnimal = haveDog 
// Error: Type '(dog: Dog) => void' is not assignable to type '(animal: Animal) => void'.
//   Types of parameters 'dog' and 'animal' are incompatible.
//     Property 'bark' is missing in type 'Animal' but required in type 'Dog'.

haveAnimal({
  age: 123,
})

传入的 Animal 没有 haveAnimal 需要的 bark() 属性,因此在检查时报错了。

注意:TS之前的函数参数是双向协变的,也就是说既是协变又是逆变的、且这段代码并不会报错。但是在如今的版本 (Version 4.1.2)tsconfig.json 中有 strictFunctionTypes 这个配置来修复这个问题。(默认开启)

那么这时候修改代码为:

- haveAnimal = haveDog
+ haveDog = haveAnimal

发现完全没问题!

因为我们在运行 haveDog(实际运行还是 haveAnimal ) 的时候会传入 Animal 的子类Dog,之前说过子类型的属性比父类型更多,因此haveDog需要访问的属性在 Animal 中都有,那么在 Dog 类型中肯定只会更多。

可以发现对于两个父子类型作为函数参数构建两个函数类型,这两个的函数类型的父子关系逆转了,这就是逆变

同时,在返回值类型上和平常没什么区别是协变的。(感兴趣的可以自己试试)

总结:在函数类型中,参数类型是逆变的,返回值类型是协变的。

练习

有如下代码:

type NoOrStr = number | string
type No = number
let noOrStr = (a: NoOrStr) => {}
let no = (a: No) => {}

noOrStr = no 会报错还是 no = noOrStr 会报错。

可以思考一下,谁是父类谁是子类然后在进行逆变转换。

练习答案

noOrStr = no 会报错。

解析

  • 在练习中,可以看做 No ≼ NoOrStr ,进行逆变转换: noOrStr ≼ no 。子类可以赋值给父类,父类不能赋值给子类,因此 no = noOrStr 是对的没问题,noOrStr = no 就会报错。
  • 又或者换种角度,noOrStr 能处理 number | string 类型的值,而 no 只能处理 number 类型的值。
    • 因此当 no = noOrStr 时没问题,因为调用 no() 时只会传入 number 类型的值,而 noOrStr 可以处理包括 number 两种类型的值。
    • 而当 noOrStr = no 时就出问题了,因为调用 noOrStr() 时会传入 number | string 类型,而 no 只能处理 number 类型的值,当调用 noOrStr() 传入 string 类型的值时, no 处理不了,因此报错。

结语

这篇文章的感悟是我学习 TS 途中遇到的一个问题查询资料并理解后所诞生的。如果有错误或疏漏欢迎指出:)

同时扩展下:infer 在协变和逆变的情况下是有不同现象的,具体可查看文档(我忘记在哪里了)。