TypeScript 中的协变和逆变是什么?
协变是“子类型关系方向不变”,比如 Dog 能赋给 Animal,() => Dog 也能赋给 () => Animal;逆变是方向反过来,出现在函数参数位置。TypeScript 的数组按协变处理,这并不安全;strictFunctionTypes 让函数类型参数按逆变检查,但方法简写仍是双变。TypeScript 4.7 起可以用 in/out 显式标注类型参数的方差。
简答
假设
Dog是Animal的子类型,一只狗可以当成动物用。那么Dog[]能不能当Animal[]、“处理狗的函数”能不能当“处理动物的函数”?协变和逆变讲的就是:类型套进一个泛型以后,子类型关系是保持原方向、反过来,还是两边都不行。只读出、不写入的位置是协变,比如返回值:
() => Dog可以赋给() => Animal。只写入的位置是逆变,比如函数参数:(a: Animal) => void可以赋给(d: Dog) => void,方向反过来了。既读又写的位置应该是不变,两边都不能互相赋值。这套规则的用处是让赋值在运行时不出错。可 TypeScript 为了兼容常见写法,有两处放宽:数组按协变处理,哪怕它可以写入;方法简写的参数按双变处理,两个方向都允许。
strictFunctionTypes只对函数类型语法生效,方法简写不受影响。写复杂泛型时,可以用 TypeScript 4.7 加的
in、out显式标注方差,既方便读者,也能让检查器在写错时报错。
协变和逆变分别长什么样
TypeScript 4.7 的发布说明用 Getter 和 Setter 解释这两个词。判断 Getter<Dog> 能否赋给 Getter<Animal>,要看 Dog 能否赋给 Animal,方向一致,所以 Getter 对 T 是协变的。判断 Setter<Dog> 能否赋给 Setter<Animal>,却要看 Animal 能否赋给 Dog,方向翻转了,所以 Setter 对 T 是逆变的。
type Animal = { name: string };
type Dog = Animal & { bark(): void };
type Getter<T> = () => T;
type Setter<T> = (value: T) => void;
declare const getDog: Getter<Dog>;
const getAnimal: Getter<Animal> = getDog; // 协变:能产出狗,当然能产出动物
declare const setAnimal: Setter<Animal>;
const setDog: Setter<Dog> = setAnimal; // 逆变:能处理任意动物,当然能处理狗
// @ts-expect-error 只会处理狗,不能承诺处理任意动物
const setAny: Setter<Animal> = setDog;
为什么参数要反过来?从调用方想:拿到 Setter<Animal> 的人会传一只猫进去。如果它背后是只会处理狗的函数,就会去调用猫的 bark()。函数重载和函数赋值的更多规则见《函数重载、联合参数与泛型怎样选择?函数类型何时可赋值?》。
数组为什么是协变的,又为什么不安全
数组既能读又能写,按道理应该是不变的。可 TypeScript 允许 Dog[] 赋给 Animal[],下面这段代码能通过类型检查,运行时却会出错。把类型擦掉以后在 node 里运行:
const dogs = [{ name: '旺财', bark: () => '汪' }];
const animals = dogs; // TypeScript 允许 Dog[] 赋给 Animal[]
animals.push({ name: '咪咪' });
for (const dog of dogs) {
try {
console.log(dog.bark());
} catch (error) {
console.log(error.constructor.name, dog.name); // TypeError 咪咪
}
}
第一次输出 汪,第二次输出 TypeError 咪咪。animals 和 dogs 是同一个数组,通过 animals 塞进去一只不会叫的猫,dogs 里就混进了非狗元素。TypeScript 选择接受这个风险,是因为把数组当作只读参数传来传去的写法太常见了,如果按不变处理,大量正常代码都会报错。
改法是在只读取的地方声明成 readonly Animal[]。只读数组没有 push,协变就是安全的,函数签名也更清楚地表达了“我不会改你的数组”。
strictFunctionTypes 管什么,不管什么
官方 Type Compatibility 文档写道,比较函数参数时,只要源参数能赋给目标参数、或者反过来也行,赋值就成功,这叫参数双变。它不安全,但能兼容很多常见的 JavaScript 写法。
strictFunctionTypes(属于 strict 系列)开启后,函数参数改成按逆变检查。可 TSConfig 文档也说明了,它只作用于函数语法写的类型,不作用于方法语法写的类型。原因是开发这个特性时发现了大量本质上不安全的类层次结构,包括 DOM 里的一些。
type MethodStyle<T> = { handle(value: T): void };
type PropertyStyle<T> = { handle: (value: T) => void };
const m: MethodStyle<Animal> = { handle: (d: Dog) => d.bark() }; // 通过,但不安全
// @ts-expect-error 属性写法按逆变检查
const p: PropertyStyle<Animal> = { handle: (d: Dog) => d.bark() };
上面两段都用 TypeScript 7.0.2 在 --strict 下验证过。把 strictFunctionTypes 关掉以后,前一节的 setAny 和这里的 PropertyStyle 两行都能通过,它们上面的 @ts-expect-error 会变成“未使用”的错误。所以自己定义回调类型时,写成属性形式才能拿到严格检查。
in 和 out 标注有什么用
TypeScript 4.7 允许在类型参数前写 out(协变)、in(逆变),两个都写就是不变:
interface State<in out T> {
get: () => T;
set: (value: T) => void;
}
declare const dogState: State<Dog>;
// @ts-expect-error 不变:State<Dog> 和 State<Animal> 互不兼容
const animalState: State<Animal> = dogState;
发布说明给了两个理由。一是可读性,复杂类型一眼看不出 T 是被读、被写还是都有。二是准确和速度:TypeScript 本来就会推算方差作为优化,但遇到循环引用时可能推不准,显式标注可以让检查器跳过深层的结构比较。
标注写错了会报错。比如把上面的 State 只写成 out T,set 用到了 T 作为输入,tsc 会提示 Type 'State<sub-T>' is not assignable to type 'State<super-T>' as implied by variance annotation。不过要注意,标注不会改变结构类型系统的本质,不需要为了“看起来专业”给每个泛型都加上。
常见追问
为什么 TypeScript 不直接让数组不变?
因为
function printAll(list: Animal[])接收Dog[]这种写法太常见了,大多数函数只读不写。TypeScript 选择了实用性,把安全交给readonly数组来表达。
返回值是 void 的函数类型为什么能接收有返回值的函数?
这是另一条兼容规则,和方差无关。
void表示调用方不使用返回值,所以forEach的回调写成x => arr.push(x)也能通过。
in、out 和 C# 的 in、out 是一回事吗?
含义相近,都是标注输入、输出位置。但 TypeScript 是结构类型系统,就算不写标注,检查器也会按结构比较或自行推算方差,标注主要用于说明意图和帮助检查器。