子类型兼容性

在编程语言理论中,子类型与超(父)类型是类型多态的一种表现形式。子类型与超类型都有其各自的数据类型,将两者关联在一起的是它们之间的可替换关系。面向对象程序设计中的里氏替换原则描述了程序中任何使用了超类型的地方都可以用其子类型进行替换,并且在替换后程序的行为保持不变。当使用子类型替换超类型时,不需要修改任何其他代码,程序依然能够正常工作。

类型系统可靠性

如果一个类型系统能够识别并拒绝程序中所有可能的类型错误,那么我们称该类型系统是可靠的。TypeScript 中的类型系统允许一些未知操作通过类型检查。因此,TypeScript 的类型系统不总是可靠的。例如,我们可以使用类型断言来改写一个值的类型,尽管提供的类型是错误的,编译器也不会报错。示例如下:

const a: string = (1 as unknown) as string;

TypeScript 类型系统中的不可靠行为大多经过了严格的设计考量来适配 JavaScript 程序中早已广泛使用的编码模式。TypeScript 也提供了一些严格类型检查的编译选项,例如 --strictNullChecks 等,通过启用这些编译选项可以有选择地逐渐增强类型系统的可靠性。

子类型的基本性质

符号约定

在深入探讨子类型关系之前,让我们先约定一下本书中表示子类型和超类型关系的符号以便于之后的描述。

若类型 A 是类型 B 的子类型,则记作:

A <: B

反之,若类型 A 是类型 B 的超类型,则记作:

A :> B

自反性

子类型关系与超类型关系具有自反性,即任意类型都是其自身的子类型和超类型。自反性可以使用如下符号表示:

A <: A 且 A :> A

传递性

子类型关系与超类型关系也具有传递性。若类型 A 是类型 B 的子类型,且类型 B 是类型 C 的子类型,那么类型 A 也是类型 C 的子类型。传递性可以使用如下符号表示:

如果:

A <: B <: C

那么:

A <: C

顶端类型与尾端类型

顶端类型与尾端类型的概念来自类型论,它们是独立于编程语言而存在的。依据类型论中的描述,顶端类型是一种通用超类型,所有类型都是顶端类型的子类型;同时,尾端类型是所有类型的子类型。

TypeScript 中存在两种顶端类型,即 any 类型和 unknown 类型。因此,所有类型都是 any 类型和 unknown 类型的子类型。示例如下:

   boolean <: any
    string <: any
    number <: any
        {} <: any
() => void <: any

   boolean <: unknown
    string <: unknown
    number <: unknown
        {} <: unknown
() => void <: unknown

TypeScript 中仅存在一种尾端类型,即 never 类型。因此,never 类型是所有类型的子类型。示例如下:

never <: boolean
never <: string
never <: number
never <: {}
never <: () => void

原始类型

TypeScript 中的原始类型有 numberbigintbooleanstringsymbolvoidnullundefined、枚举类型以及字面量类型。原始类型间的子类型关系比较容易分辨。

字面量类型

字面量类型是其对应的基础原始类型的子类型。例如,数字字面量类型是 number 类型的子类型,字符串字面量类型是 string 类型的子类型。示例如下:

    true <: boolean
   'foo' <: string
       0 <: number
      0n <: bigint
Symbol() <: symbol

undefined与null

undefined 类型和 null 类型分别只包含一个值,即 undefined 值和 null 值。它们通常用来表示还未初始化的值。

undefined 类型是除尾端类型 never 外所有类型的子类型,其中也包括 null 类型。示例如下:

undefined <: boolean
undefined <: string
undefined <: number
undefined <: null
undefined <: {}
undefined <: () => void

null 类型是除尾端类型和 undefined 类型外的所有类型的子类型。示例如下:

null <: boolean
null <: string
null <: number
null <: {}
null <: () => void

关于 Nullable 类型的详细介绍请参考 5.3.6 节。

枚举类型

在联合枚举类型中,每个枚举成员都能够表示一种类型,同时联合枚举成员类型是联合枚举类型的子类型。例如,有如下的联合枚举类型定义:

enum E {
    A,
    B,
}

我们可以得出如下子类型关系:

E.A <: E
E.B <: E

在数值型枚举中,每个枚举成员都表示一个数值常量。因此,数值型枚举类型是 number 类型的子类型。例如,有如下数值型枚举类型定义:

enum E {
    A = 0,
    B = 1,
}

我们可以得出如下子类型关系:

E <: number

关于枚举类型的详细介绍请参考 5.4 节。

函数类型

函数类型由参数类型和返回值类型构成。在比较两个函数类型间的子类型关系时要同时考虑参数类型和返回值类型。在介绍函数类型间的子类型关系之前,让我们先介绍一个重要的概念,即变型。

变型

变型与复杂类型间的子类型关系有着密不可分的联系。变型描述的是复杂类型的组成类型是如何影响复杂类型间的子类型关系的。例如,已知 Cat 类型是 Animal 类型的子类型,那么 Cat 数组类型是否是 Animal 数组类型的子类型?又或者有一个参数类型为 Cat 类型的函数以及参数类型为 Animal 类型的函数,这两个函数间的子类型关系又如何?为了确定复杂类型间的子类型关系,编译器需要根据某种变型关系进行判断。

变型关系主要有以下三种:

  • 协变

  • 逆变

  • 双变

现约定如果复杂类型 Complex 是由类型 T 构成,那么我们将其记作 Complex(T)

假设有两个复杂类型 Complex(A)Complex(B),如果由 AB 的子类型能够得出 Complex(A)Complex(B) 的子类型,那么我们将这种变型称作协变。协变关系维持了复杂类型与其组成类型间的子类型关系。协变的子类型关系如下所示(符号→表示能够推导出):

A <: B  →  Complex(A) <: Complex(B)

如果由 AB 的子类型能够得出 Complex(B)Complex(A) 的子类型,那么我们将这种变型称作逆变。逆变关系反转了复杂类型与其组成类型间的子类型关系。逆变的子类型关系如下所示:

A <: B  →  Complex(B) <: Complex(A)

如果由 AB 的子类型或者 BA 的子类型能够得出 Complex(A)Complex(B) 的子类型,那么我们将这种变型称作双变。双变同时具有协变关系与逆变关系。双变的子类型关系如下所示:

A <: B 或 B <: A  →  Complex(A) <: Complex(B)

最后,若类型间不存在上述变型关系,那么我们称之为不变。

函数参数数量

在确定函数类型间的子类型关系时,编译器将检查函数的参数数量。

若函数类型 S 是函数类型 T 的子类型,则 S 中的每一个必选参数必须能够在 T 中找到对应的参数,即 S 中必选参数的个数不能多于 T 中的参数个数。示例如下:

type S = (a: number) => void;

type T = (x: number, y: number) => void;

若函数类型 S 是函数类型 T 的子类型,则 T 中的可选参数会计入参数总数,也就是在比较参数个数时不区分 T 中的可选参数和必选参数。示例如下:

type S = (a: number) => void;

type T = (x?: number, y?: number) => void;

若函数类型 S 是函数类型 T 的子类型,则 T 中的剩余参数会被视作无穷多的可选参数并计入参数总数。在这种情况下相当于不进行参数个数检查,因为 S 的参数个数不可能比无穷多还多。示例如下:

type S = (a: number, b: number) => void;

type T = (...x: number[]) => void;

通过以上两个例子可以看到,当 T 中存在可选参数或剩余参数时,函数类型检查是不可靠的。因为当使用子类型 S 替换了超类型 T 之后,调用 S 时的实际参数个数可能少于必选参数的个数。例如,有如下的函数 s 和函数 t,其中 st 的子类型,使用一个实际参数调用函数 t 没有问题,但是将 t 替换为其子类型 s 后会产生错误,因为调用 s 需要两个实际参数。示例如下:

function s(a: number, b: number): void {}
function t(...x: number[]): void {}

t(0);
s(0); // 编译错误

上面介绍了如何处理超类型(函数类型 T)中的可选参数和剩余参数。接下来,我们再看一下如何处理子类型(函数类型 S)中存在的可选参数和剩余参数。

若函数类型 S 是函数类型 T 的子类型,则 S 中的可选参数不计入参数总数,即允许 S 中存在多余的可选参数。示例如下:

type S = (a: boolean, b?: boolean) => void;

type T = (x: boolean) => void;

若函数类型 S 是函数类型 T 的子类型,则 S 中的剩余参数也不计入参数总数。示例如下:

type S = (a: boolean, ...b: boolean[]) => void;

type T = (x: boolean) => void;

函数参数类型

函数的参数类型会影响函数类型间的子类型关系。编译器在检查函数参数类型时有两种检查模式可供选择:

  • 非严格函数类型检查模式(默认模式)。

  • 严格函数类型检查模式。

    1. 非严格函数类型检查

      非严格函数类型检查是编译器默认的检查模式。在该模式下,函数参数类型与函数类型是双变关系。

      若函数类型 S 是函数类型 T 的子类型,那么 S 的参数类型必须是 T 中对应参数类型的子类型或者超类型。这意味着在对应位置上的两个参数只要存在子类型关系即可,而不强调哪一方应该是另一方的子类型。示例如下:

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

      此例中,ST 的子类型,同时 T 也是 S 的子类型。

      在默认的类型检查模式下,函数类型检查是不可靠的,因为编译器允许使用更具体的类型来替换宽松的类型。这会导致原本合法的函数调用在替换后变得不合法,因为替换后的函数参数类型要求更加严格。这个问题可以通过启用严格函数类型检查来解决。

    2. 严格函数类型检查

TypeScript 编译器提供了 --strictFunctionTypes 编译选项用来启用严格的函数类型检查。在该模式下,函数参数类型与函数类型是逆变关系,而非相对宽松的双变关系。

若函数类型 S 是函数类型 T 的子类型,那么 S 的参数类型必须是 T 中对应参数类型的超类型。示例如下:

type S = (a: number) => void;

type T = (x: 0 | 1) => void;

此例中,ST 的子类型,S 中参数 a 的类型必须是 T 中参数 x 类型的超类型。因此,上一节中的例子在严格函数类型检查模式下将产生编译错误。示例如下:

type S = (a: 0 | 1) => void;

type T = (x: number) => void;

此例中,在严格函数类型检查模式下 S 不再是 T 的子类型。

通过以上介绍能够了解到,在 --strictFunctionTypes 模式下函数参数类型检查是可靠的,因为它只允许使用更宽松的类型来替换具体的类型。

函数返回值类型

在确定函数类型间的子类型关系时,编译器将检查函数返回值类型是否兼容。不论是否启用了 --strictFunctionTypes 编译选项,函数返回值类型与函数类型始终是协变关系。

若函数类型 S 是函数类型 T 的子类型,那么 S 的返回值类型必须是 T 的返回值类型的子类型。示例如下:

type S = () => 0 | 1;

type T = () => number;

此例中,函数类型 S 是函数类型 T 的子类型。编译器对函数返回值类型的检查是可靠的,因为在期望得到 number 类型的地方提供更加具体的 0 | 1 类型是合法的。

函数重载

在确定函数类型间的子类型关系时,编译器将检查函数重载签名类型是否兼容。

若函数类型 S 是函数类型 T 的子类型,并且 T 存在函数重载,那么 T 的每一个函数重载必须能够在 S 的函数重载中找到与其对应的子类型。示例如下:

type S = {
    (x: string): string;
    (x: number): number;
};

type T = {
    (x: 'a'): string;
    (x: 0): number;
};

此例中,函数类型 S 是函数类型 T 的子类型,T 中的两个函数重载能够在 S 中找到与之对应的子类型。

对象类型

对象类型由零个或多个类型成员组成,在比较对象类型的子类型关系时要分别考虑每一个类型成员。

结构化子类型

TypeScript 中,对象类型间的子类型关系取决于对象的结构,我们称之为结构化子类型。在结构化子类型系统中仅通过比较两个对象类型的类型成员列表就能够确定它们的子类型关系。对象类型的名称完全不影响对象类型间的子类型关系。示例如下:

class Point {
    x: number = 0;
    y: number = 0;
}

class Position {
    x: number = 0;
    y: number = 0;
}

const point: Point = new Position();
const position: Position = new Point();

此例中,PositionPoint 的子类型,反过来也成立。虽然两者是完全不同的类声明,但是它们具有相同的结构,都定义了 number 类型的属性 xy

属性成员类型

若对象类型 S 是对象类型 T 的子类型,那么对于 T 中的每一个属性成员 M(如下例中的接口 T 及其成员 xy)都能够在 S 中找到一个同名的属性成员 N(如下例中的接口 S 及其成员 xy),并且 NM 的子类型。由此可知,对象类型 T 中的属性成员数量不能多于对象类型 S 中的属性成员数量。示例如下:

interface T {
    x: string;
    y: string;
}

interface S {
    x: 'x';
    y: 'y';
    z: 'z';
}

此例中,对象类型 S 是对象类型 T 的子类型。

若对象类型 S 是对象类型 T 的子类型,那么 T 中的必选属性成员(如下例中的接口 T 及其成员 x)在 S 中也必须为必选属性成员(如下例中的接口 S 及其成员 x)。示例如下:

interface T {
    x: string;
}

interface S0 {
    x: string;
    y: string;
}

interface S1 {
    x?: string;
    y: string;
}

此例中,S0T 的子类型,但 S1 不是 T 的子类型。

调用签名与构造签名

如果对象类型 S 是对象类型 T 的子类型,那么对于 T 中的每一个调用签名 M(如下例中的接口 T 及其调用签名 (x: string): boolean;(x: string, y: number):boolean;)都能够在 S 中找到一个调用签名 N(如下例中的接口 S 及其调用签名 (x:string, y?: number): boolean;),且 NM 的子类型。示例如下:

interface T {
    (x: string): boolean;
    (x: string, y: number): boolean;
}

interface S {
    (x: string, y?: number): boolean;
}

此例中,对象类型 S 是对象类型 T 的子类型。

对象类型中的构造签名与调用签名有着相同的判断规则。如果对象类型 S 是对象类型 T 的子类型,那么对于 T 中的每一个构造签名 M(如下例中的接口 T 及其构造签名 new (x: string): object;new (x: string, y: number): object;)都能够在 S 中找到一个构造签名 N(如下例中的接口 S 及其构造签名 new (x: string, y?: number):object;),且 NM 的子类型。示例如下:

interface T {
    new (x: string): object;
    new (x: string, y: number): object;
}

interface S {
    new (x: string, y?: number): object;
}

此例中,对象类型 S 是对象类型 T 的子类型。

字符串索引签名

假设对象类型 S 是对象类型 T 的子类型,如果 T 中存在字符串索引签名(如下例中的接口 T 及其字符串索引签名 [x: string]: boolean;),那么 S 中也应该存在字符串索引签名(如下例中的接口 S 及其字符串索引签名 [x: string]: true;),并且是 T 中字符串索引签名的子类型。示例如下:

interface T {
    [x: string]: boolean;
}

interface S {
    [x: string]: true;
}

此例中,对象类型 S 是对象类型 T 的子类型。

数值索引签名

假设对象类型 S 是对象类型 T 的子类型,如果 T 中存在数值索引签名(如下例中的接口 T 及其数字索引签名 [x: number]: boolean;),那么 S 中应该存在字符串索引签名或数值索引签名(如下例中的接口 S0 及其字符串索引签名 [x: string]: true; 或者接口 S1 及其数字索引签名 [x: number]: true;),并且是 T 中数值索引签名的子类型。示例如下:

interface T {
    [x: number]: boolean;
}

interface S0 {
    [x: string]: true;
}

interface S1 {
    [x: number]: true;
}

此例中,对象类型 S0S1 是对象类型 T 的子类型。

类实例类型

在确定两个类类型之间的子类型关系时仅检查类的实例成员类型,类的静态成员类型以及构造函数类型不进行检查。示例如下:

class Point {
    x: number;
    y: number;
    static t: number;
    constructor(x: number) {}
}

class Position {
    x: number;
    y: number;
    z: number;
    constructor(x: string) {}
}

const point: Point = new Position('');

此例中,PositionPoint 的子类型,在确定子类型关系时仅检查 xy 属性。

如果类中存在私有成员或受保护成员,那么在确定类类型间的子类型关系时要求私有成员和受保护成员来自同一个类,这意味着两个类需要存在继承关系。示例如下:

class Point {
    protected x: number;
}

class Position {
    protected x: number;
}

此例中,PointPosition 类型之间不存在子类型关系。虽然两者都定义了 number 类型的属性 x,但它们是受保护成员,因此要求属性 x 必须来自同一个类。再看下面这个例子:

class Point {
    protected x: number = 0;
}

class Position extends Point {
    protected y: number = 0;
}

此例中,PointPosition 中的受保护成员 x 都来自 Point,因此 PositionPoint 的子类型。

泛型

泛型指的是带有类型参数的类型,本节将介绍如何判断泛型间的子类型关系。

泛型对象类型

对于泛型接口、泛型类和表示对象类型的泛型类型别名而言,实例化泛型类型时使用的实际类型参数不影响子类型关系,真正影响子类型关系的是泛型实例化后的结果对象类型。例如,对于下例中的泛型接口 Empty,不论使用什么实际类型参数来实例化都不影响子类型关系,因为实例化后的 Empty 类型始终为空对象类型 {}。示例如下:

interface Empty<T> {}

type T = Empty<number>;
type S = Empty<string>;

此例中,对象类型 S 是对象类型 T 的子类型,同时对象类型 T 也是对象类型 S 的子类型。

在下例中,泛型实际类型参数 T 将影响实例化后的对象类型 NotEmpty。在比较子类型关系时,使用的是泛型实例化后的结果对象类型。示例如下:

interface NotEmpty<T> {
    data: T;
}

type T = NotEmpty<boolean>;
type S = NotEmpty<true>;

此例中,对象类型 S 是对象类型 T 的子类型。

泛型函数类型

与检查函数类型相似,编译器在检查泛型函数类型时有两种检查模式可供选择:

  • 非严格泛型函数类型检查。

  • 严格泛型函数类型检查。

TypeScript 编译器提供了 --noStrictGenericChecks 编译选项用来启用或关闭严格泛型函数类型检查。

  1. 非严格泛型函数类型检查

    在非严格泛型函数类型检查模式下,编译器先将所有的泛型类型参数替换为 any 类型,然后再确定子类型关系。这意味着泛型类型参数不影响泛型函数的子类型关系。例如,有以下两个泛型函数类型:

    type A = <T, U>(x: T, y: U) => [T, U];
    
    type B = <S>(x: S, y: S) => [S, S];

    首先,将所有的类型参数替换为 any 类型,结果如下:

    type A = (x: any, y: any) => [any, any];
    
    type B = (x: any, y: any) => [any, any];

    在替换后,AB 类型变成了相同的类型,因此 AB 的子类型,同时 B 也是 A 的子类型。

  2. 严格泛型函数类型检查

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

    例如,有如下的泛型函数类型 AB

    type A = <T, U>(x: T, y: U) => [T, U];
    
    type B = <S>(x: S, y: S) => [S, S];

    如果我们想要确定 A 是否为 B 的子类型,那么先尝试使用 B 的类型来推断 A 的类型。通过比较每个参数类型和返回值类型,能够得出类型参数 TU 均为 S。接下来使用推断的结果来实例化 A 类型,即将类型 A 中的 TU 均替换为 S,替换后的结果如下:

    type A = <S>(x: S, y: S) => [S, S];

    在统一了类型参数之后,再来比较泛型函数间的子类型关系。因为统一后的类型 AB 相同,所以 AB 的子类型。示例如下:

    type A = <S>(x: S, y: S) => [S, S];
    
    type B = <S>(x: S, y: S) => [S, S];

    至此,AB 的子类型关系确定完毕。注意,这时不能确定 B 是否也为 A 的子类型,因为当前的推导过程是由 BA 推导。

    现在反过来,如果我们最开始想要确定 B 是否为 A 的子类型,那么这时将由 AB 来推断并统一类型参数的值。经推断,S 的类型为联合类型 T | U,然后使用 S = T | U 来实例化 B 类型,结果如下:

    type B = <T, U>(x: T | U, y: T | U) => [T | U, T | U];

    在统一了类型参数之后,再来比较 AB 之间的子类型关系。示例如下:

    type A = <T, U>(x: T, y: U) => [T, U];
    
    type B = <T, U>(x: T | U, y: T | U) => [T | U, T | U];

    此时,B 不是 A 的子类型,因为 B 的返回值类型不是 A 的返回值类型的子类型。

联合类型

联合类型由若干成员类型构成,在计算联合类型的子类型关系时需要考虑每一个成员类型。

假设有联合类型 S = S0 | S1 和任意类型 T,如果成员类型 S0 是类型 T 的子类型,并且成员类型 S1 是类型 T 的子类型,那么联合类型 S 是类型 T 的子类型。例如,有如下定义的联合类型 S 和类型 T

type S = 0 | 1;

type T = number;

此例中,联合类型 S 是类型 T 的子类型。

假设有联合类型 S = S0 | S1 和任意类型 T,如果类型 T 是成员类型 S0 的子类型,或者类型 T 是成员类型 S1 的子类型,那么类型 T 是联合类型 S 的子类型。例如,有如下定义的联合类型 S 和类型 T

type S = number | string;

type T = 0;

此例中,类型 T 是联合类型 S 的子类型。

交叉类型

交叉类型由若干成员类型构成,在计算交叉类型的子类型关系时需要考虑每一个成员类型。

假设有交叉类型 S = S0 & S1 和任意类型 T,如果成员类型 S0 是类型 T 的子类型,或者成员类型 S1 是类型 T 的子类型,那么交叉类型 S 是类型 T 的子类型。例如,有如下定义的交叉类型 S 和类型 T

type S = { x: number } & { y: number };

type T = { x: number };

此例中,交叉类型 S 是类型 T 的子类型。

假设有交叉类型 S = S0 & S1 和任意类型 T,如果类型 T 是成员类型 S0 的子类型,并且类型 T 是成员类型 S1 的子类型,那么类型 T 是交叉类型 S 的子类型。例如,有如下定义的交叉类型 S 和类型 T

type S = { x: number } & { y: number };

type T = { x: number; y: number; z: number };

此例中,类型 T 是交叉类型 S 的子类型。