Search K
Appearance
Appearance
深入学习 TypeScript 类型系统的话,逆变、协变、双向协变、不变是绕不过去的概念。
这些概念看起来挺高大上的,其实并不复杂,这节我们就来学习下它们吧。
TypeScript 给 JavaScript 添加了一套静态类型系统,是为了保证类型安全的,也就是保证变量只能赋同类型的值,对象只能访问它有的属性、方法。
比如 number 类型的值不能赋值给 boolean 类型的变量,Date 类型的对象就不能调用 exec 方法。
这是类型检查做的事情,遇到类型安全问题会在编译时报错。
但是这种类型安全的限制也不能太死板,有的时候需要一些变通,比如子类型是可以赋值给父类型的变量的,可以完全当成父类型来使用,也就是 " 型变(variant)"(类型改变)。
这种 "型变" 分为两种,一种是子类型可以赋值给父类型,叫做协变(covariant),一种是父类型可以赋值给子类型,叫做逆变(contravariant)。
先来看下协变:
其中协变是很好理解的,比如我们有两个 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 的参数 Guang 是 printName 参数 Person 的子类型。
那么问题来了,printName 能赋值给 printHobbies 么?printHobbies 能赋值给 printName 么?
测试一下发现是这样的:

printName 的参数 Person 不是 printHobbies 的参数 Guang 的父类型么,为啥能赋值给子类型?
因为这个函数调用的时候是按照 Guang 来约束的类型,但实际上函数只用到了父类型 Person 的属性和方法,当然不会有问题,依然是类型安全的。
这就是逆变,函数的参数有逆变的性质(而返回值是协变的,也就是子类型可以赋值给父类型)。
那反过来呢,如果 printHoobies 赋值给 printName 会发生什么?
因为函数声明的时候是按照 Person 来约束类型,但是调用的时候是按照 Guang 的类型来访问的属性和方法,那自然类型不安全了,所以就会报错。
但是在 ts2.x 之前支持这种赋值,也就是父类型可以赋值给子类型,子类型可以赋值给父类型,既逆变又协变,叫做 "双向协变"。
但是这明显是有问题的,不能保证类型安全,所以之后 ts 加了一个编译选项 strictFunctionTypes,设置为 true 就只支持函数参数的逆变,设置为 false 则是双向协变。

我们把 strictFunctionTypes 关掉之后,就会发现两种赋值都可以了:

这样就支持函数参数的双向协变,类型检查不会报错,但不能严格保证类型安全。
开启之后,函数参数就只支持逆变,子类型赋值给父类型就会报错:

再举个逆变的例子,大家觉得下面这样的 ts 代码会报错么:
type Func = (a: string) => void;
const func: Func = (a: "hello") => undefined;答案是参数的位置会,返回值的位置不会:

参数的位置是逆变的,也就是被赋值的函数参数要是赋值的函数参数的子类型,而 string 不是 'hello' 的子类型,所以报错了。
返回值的位置是协变的,也就是赋值的函数的返回值是被赋值的函数的返回值的子类型,这里 undefined 是 void 的子类型,所以不报错。
在类型编程中这种逆变性质有什么用呢?
还记得之前联合转交叉的实现么?
type UnionToIntersection<U> = (U extends U ? (x: U) => unknown : never) extends (x: infer R) => unknown ? R : never;类型参数 U 是要转换的联合类型。
U extends U 是为了触发联合类型的 distributive 的性质,让每个类型单独传入做计算,最后合并。
利用 U 做为参数构造个函数,通过模式匹配取参数的类型。
结果就是交叉类型:

我们通过构造了多个函数类型,然后模式提取参数类型的方式,来实现了联合转交叉,这里就是因为函数参数是逆变的,会返回联合类型的几个类型的子类型,也就是更具体的交叉类型。
再就是之前提取返回值类型的时候,当时说参数这里只能用 any[] 而不能用 unknown[]
type GetReturnType<Func extends Function> = Func extends (...args: any[]) => infer ReturnType ? ReturnType : never;这就是因为函数参数是逆变的,如果是 unknown[],那当 Func 是这个函数的子类型,它的参数得是 unknown 的父类型,这显然是不可能的,所以这里只能用 any。


当然,如果不开启 strictFunctionTypes 的话,参数是双向协变,那也可以正常推导。

逆变和协变都是型变,是针对父子类型而言的,非父子类型自然就不会型变,也就是不变:
非父子类型之间不会发生型变,只要类型不一样就会报错:

那类型之间的父子关系是怎么确定的呢,好像也没有看到 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 添加了静态类型系统来保证了类型安全,大多数情况下不同类型之间是不能赋值的,但是为了增加类型系统灵活性,设计了父子类型的概念。父子类型之间自然应该能赋值,也就是会发生型变(variant)。
型变分为逆变(contravariant)和协变(covariant)。协变很容易理解,就是子类型赋值给父类型。逆变主要是函数赋值的时候函数参数的性质,参数的父类型可以赋值给子类型,这是因为按照子类型来声明的参数,访问父类型的属性和方法自然没问题,依然是类型安全的。但反过来就不一定了。
不过 ts 2.x 之前反过来依然是可以赋值的,也就是既逆变又协变,叫做双向协变。
为了更严格的保证类型安全,ts 添加了 strictFunctionTypes 的编译选项,开启以后函数参数就只支持逆变,否则支持双向协变。
型变都是针对父子类型来说的,非父子类型自然就不会型变也就是不变(invariant)。
ts 中父子类型的判定是按照结构来看的,更具体的那个是子类型。
理解了如何判断父子类型(结构类型系统),父子类型的型变(逆变、协变、双向协变),很多类型兼容问题就能得到解释了。