快速理解 TypeScript 的逆变和协变
深入学习 TypeScript 类型系统的话,逆变、协变、双向协变、不变是绕不过去的概念。
这些概念看起来挺高大上的,其实并不复杂,这篇文章我们就来学习下它们吧。
类型安全和型变
TypeScript 给 JavaScript 添加了一套静态类型系统,是为了保证类型安全的,也就是保证变量只能赋同类型的值,对象只能访问它有的属性、方法。
比如 number 类型的值不能赋值给 boolean 类型的变量,Date 类型的对象就不能调用 exec 方法。
这是类型检查做的事情,遇到类型安全问题会在编译时报错。
但是这种类型安全的限制也不能太死板,有的时候需要一些变通,比如子类型是可以赋值给父类型的变量的,可以完全当成父类型来使用,也就是“型变”(类型改变)。
这种“型变”分为两种,一种是子类型可以赋值给父类型,叫做协变,一种是父类型可以赋值给子类型,叫做逆变。
先来看下协变:
协变
其中协变是很好理解的,比如我们有两个 interface:
interface Person {
name: string;
age: number;
}
interface Guang {
name: string;
age: number;
hobbies: string[]
}
这里 Guang 是 Person 的子类型,更具体,那么 Guang 类型的变量就可以赋值给 Person 类型:
这并不会报错,虽然这俩类型不一样,但是依然是类型安全的。
这种子类型可以赋值给父类型的情况就叫做协变。
为什么要支持协变很容易理解:类型系统支持了父子类型,那如果子类型还不能赋值给父类型,还叫父子类型么?
所以型变是实现类型父子关系必须的,它在保证类型安全的基础上,增加了类型系统的灵活性。
逆变相对难理解一些:
逆变
我们有这样两个函数:
let printHobbies: (guang: Guang) => void;
printHobbies = (guang) => {
console.log(guang.hobbies);
}
let printName: (person: Person) => void;
printName = (person) => {
console.log(person.name);
}
printHobbies 的参数是 printName 参数的子类型。
那么问题来了,printName 能赋值给 printHobbies 么?printHobbies 能赋值给 printName 么?
测试一下发现是这样的:
printName 的参数Person不是 printHobbies 的参数Guang的父类型么,为啥能赋值给子类型?
因为这个函数调用的时候是按照 Guang 来约束的类型,但实际上函数只用到了父类型 Person 的属性和方法,当然不会有问题,依然是类型安全的。
这就是逆变,函数的参数有逆变的性质(而返回值是协变的,也就是子类型可以赋值给父类型)。
那反过来呢,如果 printHoobies 赋值给 printName 会发生什么?
因为函数声明的时候是按照 Person 来约束类型,但是调用的时候是按照 Guang 的类型来访问的属性和方法,那自然类型不安全了,所以就会报错。
但是在 ts2.x 之前支持这种赋值,也就是父类型可以赋值给子类型,子类型可以赋值给父类型,既逆变又协变,叫做“双向协变”。
但是这明显是有问题的,不能保证类型安全,所以之后 ts 加了一个编译选项 strictFunctionTypes,设置为 true 就只支持函数参数的逆变,设置为 false 则是双向协变。
我们把 strictFunctionTypes 关掉之后,就会发现两种赋值都可以了:
这样就支持函数参数的双向协变,类型检查不会报错,但不能严格保证类型安全。
开启之后,函数参数就只支持逆变,子类型赋值给父类型就会报错:
在类型编程中这种逆变性质有什么用呢?
还记得之前联合转交叉的实现么?
type UnionToIntersection<U> =
(U extends U ? (x: U) => unknown : never) extends (x: infer R) => unknown
? R
: never
类型参数 U 是要转换的联合类型。
U extends U 是为了触发联合类型的 distributive 的性质,让每个类型单独传入做计算,最后合并。
利用 U 做为参数构造个函数,通过模式匹配取参数的类型。
结果就是交叉类型:
我们通过构造了多个函数类型,然后模式提取参数类型的方式,来实现了联合转交叉,这里就是因为函数参数是逆变的,会返回联合类型的几个类型的子类型,也就是更具体的交叉类型。
逆变和协变都是型变,是针对父子类型而言的,非父子类型自然就不会型变,也就是不变:
不变
非父子类型之间不会发生型变,只要类型不一样就会报错:
那类型之间的父子关系是怎么确定的呢,好像也没有看到 extends 的继承?
类型父子关系的判断
像 java 里面的类型都是通过 extends 继承的,如果 A extends B,那 A 就是 B 的子类型。这种叫做名义类型系统(nominal type)。
而 ts 里不看这个,只要结构上是一致的,那么就可以确定父子关系,这种叫做结构类型系统(structual type)。
还是拿上面那个例子来说:
Guang 和 Person 有 extends 的关系么?
没有呀。
那是怎么确定父子关系的?
通过结构,更具体的那个是子类型。这里的 Guang 有 Person 的所有属性,并且还多了一些属性,所以 Guang 是 Person 的子类型。
注意,这里用的是更具体,而不是更多。
判断联合类型父子关系的时候, 'a' | 'b' 和 'a' | 'b' | 'c' 哪个更具体?
'a' | 'b' 更具体,所以 'a' | 'b' 是 'a' | 'b' | 'c' 的子类型。
测试下:
总结
ts 通过给 js 添加了静态类型系统来保证了类型安全,大多数情况下不同类型之间是不能赋值的,但是为了增加类型系统灵活性,设计了父子类型的概念。父子类型之间自然应该能赋值,也就是会发生型变。
型变分为逆变和协变。协变很容易理解,就是子类型赋值给父类型。逆变主要是函数赋值的时候函数参数的性质,参数的父类型可以赋值给子类型,这是因为按照子类型来声明的参数,访问父类型的属性和方法自然没问题,依然是类型安全的。但反过来就不一定了。
不过 ts 2.x 之前反过来依然是可以赋值的,也就是既逆变又协变,叫做双向协变。
为了更严格的保证类型安全,ts 添加了 strictFunctionTypes 的编译选项,开启以后函数参数就只支持逆变,否则支持双向协变。
型变都是针对父子类型来说的,非父子类型自然就不会型变也就是不变。
ts 中父子类型的判定是按照结构来看的,更具体的那个是子类型。
理解了如何判断父子类型(结构类型系统),父子类型的型变(逆变、协变、双向协变),很多类型兼容问题就能得到解释了。
(更多内容可以看我的小册《TypeScript 类型体操通关秘籍》)