Typescript进阶之逆变、协变、双向协变、不变

591 阅读4分钟

深入学习 TypeScript 类型系统的话,逆变、协变、双向协变、不变是绕不过去的概念。

类型安全和型变

TypeScript 给 JavaScript 添加了一套静态类型系统,是为了保证类型安全的,也就是保证变量只能赋同类型的值,对象只能访问它有的属性、方法。

比如 number 类型的值不能赋值给 boolean 类型的变量,Date 类型的对象就不能调用 exec 方法。

这是类型检查做的事情,遇到类型安全问题会在编译时报错。

但是这种类型安全的限制也不能太死板,有的时候需要一些变通,比如子类型是可以赋值给父类型的变量的,可以完全当成父类型来使用,也就是“型变(variant)”(类型改变)。

这种“型变”分为两种,一种是子类型可以赋值给父类型,叫做协变(covariant),一种是父类型可以赋值给子类型,叫做逆变(contravariant)。

先来看下协变:

协变(covariant)

协变是指子类型可以替代父类型的情况。

其中协变是很好理解的,比如我们有两个 interface:

interface Person {
    name: string;
    age: number;
} 
​
interface Guang {
    name: string;
    age: number;
    hobbies: string[]
}
​

这里 Guang 是 Person 的子类型,更具体,那么 Guang 类型的变量就可以赋值给 Person 类型:

let person: Person = {
    name: '张三',
    age: 30
};

let guang: Guang = {
    name: '李四',
    age: 25,
    hobbies: ['读书', '运动']
};

// 由于 Guang 是 Person 的子类型,所以可以将 Guang 类型的变量赋值给 Person 类型的变量
person = guang;

// 但是不能将 Person 类型的变量赋值给 Guang 类型的变量,因为 Person 缺少 hobbies 属性
// guang = person; // 编译错误:Property 'hobbies' is missing in type 'Person' but required in type 'Guang'.

这并不会报错,虽然这俩类型不一样,但是依然是类型安全的。

这种子类型可以赋值给父类型的情况就叫做协变。

逆变(contravariant)

逆变是指在函数参数类型中,父类型的参数可以替代子类型的参数。

我们有这样两个函数:

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 么?

测试一下发现是这样的:

interface Person {
    name: string;
    age: number;
}

interface Guang {
    name: string;
    age: number;
    hobbies: string[];
}

let printHobbies: (guang: Guang) => void;

printHobbies = (guang) => {
    console.log(guang.hobbies);
};

let printName: (person: Person) => void;

printName = (person) => {
    console.log(person.name);
};

// 由于 Person 是 Guang 的父类型,可以将 printName 函数赋值给 printHobbies 函数
printHobbies = printName;

// 但是不能将 printHobbies 函数赋值给 printName 函数,因为 Person 类型的参数缺少 hobbies 属性
// printName = printHobbies; // 编译错误:Type '(guang: Guang) => void' is not assignable to type '(person: Person) => void'.

解释

  1. 类型兼容性

    • Guang 是 Person 的子类型,因为 Guang 包含了 Person 的所有属性,并且添加了一个额外的属性 hobbies
    • 在函数参数类型中,Person 类型的参数可以替代 Guang 类型的参数,因为 Person 类型的参数可以接受任何 Guang 类型的参数。
  2. 赋值规则

    • printHobbies = printName 是允许的,因为 printName 函数可以接受 Person 类型的参数,而 Guang 是 Person 的子类型。
    • printName = printHobbies 是不允许的,因为 printHobbies 函数期望 Guang 类型的参数,而 Person 类型的参数可能缺少 hobbies 属性。

总结

ts 通过给 js 添加了静态类型系统来保证了类型安全,大多数情况下不同类型之间是不能赋值的,但是为了增加类型系统灵活性,设计了父子类型的概念。父子类型之间自然应该能赋值,也就是会发生型变(variant)。

型变分为逆变(contravariant)和协变(covariant)。协变很容易理解,就是子类型赋值给父类型。逆变主要是函数赋值的时候函数参数的性质,参数的父类型可以赋值给子类型,这是因为按照子类型来声明的参数,访问父类型的属性和方法自然没问题,依然是类型安全的。但反过来就不一定了。

不过 ts 2.x 之前反过来依然是可以赋值的,也就是既逆变又协变,叫做双向协变。

为了更严格的保证类型安全,ts 添加了 strictFunctionTypes 的编译选项,开启以后函数参数就只支持逆变,否则支持双向协变。

型变都是针对父子类型来说的,非父子类型自然就不会型变也就是不变(invariant)。

ts 中父子类型的判定是按照结构来看的,更具体的那个是子类型。