类型细化

类型细化是指 TypeScript 编译器通过分析特定的代码结构,从而得出代码中特定位置上表达式的具体类型。细化后的表达式类型通常比其声明的类型更加具体。类型细化最常见的表现形式是从联合类型中排除若干个成员类型。例如,表达式的声明类型为联合类型 string | number,经过类型细化后其类型可以变得更加具体,例如成为 string 类型。

TypeScript 编译器主要能够识别以下几类代码结构并进行类型细化:

  • 类型守卫。

  • 可辨识联合类型。

  • 赋值语句。

  • 控制流语句。

  • 断言函数。

下面我们分别介绍这几种代码结构是如何进行类型细化的。

类型守卫

类型守卫是一类特殊形式的表达式,具有特定的代码编写模式。编译器能够根据已知的模式从代码中识别出这些类型守卫表达式,然后分析类型守卫表达式的值,从而能够将相关的变量、参数或属性等的类型细化为更加具体的类型。

实际上,类型守卫早已经融入我们的代码当中,我们通常不需要为类型守卫做额外的编码工作,它们已经在默默地发挥作用。TypeScript 支持多种形式的类型守卫。接下来我们将分别介绍它们。

typeof类型守卫

typeof 运算符用于获取操作数的数据类型。typeof 运算符的返回值是一个字符串,该字符串表明了操作数的数据类型。由于支持的数据类型的种类是固定的,因此 typeof 运算符的返回值也是一个有限集合,具体如表 6-1 所示。

image 2024 05 16 23 56 39 856
Figure 1. 表6-1 typeof运算符

typeof 类型守卫能够根据 typeof 表达式的值去细化 typeof 操作数的类型。例如,如果 typeof x 的值为字符串 'number',那么编译器就能够将 x 的类型细化为 number 类型。示例如下:

function f(x: unknown) {
    if (typeof x === 'undefined') {
        x; // undefined
    }

    if (typeof x === 'object') {
        x; // object | null
    }

    if (typeof x === 'boolean') {
        x; // boolean
    }

    if (typeof x === 'number') {
        x; // number
    }

    if (typeof x === 'string') {
        x; // string
    }

    if (typeof x === 'symbol') {
        x; // symbol
    }

    if (typeof x === 'function') {
        x; // Function
    }
}

从表6-1中能够看到,对 null 值使用 typeof 运算符的返回值不是字符串 'null',而是字符串 'object'。因此,typeof 类型守卫在细化运算结果为 'object' 的类型时,会包含 null 类型。示例如下:

function f(x: number[] | undefined | null) {
    if (typeof x === 'object') {
        x; // number[] | null
    } else {
        x; // undefined
    }
}

此例第 2 行,在 typeof 类型守卫中使用了字符串 'object',参数 x 细化后的类型为 number[] 类型或 null 类型。在 JavaScript 中没有独立的数组类型,数组属于对象类型。

虽然函数也是一种对象类型,但函数特殊的地方在于它是可以调用的对象。typeof 运算符为函数类型定义了一个单独的 'function' 返回值,使用了 'function'typeof 类型守卫会将操作数的类型细化为函数类型。示例如下:

interface FooFunction {
    (): void;
}

function f(x: FooFunction | undefined) {
    if (typeof x === 'function') {
        x; // FooFunction
    } else {
        x; // undefined
    }
}

我们介绍过带有调用签名的对象类型是函数类型。因为接口表示一种对象类型,且 FooFunction 接口中有调用签名成员,所以 FooFunction 接口表示函数类型。第 6 行,typeof 类型守卫将参数x的类型细化为函数类型 FooFunction

instanceof类型守卫

instanceof 运算符能够检测实例对象与构造函数之间的关系。instanceof 运算符的左操作数为实例对象,右操作数为构造函数,若构造函数的 prototype 属性值存在于实例对象的原型链上,则返回 true;否则,返回 false

instanceof 类型守卫会根据 instanceof 运算符的返回值将左操作数的类型进行细化。例如,下例中如果参数 x 是使用 Date 构造函数创建出来的实例,如 new Date(),那么将 x 的类型细化为 Date 类型。同理,如果参数 x 是一个正则表达式实例,那么将 x 的类型细化为 RegExp 类型。示例如下:

function f(x: Date | RegExp) {
    if (x instanceof Date) {
        x; // Date
    }

    if (x instanceof RegExp) {
        x; // RegExp
    }
}

instanceof 类型守卫同样适用于自定义构造函数,并对其实例对象进行类型细化。例如,下例中定义了两个类 AB,通过 instanceof 类型守卫能够将实例对象细化为类型 AB

class A {}
class B {}

function f(x: A | B) {
    if (x instanceof A) {
        x; // A
    }

    if (x instanceof B) {
        x; // B
    }
}

in类型守卫

in 运算符是 JavaScript 中的关系运算符之一,用来判断对象自身或其原型链中是否存在给定的属性,若存在则返回 true,否则返回 falsein 运算符有两个操作数,左操作数为待测试的属性名,右操作数为测试对象。

in 类型守卫根据 in 运算符的测试结果,将右操作数的类型细化为具体的对象类型。示例如下:

interface A {
    x: number;
}
interface B {
    y: string;
}

function f(x: A | B) {
    if ('x' in x) {
        x; // A
    } else {
        x; // B
    }
}

此例第 9 行,如果参数x中存在属性 'x',那么我们知道 x 的类型为 A。在这种情况下,in 类型守卫也能够将参数 x 的类型细化为 A

逻辑与、或、非类型守卫

逻辑与表达式、逻辑或表达式和逻辑非表达式也可以作为类型守卫。逻辑表达式在求值时会判断操作数的真与假。如果一个值转换为布尔值后为 true,那么该值为真值;如果一个值转换为布尔值后为 false,那么该值为假值。不同类型的值转换为布尔值的具体规则如表 6-2 所示。

image 2024 05 17 00 05 19 529
Figure 2. 表6-2 布尔值的转换

不仅是逻辑表达式会进行真假值比较,JavaScript 中的很多语法结构也都会进行真假值比较。例如,if 条件判断语句使用真假值比较,若 if 表达式的值为真,则执行 if 分支的代码,否则执行 else 分支的代码。示例如下:

function f(x: true | false | 0 | 0n | '' | undefined | null)
{
    if (x) {
        x; // true
    } else {
        x; // false | 0 | 0n | '' | undefined | null
    }
}

此例第 2 行,if 语句的条件判断表达式中只使用了参数 x。在 if 分支中,以 x 是真值为前提对 x 进行类型细化,细化后的类型为 true 类型,因为 false | 0 | 0n | ' ' | undefined |null 都是假值类型。

逻辑非运算符 ! 是一元运算符,它只有一个操作数。若逻辑非运算符的操作数为真,那么逻辑非表达式的值为 false;反之,若逻辑非运算符的操作数为假,则逻辑非表达式的值为 true。逻辑非类型守卫将根据逻辑非表达式的结果对操作数进行类型细化。示例如下:

function f(x: true | false | 0 | 0n | '' | undefined | null)
{
    if (!x) {
        x; // false | 0 | 0n | '' | undefined | null
    } else {
        x; // true
    }
}

此例第 2 行,在参数 x 上使用逻辑非类型守卫能够将 if 分支中 x 的类型细化为假值类型,即 false | 0 | 0n | '' | undefined | null 联合类型。

逻辑与运算符 && 是二元运算符,它有两个操作数。若左操作数为假,则返回左操作数;否则,返回右操作数。逻辑与类型守卫将根据逻辑与表达式的结果对操作数进行类型细化。示例如下:

function f(x: number | undefined | null) {
    if (x !== undefined && x !== null) {
        x; //  number
    } else {
        x; //  undefined | null
    }
}

此例第 3 行,在 if 分支中以逻辑与类型守卫的结果为 true 作为前提,对参数 x 进行细化。第 2 行,先对 && 运算符的左操作数进行类型细化,细化后 x 的类型为联合类型 number | null;在此基础上,接下来根据 && 运算符的右操作数继续进行类型细化,结果为 number 类型。

逻辑或运算符 || 是二元运算符,它有两个操作数。若左操作数为真,则返回左操作数;否则,返回右操作数。同逻辑与类型守卫类似,逻辑或类型守卫将根据逻辑或表达式的结果对操作数进行类型细化。示例如下:

function f(x: 0 | undefined | null) {
    if (x === undefined || x === null) {
        x; // undefined | null
    } else {
        x; // 0
    }
}

逻辑与、或、非类型守卫也支持在操作数中使用对象属性访问表达式,并且能够对对象属性进行类型细化。示例如下:

interface Options {
    location?: {
        x?: number;
        y?: number;
    };
}

function f(options?: Options) {
    if (options && options.location && options.location.x) {
        const x = options.location.x; // number
    }

    const y = options.location.x;
    //        ~~~~~~~~~~~~~~~~
    //        编译错误:对象可能为 'undefined'
}

此例中,options 参数以及它的属性都是可选的,它们的值有可能是 undefined。第 9 行,使用了逻辑与类型守卫来确保 options.location.x 访问路径中的每一个值都不为空。第 10 行,将属性 x 的类型细化为非空类型 number。第 13 行,在没有使用类型守卫的情况下直接访问 options.location.x 属性会产生编译错误,因为在属性 x 的访问路径上有可能出现 undefined 值,而访问 undefined 值的某个属性将抛出类型错误异常。

需要注意的是,如果在对象属性上使用了逻辑与、或、非类型守卫,而后又对该对象属性进行了赋值操作,那么类型守卫将失效,不会进行类型细化。示例如下:

interface Options {
    location?: {
        x?: number;
        y?: number;
    };
}

function f(options?: Options) {
    if (options && options.location && options.location.x) {
        // 有效
        const x = options.location.x;           // number
    }

    if (options && options.location && options.location.x) {
        options.location = { x: 1, y: 1 };      // 重新赋值

        // 无效
        const x = options.location.x;           // number | undefined
    }

    if (options && options.location && options.location.x) {
        options = { location: { x: 1, y: 1 } }; // 重新赋值

        // 无效
        const x = options.location.x;           // 编译错误
    }
}

作为对比,第 9 行的类型守卫能够生效,因为在 if 分支内没有对 options 及其属性进行重新赋值。第 15 行,在使用了类型守卫后又对 options.location 进行了重新赋值,这会导致第 14 行的类型守卫失效。实际上不只是 options.location,给 options.location.x 访问路径上的任何对象属性重新赋值都会导致类型守卫失效。例如第 22 行,对 options 赋值也会导致类型守卫失效。

等式类型守卫

等式表达式是十分常用的代码结构,同时它也是一种类型守卫,即等式类型守卫。等式表达式可以使用四种等式运算符 ===!====!=,它们能够将两个值进行相等性比较并返回一个布尔值。编译器能够对等式表达式进行分析,从而将等式运算符的操作数进行类型细化。

当等式运算符的操作数之一是 undefined 值或 null 值时,该等式类型守卫也是一个空值类型守卫。空值类型守卫能够将一个值的类型细化为空类型或非空类型。示例如下:

function f0(x: boolean | undefined) {
    if (x === undefined) {
        x; // undefined
    } else {
        x; // boolean
    }

    if (x !== undefined) {
        x; // boolean
    } else {
        x; // undefined
    }
}

function f1(x: boolean | null) {
    if (x === null) {
        x; // null
    } else {
        x; // boolean
    }

    if (x !== null) {
        x; // boolean
    } else {
        x; // null
    }
}

此例中,if 语句的条件表达式是等式类型守卫。编译器将根据等式表达式的值在 if 分支和 else 分支中对参数 x 进行类型细化。例如第 2 行,if 语句的条件表达式判断参数 x 是否等于 undefined 值,然后在 if 分支中将 x 的类型细化为 undefined 类型,并在 else 分支中将 x 的类型细化为 boolean 类型。

如果等式类型守卫中使用的是严格相等运算符 ===!==,那么类型细化时将区别对待 undefined 类型和 null 类型。例如,若判定一个值严格等于 undefined 值,则将该值细化为 undefined 类型,而不是细化为联合类型 undefined | null。示例如下:

function f0(x: boolean | undefined | null) {
    if (x === undefined) {
        x; // undefined
    } else {
        x; // boolean | null
    }

    if (x !== undefined) {
        x; // boolean | null
    } else {
        x; // undefined
    }

    if (x === null) {
        x; // null
    } else {
        x; // boolean | undefined
    }

    if (x !== null) {
        x; // boolean | undefined
    } else {
        x; // null
    }
}

此例第 8 行,当 x 不为 undefined 值时,TypeScript 推断出 x 的类型为 booleannull,这意味着等式类型守卫将分开处理 undefined 类型和 null 类型。

但如果等式类型守卫中使用的是非严格相等运算符 ==!=,那么类型细化时会将 undefined 类型和 null 类型视为相同的空类型,不论在等式类型守卫中使用的是 undefined 值还是 null 值,结果都是相同的。例如,若使用非严格相等运算符判定一个值等于 undefined 值,则将该值细化为联合类型 undefined | null。示例如下:

function f0(x: boolean | undefined | null) {
    if (x == undefined) {
        x; // undefined | null
    } else {
        x; // boolean
    }

    if (x != undefined) {
        x; // boolean
    } else {
        x; // undefined | null
    }
}

function f1(x: boolean | undefined | null) {
    if (x == null) {
        x; // undefined | null
    } else {
        x; // boolean
    }

    if (x != null) {
        x; // boolean
    } else {
        x; // undefined | null
    }
}

此例第 2 行和第 16 行,我们能看到不论是将 xundefined 值比较,还是与 null 值比较,类型细化后的 x 的类型都是联合类型 undefined | null

除了 undefined 值和 null 值之外,等式类型守卫还支持以下种类的字面量:

  • boolean 字面量。

  • string 字面量。

  • number 字面量和 bigint 字面量。

  • 枚举成员字面量。

当等式类型守卫中出现以上字面量时,会将操作数的类型细化为相应的字面量类型。示例如下:

function f0(x: boolean) {
    if (x === true) {
        x; // true
    } else {
        x; // false
    }
}

function f1(x: string) {
    if (x === 'foo') {
        x; // 'foo'
    } else {
        x; // string
    }
}

function f2(x: number) {
    if (x === 0) {
        x; // 0
    } else {
        x; // number
    }
}

function f3(x: bigint) {
    if (x === 0n) {
        x; // 0n
    } else {
        x; // bigint
    }
}

enum E {
    X,
    Y,
}
function f4(x: E) {
    if (x === E.X) {
        x; // E.X
    } else {
        x; // E.Y
    }
}

等式类型守卫也支持将两个参数或变量进行等式比较,并同时细化两个操作数的类型。示例如下:

function f0(x: string | number, y: string | boolean) {
    if (x === y) {
        x; // string
        y; // string
    } else {
        x; // string | number
        y; // string | boolean
    }
}

function f1(x: number, y: 1 | 2) {
    if (x === y) {
        x; // 1 | 2
        y; // 1 | 2
    } else {
        x; // number
        y; // 1 | 2
    }
}

此例第 2 行,如果 xy 相等,那么 xy 的类型一定相同。因此,编译器将 if 分支中 xy 的类型细化为两者之间的共同类型 string。同理,第 12 行,yx 的子类型,如果 xy 相等,那么 xy 都为 1 | 2 类型。

switch 语句中,每一个 case 分支语句都相当于等式类型守卫。在 case 分支中,编译器会对条件表达式进行类型细化。示例如下:

function f(x: number) {
    switch (x) {
        case 0:
            x; // 0
            break;
        case 1:
            x; // 1
            break;
        default:
            x; // number
    }
}

此例中,switch 语句的每个 case 分支都相当于将 xcase 表达式的值进行相等比较并可以视为等式类型守卫,编译器能够细化参数 x 的类型。

自定义类型守卫函数

除了内置的类型守卫之外,TypeScript 允许自定义类型守卫函数。类型守卫函数是指在函数返回值类型中使用了类型谓词的函数。类型谓词的语法如下所示:

x is T

在该语法中,x 为类型守卫函数中的某个形式参数名;T 表示任意的类型。从本质上讲,类型谓词相当于 boolean 类型。

类型谓词表示一种类型判定,即判定 x 的类型是否为 T。当在 if 语句中或者逻辑表达式中使用类型守卫函数时,编译器能够将 x 的类型细化为 T 类型。例如,下例中定义了两个类型守卫函数 isTypeAisTypeB,两者分别能够判定函数参数 x 的类型是否为类型 AB

type A = { a: string };
type B = { b: string };

function isTypeA(x: A | B): x is A {
    return (x as A).a !== undefined;
}

function isTypeB(x: A | B): x is B {
    return (x as B).b !== undefined;
}

function f(x: A | B) {
    if (isTypeA(x)) {
        x; // A
    } else {
        x; // B
    }

    if (isTypeB(x)) {
        x; // B
    } else {
        x; // A
    }
}

此例第 13 行使用 isTypeA 类型守卫函数,在 if 分支中编译器能够将参数 x 的类型细化为 A 类型,同时在 else 分支中编译器能够将参数 x 的类型细化为 B 类型。

this类型守卫

在类型谓词 x is T 中,x 可以为关键字 this,这时它叫作 this 类型守卫。this 类型守卫主要用于类和接口中,它能够将方法调用对象的类型细化为 T 类型。示例如下:

class Teacher {
    isStudent(): this is Student {
        return false;
    }
}

class Student {
    grade: string;

    isStudent(): this is Student {
        return true;
    }
}

function f(person: Teacher | Student) {
    if (person.isStudent()) {
        person.grade; // Student
    }
}

此例中,isStudent 方法是 this 类型守卫,能够判定 this 对象是否为 Student 类的实例对象。第 16 行,在 if 语句中使用了 this 类型守卫后,编译器能够将 if 分支中 person 对象的类型细化为 Student 类型。

请注意,类型谓词 this is T 只能作为函数和方法的返回值类型,而不能用作属性或存取器的类型。在 TypeScript 的早期版本中曾支持在属性上使用 this is T 类型谓词,但是在之后的版本中移除了该特性。

可辨识联合类型

在程序中,通过结合使用联合类型、单元类型和类型守卫能够创建出一种高级应用模式,这称作可辨识联合。

可辨识联合也叫作标签联合或变体类型,是一种数据结构,该数据结构中存储了一组数量固定且种类不同的类型,还存在一个标签字段,该标签字段用于标识可辨识联合中当前被选择的类型,在同一时刻只有一种类型会被选中。

可辨识联合在函数式编程中比较常用,TypeScript 基于现有的代码结构和编码模式提供了对可辨识联合的支持。根据可辨识联合的定义,TypeScript 中的可辨识联合类型由以下几个要素构成:

  • 一组数量固定且种类不同的对象类型。这些对象类型中含有共同的判别式属性,判别式属性就是可辨识联合定义中的标签属性。若一个对象类型中包含判别式属性,则该对象类型是可辨识对象类型。

  • 由可辨识对象类型组成的联合类型即可辨识联合,通常我们会使用类型别名为可辨识联合类型命名。

  • 判别式属性类型守卫。判别式属性类型守卫的作用是从可辨识联合中选取某一特定类型。

接下来,我们通过一个例子来介绍可辨识联合的构造及使用方式。

第一步,先创建两个可辨识对象类型。下例中,我们使用接口定义了两个对象类型 SquareCircle。这两个对象类型中包含了共同的判别式属性 kind。示例如下:

interface Square {
    kind: 'square';
    size: number;
}

interface Circle {
    kind: 'circle';
    radius: number;
}

第二步,创建可辨识对象类型 SquareCircle 的联合类型,即可辨识联合。我们使用类型别名为该可辨识联合类型命名,以方便在程序中使用。示例如下:

type Shape = Square | Circle;

此例中,类型别名 Shape 引用了可辨识联合类型。

最后,我们将所有代码合并在一起。在程序中使用判别式属性类型守卫从可辨识联合类型中选取某一特定类型。示例如下:

interface Square {
    kind: 'square';
    size: number;
}

interface Circle {
    kind: 'circle';
    radius: number;
}

type Shape = Square | Circle;

function f(shape: Shape) {
    if (shape.kind === 'square') {
        shape; // Square
    }

    if (shape.kind === 'circle') {
        shape; // Circle
    }
}

此例第 14 行和第 18 行,在 if 语句中使用了判别式属性类型守卫去检查判别式属性的值。第 15 行,在 if 分支中根据判别式属性值 'square' 能够将可辨识联合细化为具体的 Square 对象类型。同理,第 19 行,在 if 分支中根据判别式属性值 'circle' 能够将可辨识联合细化为具体的 Circle 对象类型。

判别式属性

对于可辨识联合类型整体来讲,其判别式属性的类型是一个联合类型,该联合类型的成员类型是由每一个可辨识对象类型中该判别式属性的类型所组成。TypeScript 要求在判别式属性的联合类型中至少有一个单元类型。关于单元类型的详细介绍请参考 5.6 节。

字符串字面量类型是常用的判别式属性类型,它正是一种单元类型。除此之外,也可以使用数字字面量类型和枚举成员字面量类型等任意单元类型。例如,下例中以数字字面量类型作为判别式属性:

interface A {
    kind: 0;
    c: number;
}

interface B {
    kind: 1;
    d: number;
}

type T = A | B;

function f(t: T) {
    if (t.kind === 0) {
        t; // A
    } else {
        t; // B
    }
}

按照判别式属性的定义,可辨识联合类型中可以同时存在多个判别式属性。例如,在下例的可辨识对象类型 A 和可辨识对象类型 B 中,kind 属性和 type 属性都是判别式属性,两者都可以用来区分可辨识联合。示例如下:

interface A {
    kind: true;
    type: 'A';
}

interface B {
    kind: false;
    type: 'B';
}

type T = A | B;

function f(t: T) {
    if (t.kind === true) {
        t; // A
    } else {
        t; // B
    }

    if (t.type === 'A') {
        t; // A
    } else {
        t; // B
    }
}

通常情况下,判别式属性的类型都是单元类型,因为这样做方便在判别式属性类型守卫中进行比较。但在实际代码中事情往往没有这么简单,有些时候判别式属性不全是单元类型。因此,TypeScript 也适当放宽了限制,不要求可辨识联合中每一个判别式属性的类型都为单元类型,而是要求至少存在一个单元类型的判别式属性。例如,下例中的 Result 是可辨识联合类型,判别式属性 error 的类型为联合类型 null | Error,其中,null 类型是单元类型,而 Error 类型不是单元类型。示例如下:

interface Success {
    error: null;
    value: number;
}

interface Failure {
    error: Error;
}

type Result = Success | Failure;

function f(result: Result) {
    if (result.error) {
        result; // Failure
    }

    if (!result.error) {
        result; // Success
    }
}

第 13 行和第 17 行,判别式属性类型守卫仍可以通过比较判别式属性的值来细化可辨识联合类型。第 13 行,如果 result.error 的值为真,则将 result 参数的类型细化为 Failure 类型。第 17 行,如果 result.error 的值为假,则将 result 参数的类型细化为 Success 类型。

判别式属性类型守卫

判别式属性类型守卫表达式支持以下几种形式:

  • x.p

  • !x.p

  • x.p == v

  • x.p === v

  • x.p != v

  • x.p !== v

其中,x 代表可辨识联合对象;p 为判别式属性名;v 若存在,则为一个表达式。判别式属性类型守卫能够对可辨识联合对象 x 进行类型细化。示例如下:

interface Square {
    kind: 'square';
    size: number;
}

interface Rectangle {
    kind: 'rectangle';
    width: number;
    height: number;
}

interface Circle {
    kind: 'circle';
    radius: number;
}

type Shape = Square | Rectangle | Circle;

function f(shape: Shape) {
    if (shape.kind === 'square') {
        shape; // Square
    } else {
        shape; // Circle
    }

    if (shape.kind !== 'square') {
        shape; // Rectangle | Circle
    } else {
        shape; // Square
    }

    if (shape.kind === 'square' || shape.kind === 'rectangle') {
        shape; // Square | Rectangle
    } else {
        shape; // Circle
    }
}

除了使用判别式属性类型守卫和 if 语句之外,还可以使用 switch 语句来对可辨识联合类型进行类型细化。在每个 case 语句中,都会根据判别式属性的类型来细化可辨识联合类型。示例如下:

interface Square {
    kind: 'square';
    size: number;
}

interface Circle {
    kind: 'circle';
    radius: number;
}

type Shape = Square | Circle;

function f(shape: Shape) {
    switch (shape.kind) {
        case 'square':
            shape; // Square
            break;
        case 'circle':
            shape; // Circle
            break;
    }
}

可辨识联合完整性检查

回到可辨识联合的定义,可辨识联合是由一组数量固定且种类不同的对象类型构成。编译器能够利用该性质并结合 switch 语句来对可辨识联合进行完整性检查。编译器能够分析出 switch 语句是否处理了可辨识联合中的所有可辨识对象。让我们先回顾一下 switch 语句的语法,如下所示:

switch (expr) {

    case A:
        action
        break;

    case B:
        action
        break;

    default:
        action;
}

如果 switch 语句中的分支能够匹配 expr 表达式的所有可能值,那么我们将该 switch 语句称作完整的 switch 语句。若 switch 语句中定义了 default 分支,那么该 switch 语句一定是完整的 switch 语句。

例如,在下例的可辨识联合 Shape 中包含了 CircleSquare 两种类型。在 switch 语句中,两个 case 分支分别匹配了 CircleSquare 类型并返回。编译器能够检测出 switch 语句已经处理了所有可能的情况并退出函数,同时第 21 行的代码不可能被执行到。在这种情况下,编译器会给出提示 存在执行不到的代码。示例如下:

interface Circle {
    kind: 'circle';
    radius: number;
}

interface Square {
    kind: 'square';
    size: number;
}

type Shape = Circle | Square;

function area(s: Shape): number {
    switch (s.kind) {
        case 'square':
            return s.size * s.size;
        case 'circle':
            return Math.PI * s.radius * s.radius;
    }

    console.log('foo'); // <- 检测到此行为不可达的代码
}

更通用的完整性检查方法是给 switch 语句添加 default 分支,并在 default 分支中使用一个特殊的辅助函数来帮助进行完整性检查。示例如下:

interface Circle {
    kind: 'circle';
    radius: number;
}

interface Square {
    kind: 'square';
    size: number;
}

type Shape = Circle | Square;

function area(s: Shape) {
    switch (s.kind) {
        case 'square':
            return s.size * s.size;
        default:
            assertNever(s);
        //              ~
        //              编译错误!类型'Circle'不能赋值给类型'never'
    }
}

function assertNever(x: never): never {
    throw new Error('Unexpected object: ' + x);
}

此例中的方法是一种变通方法,它需要定义一个额外的 assertNever() 函数并声明它的参数类型为 never 类型。该方法能够帮助进行完整性检查的原因是,如果 switch 语句的 case 分支没有匹配到所有可能的可辨识对象类型,那么在 default 分支中 s 的类型为某一个或多个可辨识对象类型,而对象类型不允许赋值给 never 类型,因此会产生编译错误。但如果 case 语句匹配了全部的可辨识对象类型,那么 default 分支中s的类型为 never 类型,因此也就不会产生编译错误。

赋值语句分析

除了利用类型守卫去细化类型,TypeScript 编译器还能够分析代码中的赋值语句,并根据等号右侧操作数的类型去细化左侧操作数的类型。例如,当给变量赋予一个字符串值时,编译器可以将该变量的类型细化为 string 类型。示例如下:

let x;

x = true;
x; // boolean

x = false;
x; // boolean

x = 'x';
x; // string

x = 0;
x; // number

x = 0n;
x; // bigint

x = Symbol();
x; // symbol

x = undefined;
x; // undefined

x = null;
x; // null

上例中,在声明变量 x 时没有使用类型注解,因此编译器仅根据变量 x 被赋予的值进行类型细化。但如果在变量或参数声明中包含了类型注解,那么在进行类型细化时同样会参考变量声明的类型。示例如下:

let x: boolean | 'x';

x = 'x';

x; // 'x'

x = true;

x; // true

此例第 3 行,给变量 x 赋予了一个字符串值 'x'。第 5 行,变量 x 细化后的类型为字符串字面量类型 'x',而不是 string 类型。这是因为编译器在细化类型时必须参考变量声明的类型,细化后的类型能够赋值给变量声明的类型是最基本的要求。

在变量 x 的类型注解中使用了 boolean 类型。当给 x 赋予了 true 值之后,类型细化的结果是 true 类型,而不是 boolean 类型。因为在 6.3 节中我们介绍过,boolean 类型等同于 true | false 联合类型,因此变量 x 声明的类型等同于联合类型 true | false | 'x',那么细化后的类型为 true 类型也就不足为奇了。

基于控制流的类型分析

TypeScript 编译器能够分析程序代码中所有可能的执行路径,从而得到在代码中某一特定位置上的变量类型和参数类型等,我们将这种类型分析方式叫作基于控制流的类型分析。常用的控制流语句有 if 语句、switch 语句以及 return 语句等。在使用类型守卫时,我们已经在使用基于控制流的类型分析了。示例如下:

function f0(x: string | number | boolean) {
    if (typeof x === 'string') {
        x;  // string
    }

    x;   // number | boolean
}

function f1(x: string | number) {
    if (typeof x === 'number') {
        x;  // number
        return;
    }

    x;   // string
}

通过基于控制流的类型分析,编译器还能够对变量进行确切赋值分析。确切赋值分析能够对数据流进行分析,其目的是确保变量在使用之前已经被赋值。例如,下例中第 3 行和第 10 行会产生编译错误,因为在使用变量 x 之前它没有被赋值;但是在第 7 行没有编译错误,因为第 6 行对变量 x 进行了赋值操作,这就是确切赋值分析的作用。示例如下:

function f(check: boolean) {
    let x: number;
    x;         // 编译错误!变量 'x' 在赋值之前使用

    if (check) {
        x = 1;
        x;     // number
    }

    x;         // 编译错误!变量 'x' 在赋值之前使用
    x = 2;
    x;         // number
}

断言函数

在程序设计中,断言表示一种判定。如果对断言求值后的结果为 false,则意味着程序出错。

TypeScript 3.7 引入了断言函数功能。断言函数用于检查实际参数的类型是否符合类型判定。若符合类型判定,则函数正常返回;若不符合类型判定,则函数抛出异常。基于控制流的类型分析能够识别断言函数并进行类型细化。

断言函数有以下两种形式:

function assert(x: unknown): asserts x is T { }

或者

function assert(x: unknown): asserts x { }

在该语法中,asserts x is Tasserts x 表示类型判定,它只能作为函数的返回值类型。assertsis 是关键字;x 必须为函数参数列表中的一个形式参数名;T 表示任意的类型;is T 部分是可选的。若一个函数带有 asserts 类型判定,那么该函数就是一个断言函数。接下来将分别介绍这两种断言函数。

asserts x is T

对于 asserts x is T 形式的断言函数,它只有在实际参数 x 的类型为 T 时才会正常返回,否则将抛出异常。例如,下例中定义了 assertIsBoolean 断言函数,它的类型判定为 asserts x is boolean。这表示只有在参数 x 的值是 boolean 类型时,该函数才会正常返回,如果参数 x 的值不是 boolean 类型,那么 assertIsBoolean 函数将抛出异常。示例如下:

function assertIsBoolean(x: unknown): asserts x is boolean {
    if (typeof x !== 'boolean') {
        throw new TypeError('Boolean type expected.');
    }
}

assertIsBoolean 断言函数的函数体中,开发者需要按照约定的断言函数语义去实现断言函数。第 2 行使用了类型守卫,当参数 x 的类型不是 boolean 时函数抛出一个异常。

asserts x

对于 asserts x 形式的断言函数,它只有在实际参数 x 的值为真时才会正常返回,否则将抛出异常。例如,下例中定义了 assertTruthy 断言函数,它的类型判定为 asserts x。这表示只有在参数 x 是真值时,该函数才会正常返回,如果参数 x 不是真值,那么 assertTruthy 函数将抛出异常。示例如下:

function assertTruthy(x: unknown): asserts x {
    if (!x) {
        throw new TypeError(
            `${x} should be a truthy value.`
        );
    }
}

assertTruthy 断言函数的函数体中,开发者需要按照约定的断言函数语义去实现断言函数。第 2 行使用了类型守卫,当参数 x 是假值时,函数抛出一个异常。

关于真假值的详细介绍请参考 6.11.1 节。

断言函数的返回值

在定义断言函数时,我们需要将函数的返回值类型声明为 asserts 类型判定。编译器将 asserts 类型判定视为 void 类型,这意味着断言函数的返回值类型是 void。从类型兼容性的角度来考虑:undefined 类型可以赋值给 void 类型;never 类型是尾端类型,也可以赋值给 void 类型;当然,还有无所不能的 any 类型也可以赋值给 void 类型。除此之外,任何类型都不能作为断言函数的返回值类型(在严格类型检查模式下)。

下例中,f0 断言函数和 f1 断言函数都是正确的使用方式。如果函数抛出异常,那么相当于函数返回值类型为 never 类型;如果函数没有使用 return 语句,那么在正常退出函数时相当于返回了 undefined 值。f2 断言函数和 f3 断言函数是错误的使用方式,因为它们的返回值类型与 void 类型不兼容。示例如下:

function f0(x: unknown): asserts x {
    if (!x) {
        // 相当于返回 never 类型,与 void 类型兼容
        throw new TypeError(
            `${x} should be a truthy value.`
        );
    }

    // 正确,隐式地返回 undefined 类型,与 void 类型兼容
}

function f1(x: unknown): asserts x {
    if (!x) {
        throw new TypeError(
            `${x} should be a truthy value.`
        );
    }

    // 正确
    return undefined;  // 返回 undefined 类型,与 void 类型兼容
}

function f2(x: unknown): asserts x {
    if (!x) {
        throw new TypeError(
            `${x} should be a truthy value.`
        );
    }

    return false;  // 编译错误!类型 false 不能赋值给类型 void
}

function f3(x: unknown): asserts x {
    if (!x) {
        throw new TypeError(
            `${x} should be a truthy value.`
        );
    }

    return null; // 编译错误!类型 null 不能赋值给类型 void
}

断言函数的应用

当程序中调用了断言函数后,其结果一定为以下两种情况之一:

  • 断言判定失败,程序抛出异常并停止继续向后执行代码。

  • 断言判定成功,程序继续向后执行代码。

基于控制流的类型分析能够利用以上的事实对调用断言函数之后的代码进行类型细化。示例如下:

function assertIsNumber(x: unknown): asserts x is number {
    if (typeof x !== 'number') {
        throw new TypeError(`${x} should be a number.`);
    }
}

function f(x: any, y: any) {
    x; // any
    y; // any

    assertIsNumber(x);
    assertIsNumber(y);

    x; // number
    y; // number
}

此例中,assertIsNumber 断言函数用于确保传入的参数是 number 类型。f 函数的两个参数 xy 都是 any 类型。第 8、9 行还没有执行断言函数,这时参数 x 和 y 都是 any 类型。第 14、15 行,在执行了 assertIsNumber 断言函数后,编译器能够分析出当前位置上参数 xy 的类型一定是 number 类型。因为如果不是 number 类型,那么意味着断言函数已经抛出异常并退出了 f 函数,不可能执行到第 14 和 15 行位置。该分析结果也符合事实。

在 5.8.1 节中,我们介绍了返回值类型为 never 的函数。如果一个函数抛出了异常或者陷入了死循环,那么该函数无法正常返回一个值,因此该函数的返回值类型为 never 类型。如果程序中调用了一个返回值类型为 never 的函数,那么就意味着程序会在该函数的调用位置终止,永远不会继续执行后续的代码。

类似于对断言函数的分析,编译器同样能够分析出返回值类型为 never 类型的函数对控制流的影响以及对变量或参数等类型的影响。例如,在下例的函数 f 中,编译器能够推断出在 if 语句之外的参数 x 的类型为 string 类型。因为如果 x 的类型为 undefined 类型,那么函数将 终止 于第 7 行。示例如下:

function neverReturns(): never {
    throw new Error();
}

function f(x: string | undefined) {
    if (x === undefined) {
        neverReturns();
    }

    x; // string
}