详解 TS 中的子类型兼容性

2,258 阅读21分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情

简介

在写 TypeScript 代码时经常遇到类型检查不通过的问题,这些问题根据编译器给出的错误提示以及修改建议多数可以快速修复。本文讲解的内容是编译器进行类型检查时的兼容性相关检查规则,这些规则在 TypeScript 语言背后默默发挥作用。不了解这些规则的话,在遇到兼容性报错只会根据错误提示来修复,若清楚了编译器的子类型赋值兼容性、赋值兼容性等规则,则可以及早规避相关代码错误。

就是说,本文讲的内容是 TypeScript 类型系统的内部工作方式与原理,掌握之后可以做到 “在编码时将代码在你的大脑里面运行一遍,不用依赖于编译器的类型检查”

赋值兼容性与子类型兼容性

在进行赋值、函数调用时传递参数,TypeScript 会对变量进行类型兼容性检查,若类型兼容性检查通过,则说明满足赋值兼容性要求。

一般情况下,若满足子类型兼容性,则一定满足赋值兼容性;然而,满足赋值兼容性,并不一定满足子类型兼容性。

赋值兼容性特例

下面三种情况下,满足赋值兼容性,但是不满足子类型兼容性。

  • any 类型 下例中变量 tT 类型,any 类型的变量 a 赋值给变量 t 不会导致类型错误,满足赋值兼容性。any 类型为[[顶端类型]],顶端类型是所有类型的超类型,不是 T 的子类型。
interface T {
 namestring;
}
declare let t: T
declare const aany
t = a
  • 数字枚举类型 下例中变量 anumber 类型,变量 e 为枚举类型 E 赋值给 a 不会导致类型错误,满足赋值兼容性。这里 number 类型能够赋值给数字枚举类型,但是 number 类型却不是数字枚举类型的子类型,不满足子类型兼容性。
enum E {
 A,
 B,
 C
}
let a = 5
declare let e: E
e = a
  • 带有可选属性的对象类型 下例中 S 类型能够赋值给 T 类型,满足赋值兼容性,但是 S 类型并不是 T 类型的子类型。因为按照子类型兼容性的要求,若 T 类型上所有的属性(包括可选属性)在 S 类型上都能找到,才算 ST 的子类型,这里 S 上没有 T 上存在的可选属性 b,所以不满足子类型兼容性。
interface T {
 astring;
 b?: string;
}

interface S {
    astring;
}
declare let t: T
declare let s: S
t = s

子类型与赋值类型差异的原因

既然上面三种情况不满足子类型兼容性,那为什么 TypeScript 又允许其满足赋值兼容性呢?

情况一:any 类型

any 类型是[[顶端类型]],根据类型论的解释:顶端类型是所有类型的超类型,确实不会是任何类型的子类型。这属于 TypeScript 类型系统的特例,目的应该是确保现有的 js 代码在向 ts 代码转移时更容易,可以非常快速的通过 any 类型来绕过 TypeScript 中子类型兼容性限制

比如:现有一份功能都已实现的 js 代码,给所有变量 any 类型,基本就可以直接变为 ts 代码,同时类型兼容性还不报错。不过这样一来,TypeScript 也就变成了 AnyScript 了: )。

情况二:数字枚举类型

其实,数字枚举类型被编译为 js 后,在运行时的值就是 number 类型,见下图。之所以,允许 number 类型被赋值给数字枚举类型,也是基于向现有代码兼容的考虑。

情况三:带有可选属性的对象类型

TypeScript 中的可选属性修饰符从类型的角度来看其实是给该属性值类型联合了一个 undefined 类型,而 js 中访问对象上不存在的属性和属性值为 undefined 都会得到 undefined 值。在 js 中多数情况下不使用 hasOwnProperty 方法来判断对象的属性是否存在,是没法区分属性不存在还是属性值为 undefined

TypeScript 的类型系统是在编译时检查的,编译后的代码不存在类型相关代码,所以为了与现有 js 代码兼容保留了带有可选属性的对象类型赋值兼容性。就是编译后代码赋值不影响代码执行,但是带有可选属性的对象类型在进行子类型关系检查时是不通过的。

子类型兼容性

据面向对象程序设计中[[里氏替换原则]]描述,程序中任何使用了超(父)类型的地方都可以使用其子类型进行替换。TypeScript 中的子类型兼容性体现的就是这一原则,而这也正是多态

多态

多态在类型论中指的是:相同的消息在发送给不同对象时,系统可以根据对象类型不同,分别引发对应类型的方法。

例如,电脑给打印机发出打印消息,彩色打印机和黑白打印机分别执行打印方法,各自打印出彩色照片和黑白照片。

那么,如果把“打印”这个消息发送给到非打印机设备,比如键盘,那么会怎样呢?

当然是不能执行打印方法,不能正常打印出照片的。那系统是怎么知道键盘不能接受打印消息呢?这就涉及到子类型兼容性了,不能执行打印方法的键盘相当于与打印机不兼容。

在 java 语言中打印机相当于是一个接口,描述了打印机所具备的基本功能(方法),不同类型打印机必须实现了打印机这个接口才是与打印机兼容的。

在 TypeScript 中,也存在基于接口实现的子类型兼容性判断,由于要与 js 语言兼容以及 js 语法特性的历史原因,TypeScript 又对子类型兼容检查制定了更详细的规则。

符号约定

子类型兼容性形容的是子类型与超类型的关系(为了便于描述,后文也称作子类型关系),这里把这个关系用符号进行一下约定,便于描述。

若类型 S 是类型 T 的子类型,则用符号表示为:

S <: T

任意类型都是自身的子类型和超类型,称为自反性,用符号表示为:

S <: S 且 S :> S

像类的继承一样,子类型与超类型关系具有传递性,用符号表示为:

若
R <: S <: T
则
R <: T

基本类型

基本类型主要包括原始类型、顶端类型、尾端类型,其子类型关系总结如下:

  1. never <: undefined <: null <: [原始类型] number bigint boolean string symbol <: [顶端类型] any unknown

  2. 字面量类型 <: 相应的原始类型

    1. true <: boolean
    2. 'a' <: string
    3. 0 <: number
    4. 0n <: bigint
    5. Symbol() <: symbol
  3. 枚举类型 <: number

用图表示如下:

TS子类型兼容性1.png

TS子类型兼容性2.png

函数类型

函数类型子类型关系的比较不同于上面简单类型的子类型关系比较,因为函数类型由参数类型、返回值类型构成。

函数参数数量

TypeScript 在检查函数子类型关系时,编译器将先检查函数参数数量,具体检查规则如下。

  • 规则一:如果 ST 的子类型,即:S <: T,则 S 中所有必选参数必须能够在 T 中找到对应的参数,即 S 中必选参数的个数不能多于 T 中的参数个数。

注意:这一点和对象类型的子类型关系刚好相反,后文会讲到。

type S = (a: number) => void;
type T = (x: number, y: number) => void;
  • 规则二:如果 ST 的子类型,即:S <: T,则 T 中的可选参数会计入参数总数,不区分可选参数还是必选参数。
type S = (a: number) => void;
type T = (x?: number, y?: number) => void;
  • 规则三:T 中的剩余参数会被当做无穷多个可选参数并计入参数总数,这相当于不进行参数个数检查,因为 S 的参数个数不可能比无穷多还多。
type S = (a: number) => void;
type T = (...x: number[]) => void;
  • 规则四:子类型 S 中的可选参数不计入参数总数,即 S 中可以存在多余的可选参数。
type S = (a: number, b?: number) => void;
type T = (x: number) => void;
  • 规则五:子类型 S 中的剩余参数不计入参数总数。
type S = (a: number, ...b: number[]) => void;
type T = (x: number) => void;

函数参数类型

TypeScript 检查完函数数量后继续检查函数参数类型,分为两种检查模式:非严格函数类型检查模式(默认模式)严格函数类型检查模式,可以通过 --strictFunctionTypes 编译选项来配置。

非严格函数类型检查模式 该模式下函数参数类型与函数类型是双变关系(双变关系是[[变型]]关系之一)。若函数类型 S 是函数类型 T 的子类型,那么 S 的参数类型必须是 T 中对应参数类型的子类型或者超类型,即只要函数对应位置参数满足子类型关系即可,与子类型关系的方向无关。

// 非严格函数类检查
type S = (a: 0 | 1) => number;
type T = (b: number) => number;

这里参数 ab 的子类型,参数 ba 的超类型,因此 S 是 T 的子类型。

Sep-12-2022 00-33-14 1.gif

严格函数类型检查模式 该模式下函数参数类型与函数类型是逆变关系。若函数类型 S 是函数类型 T 的子类型,那么 S 的参数类型必须是 T 中参数的超类型。

// 严格函数类检查
type S = (a: number) => number;
type T = (b: 0 | 1) => number;

这里参数 ab 的超类型, S 是 T 的子类型,下图用不正确的类型验证并开启严格函数类型检查会报错。

Sep-12-2022 00-39-00.gif

函数返回值类型

非严格函数类型检查模式严格函数类型检查模式下,函数返回值类型与函数类型始终是协变关系。若函数类型 S 是函数类型 T 的子类型,那么 S 的返回值类型必须是 T 返回值类型的子类型。

type S = (a: number) => 0;
type T = (b: 0 | 1) => number;

这里参数 a 是参数 b 的超类型,满足 S 是 T 子类型的函数参数数量及类型条件要求,同时 S 的返回值类型是字面量 0number 类型的子类型。若将 S、T 的返回值类型反过来,则破坏了协变关系,导致类型报错,见下图。

Sep-12-2022 01-09-45.gif

函数重载

若 S 是 T 的子类型,并且 T 存在函数重载,则 T 的每一个函数重载都能在 S 的函数重载中找到对应的子类型;S 中找到的子类型可以重复使用,就是说 S 的函数重载签名数量可以少于 T 的。

interface S {
    (a: string): 'a'
    (a: string, b: boolean): string
}
interface T {
    (a: string, b?: boolean): string
}

上面代码 S 是 T 的子类型的分析过程如下:

  1. T 上具有一个函数重载 (a: string, b?: boolean): string (简称重载 T1),于是去 S 上找对应的函数重载
  2. S 上的函数重载 (a: string): 'a' (简称重载 S1) 和上一步的函数重载按照函数类型的子类型兼容性规则判断兼容性
  3. T1 的参数数量为 2,S1 的参数数量为 1,满足数量要求
  4. S1 的参数类型是 T1 的参数类型的子类型(都是 string 类型,具有自反性),满足参数类型要求
  5. S1 返回值类型是 T1 返回值类型的子类型(字符串字面量是 string 类型的子类型),满足返回值类型要求。若修改 S1 的返回值类型将导致不兼容,产生下图报错
  6. 这里 TypeScript 编译器根据 S 的函数重载顺序判断 T1 所有重载在找到了对应重载,不再继续往下判断 S 上的另一个重载(但是我们还是继续分析 S 上的另一个重载)
  7. S 上存在另一个重载 (a: string, b: boolean): string (简称 S2),与 T1 按照函数类型的子类型兼容性规则进行判断
  8. T1 的参数数量为 2,S2 的参数数量为 2,满足数量要求
  9. T1、S2 的参数 a 满足类型要求,T1 的参数 b 类型为 boolean | undefined ,S2 的参数 b 类型为 boolean 不满足子类型要求
  10. 重载 S2 与重载 T1 不兼容

子类型函数重载返回值不兼容导致报错

子类型函数重载参数类型不兼容导致报错

对象类型

对象类型是有零个或多个基本类型、函数类型成员组成,比较对象类型的子类型关系时需要分别比较每一个类型成员子类型关系。

结构化子类型

TypeScript 中对象类型间的子类型关系取决于对象的结构,对象类型的名称不影响其子类型关系,这一特性叫做[[结构化子类型]],也可以通俗地理解为[[鸭式辨型]]。

这里类型 S、T 名称不同,但是具有相同成员类型,S 是 T 的子类型,同时 T 也是 S 的子类型,即S、T 满足子类型关系。

属性成员类型

  • 若对象类型 S 是对象类型 T 的子类型,则对于 T 中的每一个属性成员 M,都能够在 S 中找到一个同名的属性 N,满足 N 是 M 的子类型。也就是说 S 必须包含 T 中的所有属性成员,T 的属性成员个数不能多于 S 的。该关系也可以简单记为:协变
  • 另外,对象类型 T 中的必选属性成员在 S 中也必须是必选属性成员。

这里 S 包含 T 中所有属性成员,满足子类型兼容性

这里 T 的必选属性 y 在 S 中不是必选属性,不满足子类型兼容性

调用签名与构造签名

  • 若对象类型 S 是对象类型 T 的子类型,则对象类型 T 中每一个调用签名 M 都能在对象类型 S 中找到一个调用签名 N,满足 N 是 M 的子类型。该关系也可以简单记为:协变
  • 构造签名的子类型判断规则和调用签名相同,也就是说对象类型的子类型必须包含其超类型的调用签名或构造签名,同时调用签名或构造签名还要满足子类型要求。

字符串索引签名

若对象类型 S 是对象类型 T 的子类型,如果 T 中存在字符串索引签名(对象类型只能存在一个字符串签名),则 S 中也应该存在字符串索引签名,并且 S 中的字符串索引签名是 T 中字符串索引签名的子类型。该关系也可以简单记为:协变

数值索引签名

若对象类型 S 是对象类型 T 的子类型,如果 T 中存在数值索引签名(对象类型只能存在一个数值索引签名),则 S 中也应该存在数值索引签名或字符串索引签名,并且是 T 中数值索引签名的子类型。该关系也可以简单记为:协变

若对象类型、接口中同时存在数字索引签名和字符串索引签名时,数值索引签名必须能够赋值给字符串索引签名,因为在 JavaScript 中,对象的属性名只能为字符串或 Symbol,数组的数值索引最终也会被转为字符串索引。因此,数值索引签名表示的集合是字符串索引签名表示的集合的子集。

所以,与上面字符串索引签名的区别在于,与 T 中对应的可以是数值索引签名或字符串索引签名,相当于把子类型索引签名将类型放宽了,可以和函数类型参数类型的放宽类比。

类实例类型

  • 在判断两个类之前的子类型关系时,仅检查类的实例成员类型,不检查类的静态成员类型、构造函数类型。

这里类 S、T 的构造函数类型不同,类 S 的实例成员类型满足子类型关系(根据对象类型的属性成员类型判规则检查子类型关系),所以 S 是 T 的子类型。

这里修改类 S 的属性 x 的类型,导致类型 S 的实例成员类型子类型关系检查不通过,S 不是 T 的子类型。

  • 对于类的私有成员、受保护成员,检查子类型关系时要求其来自于同一个类,即两个类必须存在集成关系。

这里类 S 的成员 x 位受保护成员,S 并非继承自 T,故 S 不是 T 的子类型。

这里 T1 具有私有成员 x,同时 T1 继承自 T,故 T1 是 T 的子类型。

泛型

泛型可以理解为:类似于函数形参在被函数调用时传入的实参替换,泛型分为泛型对象类型、泛型函数类型。

泛型对象类型

泛型对象类型包含:泛型接口、泛型类、泛型类型别名,TypeScript 在检查其子类型关系时泛型的类型参数不参与,泛型对象的结果对象类型参与。

interface Event<T> {}
type T = Event<string>
type S = Event<number>

这里 Event<T> 为泛型接口,其结果类型为[[空对象类型字面量]] {} ,S、T 的类型为泛型接口的结果类型,也为[[空对象类型字面量]] {} 。根据上面对象类型分析可知,两个空对象类型字面量互相是子类型,即:S 是 T 的子类型,T 也是 S 的子类型。

interface Event<T> {
 data: T
}
type T = Event<string>
type S = Event<number>

这里类型别名 T 的类型为泛型接口 Event<string> 的结果类型,为 { data: string } 类型,称作类型 R1;类型别名 S 的类型为泛型接口 Event<number> 的结果类型,为 { data: number } 类型,称作类型 R2。根据对象类型的子类型关系判断规则可知,R2 上的 number 类型的属性 data 不是 R1 上 string 类型的属性 data 的子类型,故 S 不是 T 的子类型;同理,T 也不是 S 的子类型。

上述代码在 TypeScript 中验证见下图:

泛型函数类型

函数参数类型检查相似,编译器在检查泛型函数类型是也有非严格泛型函数类型检查严格泛型函数类型检查两种检查模式,可以通过 --noStrictGenericChecks 编译选项来配置。

非严格泛型函数类型检查 编译器将所有泛型参数类型替换为 any 类型,然后再按照函数类型的子类型关系检查规则来检查子类型兼容性。

type T = <UV>(a: U, b: V) => [U, V]
type S = <W>(a: W, b: W) => [W, W]

这段代码,在非严格泛型函数类型检查模式下,编译器检查步骤如下:

  1. 替换所有泛型参数为 any 类型
type T = (a: any, b: any) => [anyany]
type S = (a: any, b: any) => [anyany]

将泛型参数替换成了具体的类型 any,所以不再是泛型函数签名,去掉泛型参数并成为了普通函数签名。 2. 根据函数类型的子类型判断规则依次检查两个函数类型的参数数量、参数类型、返回值类型,这里两个函数签名类型完全相同符合函数类型的子类型关系要求 3. 得出检查结果:S 是 T 子类型,T 也是 S 的子类型

严格泛型函数类型检查 在严格泛型函数类型检查模式下,编译器不再使用 any 类型替换泛型参数,而是先通过类型推断来统一两个泛型函数的类型参数,再确定两者的子类型关系。

type T = <UV>(a: U, b: V) => [U, V]
type S = <W>(a: W, b: W) => [W, W]

假设要检查 T 是否是 S 的子类型,编译器处理步骤如下:

  1. 尝试使用 S 的类型来推断 T 的类型,得出 T 的参数类型 U、V 都为 W 类型
  2. 根据推断将 T 的泛型实例化
type T = <W>(a: W, b: W) => [W, W]
type S = <W>(a: W, b: W) => [W, W]
  1. 比较泛型实例化后两者的子类型关系,这里两者的类型完全相同
  2. 得出结论:T 是 S 的子类型

那这里是否能确定 S 是 T 的子类型呢?答案是不能的,因为要确定 S 是 T 的子类型需要反过来使用 T 的类型来推断 S 的类型,具体过程见下面分析。

要检查 S 是否是 T 的子类型,编译器处理步骤如下:

  1. 尝试使用 T 的类型来推断 S 的类型,得出 S 的参数类型 W 为联合类型 U | V
  2. 根据推断将 S 的泛型实例化
type T = <UV>(a: U, b: V) => [U, V]
type S = <UV>(a: U | V, b: U | V) => [U | V, U | V]
  1. 比较泛型实例化后两者的子类型关系,这里需要判断类型 S 是否是类型 T 的子类型
  2. 根据函数类型的子类型判断规则依次检查函数参数数量、参数类型、返回值类型,参数数量是满足要求的,但是参数类型、返回值类型不满足函数子类型要求

这里判断 U | V 是否是 U 的超类型(非严格函数类型检查模式)、或是否具有子类型关系(严格函数类型检查模式)时,需要知道联合类型的子类型判断规则,见下文说明。

  1. 得出结论:S 不是 T 的子类型

这里 S 不是 T 的子类型验证见下图:

联合类型

联合类型由若干成员类型构成,检查联合类型时需要考虑各个成员类型的子类型关系。检查规则为:若 S 的所有成员类型都是类型 T 的子类型,则 S 是 T 的子类型,也是[[联合类型]]中交集的概念。

这里 S 的成员类型 01 都是 T 的子类型,所以 S 也是 T 的子类型。

type T = number
type S = 0 | 1

这里 S 的成员类型 string 不是 T 的子类型,所以 S 不是 T 的子类型。

这里 S 和 T 都是联合类型,S 的成员类型 1a 都是 number | string 的子类型,所以 S 是 T 的子类型。

type T = number | string
type S = 1 | 'a'

联合类型 number | string 表示类型是 number string 类型之一,而字面量类型 1number 类型的子类型,所以 1number | string 的子类型,anumber | string 的子类型的推导同样。

交叉类型

交叉类型由若干成员类型构成,检查交叉类型时需要考虑各个成员类型的子类型关系。检查规则为:若 S 至少有一个成员类型是类型 T 的子类型,则 S 是 T 的子类型,也是[[交叉类型]]中并集的概念。

这里 S 的成员类型 number 是 T 的子类型,所以 S 是 T 的子类型。

type T = number
type S = number & string

这里 S 的所有成员类型都不是 T 的子类型,所以 S 不是 T 的子类型。

这里 S 的成员类型都不是 T 的子类型,但是 S 却是 T 的子类型,因为 S 的结果类型为 never 类型,而 never 为[[尾端类型]]是所有类型的子类型。

type T = number
type S = 'a' & 'b'

S 的结果类型为 never 类型。

一些疑难点

函数的子类型兼容性描述的是函数,而多态描述的是对象,两者相同?

在上文中函数类型提到使用子类型替换超类型这一原则就是多态,为何函数的子类型关系也能用多态来解释?

据面向对象程序设计中[[里氏替换原则]]描述,程序中任何使用了超(父)类型的地方都可以使用其子类型进行替换。TypeScript 中的子类型兼容性体现的就是这一原则,而这也正是多态

解答如下:

在 js 中函数也是对象,TypeScript 中将其类型描述为具有调用签名的对象,详细可见[[函数签名]]。所以,函数的子类型关系也属于对象类型的子类型关系,也算是多态。