前言
TypeScript 中有许多关于类型系统的概念,如果只知其一不知其二的话,那么就有可能被报错打的满地找牙。
这篇文章写的是关于类型系统中的协变与逆变的概念,了解协变和逆变是如何发生及运作的。
类型关系
理解一个新东西所需要的是一个良好且完善的上下文,所以需要先了解最基础的类型关系。
在 TypeScript 中的类型只与值有关,即鸭子类型。
父子类型
普通类型
假设有如下接口类型:
interface Animal {
age: number
}
interface Dog extends Animal {
bark(): void
}
Dog 继承于父类 Animal ,也就是说 Dog 是 Animal 的子类型,我们可以称之为 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() 属性,因此赋值失败并报错。
总结:
- 子类型比父类型描述的更具体,父类型相对于子类型是更广泛的,子类型相对于父类型是更精确的。
- 判断是否是子类型可以这么理解,子类型是一定可以赋值给父类型的。
联合类型
假设有如下类型:
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子集。不过在这还是说Son是Parent的子类型。
协变和逆变
依旧假设我们有依旧有上面的Animal和Dog两个父子类型。
协变(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 在协变和逆变的情况下是有不同现象的,具体可查看文档(我忘记在哪里了)。