TypeScript 中协变, 逆变, 不变以及不安全的双变
类型兼容性与可赋值性
TypeScript 采用 结构化的类型系统 (即, 鸭子类型), 只要两个类型的结构是匹配的, 两个类型就可以兼容
interface Duck {
name: string;
quack(): void;
swim(): void;
}
interface Person {
name: string;
quack(): void; // 学鸭子叫
swim(): void;
}
const person: Person = {
name: "someone",
quack() { console.log("嘎嘎嘎 (生硬模仿"); },
swim() { console.log("蛙泳中..."); }
};
// 允许赋值
const duck: Duck = person;与之对比的是 C# 等采用 名义类型系统 的语言, 类型之间必须有显式继承关系才能兼容
interface IDuck {
string Name { get; }
void Quack();
void Swim();
}
interface IPerson {
string Name { get; }
void Quack();
void Swim();
}
class Person : IPerson {
public string Name { get; set; }
public void Quack() { }
public void Swim() { }
}
Person person = new Person { Name = "someone" };
// 编译错误: Person 没有实现 IDuck
IDuck duck = person;这种 "只看结构不看语义" 的特性让 TypeScript 非常灵活, 但也让我们习惯了各种隐式赋值, 从而忽略了型变带来的潜在问题.
泛型打破了类型兼容的直觉
我们定义一些下文要用的基础类型
class Animal {
name: string = "";
}
class Dog extends Animal {
sayName() {
console.log("wangwang");
}
}
type Callback<T> = (input: T) => void
type Factory<T> = () => T
type Handler<T> = {
process(input: T): void;
}
type Id<T> = (input:T) => T理所当然的, 我们认为
const dog: Dog = new Dog();
// 可以赋值, 因为 Dog 是 Animal 的子类型, 有 Animal 的所有属性
const animal: Animal = dog; 那是不是 Callback<Dog> 也能赋给 Callback<Animal> 呢?
declare let callbackDog: Callback<Dog>
declare let callbackAnimal: Callback<Animal>
// 类型检查错误
// Type 'Callback<Dog>' is not assignable to type 'Callback<Animal>'.
callbackAnimal = callbackDog要理解这个现象就要理解 型变
什么是型变 (Variance)
型变描述了泛型 Callback<T> 的父子类型关系如何随 T 变化.
- 我们知道
Dog是Animal的子类型. - 但是对于
Callback<T>来说,Callback<Dog>是Callback<Animal>的父类型.
这种变化来源于 泛型 Callback<T> 对类型 T 的父子关系造成了类型变化 (型变). 型变分为四种
| 型变类型 | 子类型方向 | 例子 |
|---|---|---|
| 协变 | 方向一致: Dog -> Animal则 F<Dog> -> F<Animal> | Factory<Dog> 是 Factory<Animal> 的子类型 |
| 逆变 | 方向相反: Dog -> Animal,则 F<Animal> -> F<Dog> | Callback<Animal> 是 Callback<Dog> 的子类型 |
| 双变 | 两个方向都允许 | Handler<Animal> 和 Handler<Dog>(后面解释为什么 |
| 不变 | 两个方向都不允许 | Id<Animal> 和 Id<Dog>(后面解释为什么 |
型变的方向取决于 T 在泛型中的使用方式
或者更精确的说: 型变取决于类型参数在可观察位置 (observable positions) 中的正负极性 (variance polarity)
逆变 (Contravariance)
当类型变量仅用在函数参数位置时, 逆变发生
允许用更通用的类型替代, 因为能处理父类自然能处理子类传入.
const animalSayName: Callback<Animal> = (animal: Animal) => {
console.log(`name: ${animal.name}`)
}
// 允许入参变得更加通用, 即使用户输入了 Dog 我们也能正确处理
const dogSayName:Callback<Dog> = animalSayName; 当调用者执行 dogSayName(new Dog()) 时, 实际执行的函数只需要 Animal 的属性 name. 既然 Dog 是 Animal 的子类型, 它必然有 name 属性, 所以这是安全的.
反过来就不行了:
const dogSayName: Callback<Dog> = (dog) => {
dog.sayName();
}
// Type 'Callback<Dog>' is not assignable to type 'Callback<Animal>'.
const animalSayName:Callback<Animal> = dogSayName;调用者可能执行 animalSayName(new Animal()), 但实现期望的是 Dog, 会调用 sayName() 方法. 普通 Animal 没有这个方法, 类型检查就会报错.
协变 (Covariance)
当类型变量仅用在函数返回值时, 协变发生
允许用更具体的类型替代, 因为返回子类当父类用没问题.
const createDog: Factory<Dog> = () => {
return new Dog();
}
// 返回 Dog 比 Animal 更具体, 允许
const createAnimal: Factory<Animal> = createDog;调用者期望得到 Animal, 实际得到了 Dog. 由于 Dog 拥有 Animal 的所有属性, 调用者只使用 Animal 的属性时完全没问题.
反过来就不行了
const createAnimal: Factory<Animal> = () => {
return new Animal();
}
// Type 'Factory<Animal>' is not assignable to type 'Factory<Dog>'.
const createDog: Factory<Dog> = createAnimal;调用者期望得到 Dog, 想调用 sayName() 方法, 但实际返回的是普通 Animal 没有这个方法, 类型检查会报错
双变 (Bivariance)
在类型系统理论中, 双变不是一个 "自然存在" 的型变类别. 而是 TypeScript 为了实用性, 故意对 method 语法定义的函数做的宽松处理, 允许类型变量作为参数使用时发生双变 (即双变宽松, method bivariance hack)
// 标准实现
const standardDogHandler: Handler<Dog> = {
process(input: Dog) {
input.sayName();
}
}
const standardAnimalHandler: Handler<Animal> = {
process(input: Animal) {
console.log(input.name)
}
}
// 参数 Dog -> Animal (逆变), 本来就允许
const safeDogHandler: Handler<Dog> = standardAnimalHandler;
// 参数 Animal -> Dog (协变), 本不该允许, 但双变宽松放过了
const unsafeAnimalHandler: Handler<Animal> = standardDogHandler
// 虽然类型检查上放过了协变, 但是调用的时候还是会出错
// [ERR]: input.sayName is not a function
unsafeAnimalHandler.process(new Animal());重申双变宽松的条件
当类型变量作为 method 语法声明的函数的参数时发生双变
函数类型不是通过
method语法声明的 - 无法出发双变宽松type Handler2<T> = { process: (input: T) => void; } declare let dogHandler2: Handler2<Dog> declare let animalHandler2: Handler2<Animal> // Type 'Handler2<Dog>' is not assignable to type 'Handler2<Animal>'. animalHandler2 = dogHandler2;TypeScript 类型检查只管类型不管实现
同时, 双变宽松只对类型检查生效, 不会影响实现
// 类型用 method 语法声明 type Handler<T> = { process(input: T): void; } // 实现用属性赋值 const standardDogHandler: Handler<Dog> = { // 改为非 method 方法实现 process: (input: Dog) => { input.sayName(); } } const standardAnimalHandler: Handler<Animal> = { // 改为非 method 方法实现 process: (input: Animal) => { console.log(input.name) } } // 双边宽松仍然生效 const unsafeAnimalHandler: Handler<Animal> = standardDogHandler// 类型用属性赋值语法 type Handler2<T> = { process: (input: T) => void; } // 实现用 method const standardDogHandler: Handler2<Dog> = { process(input: Dog) { input.sayName(); } } const standardAnimalHandler: Handler2<Animal> = { process(input: Animal) { console.log(input.name) } } // 双变宽松失效 // Type 'Handler2<Dog>' is not assignable to type 'Handler2<Animal>'. const unsafeAnimalHandler: Handler2<Animal> = standardDogHandler双变宽松只对参数位生效, 对返回值仍然只有协变, 没有逆变
type IdHandler<T> = { id(input: T): T; } const idDogHandler:IdHandler<Dog> = { id(input: Dog){ input.sayName(); return input; } } const idAnimalHandler:IdHandler<Animal> = { id: (input: Animal) => input } // 参数位因为双变宽松过了, 返回值为协变也过了 const idAnimalHandler2: IdHandler<Animal> = idDogHandler; // 参数位因为逆变过了, 返回值触发逆变没通过 // Type 'IdHandler<Animal>' is not assignable to type 'IdHandler<Dog>'. const idDogHandler2: IdHandler<Dog> = idAnimalHandler;
不变 (Invariance)
当类型变量同时位于输入和输出时, 且没有触发 TypeScript 的双变宽松, 就会出现 不变 的情况
const idDog = (input: Dog) => input;
const idAnimal = (input: Animal) => input;
// Type '(input: Animal) => Animal' is not assignable to type 'Id<Dog>'.
const idDog2: Id<Dog> = idAnimal;
// Type '(input: Dog) => Dog' is not assignable to type 'Id<Animal>'.
const idAnimal2: Id<Animal> = idDog;- 参数位置要求逆变 (
Animal->Dog) - 返回值位置要求协变 (
Dog->Animal) - 两个要求互相矛盾, 所以只能不变
原本 Dog 是 Animal 子类, 但是 Id 发生了不变, Id<Dog> 和 Id<Animal> 父子关系 (协变与逆变关系) 消失了
重申不变的条件 - 不能触发双变宽松
type IdHandler<T> = {
id(input: T): T;
}
const idDogHandler:IdHandler<Dog> = {
id(input: Dog){
input.sayName();
return input;
}
}
const idAnimalHandler:IdHandler<Animal> = {
id: (input: Animal) => input
}
// 参数位因为双变宽松过了, 返回值为协变也过了
const idAnimalHandler2: IdHandler<Animal> = idDogHandler;属性的型变
为了简化理解, 上面我们之讨论了类型参数作为函数使用的场景, 继续考虑作为属性的场景
- 可变属性会触发双变宽松
- 只读属性只有协变
我们可以这样理解上述规则
对于非只读变量
type Box<T> = { value: T }类似于
type BoxExplicit<T> = { getValue(): T; setValue(v: T): void; }会触发了双变宽松
class Box<T> { value: T constructor(_value: T){ this.value = _value } } const dogBox = new Box<Dog>(new Dog) let animalBox: Box<Animal>; // 双变宽松, 可以赋值 animalBox = dogBox; animalBox.value = new Animal() // 替换一个真 Animal, 同时也替换了 dogBox // dogBox 里面放的实际是 animal, 调用 sayName 会报错 // [ERR]: dogBox.value.sayName is not a function dogBox.value.sayName()对于只读变量
type ReadonlyBox<T> = { readonly value: T }类似于
type ReadonlBoxExplicit<T> = { getValue(): T; }只触发协变
class ReadonlyBox<T> { readonly value: T constructor(_value: T){ this.value = _value } } const dogBox = new ReadonlyBox<Dog>(new Dog) const animalBox = new ReadonlyBox<Animal>(new Animal) // 协变允许赋值 const animalBox2: ReadonlyBox<Animal> = dogBox; // Type 'ReadonlyBox<Animal>' is not assignable to type 'ReadonlyBox<Dog>'. const dogBox2: ReadonlyBox<Dog> = animalBox;
为什么 TypeScript 允许双变宽松
根据 TypeScript FAQ, TypeScript 团队承认双变是不够类型安全的, 但选择了务实妥协, 以规避 TypeScript 升级造成的破坏性变更
数组操作会全面失效
// Array<T> 的定义中包含有类似 push(...items: T[]): number 的签名 // 如果禁止双变宽松, 参数只能逆变, 参数 Dog 比 Animal 更具体, 不能赋值 const dogs: Dog[] = [new Dog()]; const animals: Animal[] = dogs; // 严格模式下报错 animals.push(new Animal()); // 运行时破坏了 dogs 数组的类型这意味着很多多态的数组操作都不能用. 你不能写一个接受
Animal[]的函数, 然后传入Dog[]. 这对于实际项目来说是灾难性的事件处理模式会崩
DOM 的
addEventListener和 Node.js 的事件模式都依赖这个宽松:// addEventListener 期望的签名是 (e: Event) => void // 但我们传入的是 (e: MouseEvent) => void // 如果禁止双变宽松, 参数只能逆变, 参数 MouseEvent 比 Event 更具体, 不能赋值 element.addEventListener('click', (e: MouseEvent) => { console.log(e.clientX); });整个 JavaScript 生态的事件系统都依赖这种模式. 如果严格类型检查, 事件处理 API 完全不可用
目前 TypeScript 也没想好怎么做...
其他语言是怎么做的
C# 等采用 名义类型系统 语言需要显式标注类型参数用于 参数 / 返回值 才能开启 协变 / 逆变. 不标注则默认不变
// 允许协变: out 关键字, T 只能出现在输出位置
interface IFactory<out T> {
T Create();
// void Process(T item); // 真这么使用会导致编译错误, out T 不能用于参数
}
// 允许逆变: in 关键字, T 只能出现在输入位置
interface ICallback<in T> {
void Process(T item);
// T Create(); // 真这么使用会导致编译错误, in T 不能用于返回值
}
// 默认不变: 不标注, T 可以出现在任何位置
interface IHandler<T> {
T Process(T item);
}
// 使用时
class Animal { public string Name = ""; }
class Dog : Animal { public void Bark() => Console.WriteLine("Woof!"); }
// 协变生效
IFactory<Dog> dogFactory = ...;
IFactory<Animal> animalFactory = dogFactory;
// 逆变生效
ICallback<Animal> animalCallback = ...;
ICallback<Dog> dogCallback = animalCallback;
// 不变: 两个方向都不允许
IHandler<Dog> dogHandler = ...;
IHandler<Animal> animalHandler = ...;
animalHandler = dogHandler; // 编译错误
dogHandler = animalHandler; // 编译错误如何规避双变宽松引发的运行时错误
typescript-eslint 提供了一条规则, 强制使用 property 语法定义方法来获得更严格的类型检查:
// eslint.config.js
export default tseslint.config({
rules: {
"@typescript-eslint/method-signature-style": "error"
}
});这条规则会强制所有 method signature 改成 property signature, 但这会让代码可读性变差:
// 会报错
interface T1 {
func(arg: string): number;
}