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 变化.

  • 我们知道 DogAnimal 的子类型.
  • 但是对于 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. 既然 DogAnimal 的子类型, 它必然有 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 语法声明的函数的参数时发生双变

  1. 函数类型不是通过 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;
  2. 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
  3. 双变宽松只对参数位生效, 对返回值仍然只有协变, 没有逆变

    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)
  • 两个要求互相矛盾, 所以只能不变

原本 DogAnimal 子类, 但是 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 升级造成的破坏性变更

  1. 数组操作会全面失效

    // Array<T> 的定义中包含有类似 push(...items: T[]): number 的签名
    // 如果禁止双变宽松, 参数只能逆变, 参数 Dog 比 Animal 更具体, 不能赋值
    
    const dogs: Dog[] = [new Dog()];
    const animals: Animal[] = dogs;  // 严格模式下报错
    
    animals.push(new Animal());  // 运行时破坏了 dogs 数组的类型

    这意味着很多多态的数组操作都不能用. 你不能写一个接受 Animal[] 的函数, 然后传入 Dog[]. 这对于实际项目来说是灾难性的

  2. 事件处理模式会崩

    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;
}