类型放宽

在编译器进行类型推断的过程中,有时会将放宽的源类型作为推断的结果类型。例如,源类型为数字字面量类型 0,放宽后的类型为原始类型 number。示例如下:

let zero = 0;

此例中,等号右侧数字 0 的类型为数字字面量类型 0,推断出的变量 zero 的类型为放宽的 number 类型。

类型放宽是 TypeScript 语言的内部行为,它并非是提供给开发者的某种功能特性,因此只需了解即可。TypeScript 语言内部的类型放宽分为以下两类:

  • 常规类型放宽。

  • 字面量类型放宽。

接下来,让我们分别介绍它们。

常规类型放宽

常规类型放宽相对简单,是指编译器在进行类型推断时会将 undefined 类型和 null 类型放宽为 any 类型。常规类型放宽是在 TypeScript 语言早期版本中就已经存在的行为。在 TypeScript 2.0 版本之前,undefined 类型和 null 类型是内部类型,没有开放给开发者使用,因此编译器需要将它们放宽为 any 类型来方便用户使用以及在面板中显示相关类型信息。但自从 TypeScript 2.0 引入了 --strictNullChecks 模式后,常规类型放宽的规则也有所变化。

非严格类型检查模式

在非严格类型检查模式下,即没有启用 --strictNullChecks 编译选项时,undefined 类型和 null 类型会被放宽为 any 类型。我们可以在 tsconfig.json 配置文件中禁用严格类型检查模式,如下所示:

{
    "compilerOptions": {
        "strictNullChecks": false
    }
}

下例中,所有变量的推断类型均为 any 类型。需要理解的是即便在非严格类型检查模式下,undefined 值的类型依然是 undefined 类型(null 值同理),只是编译器在类型推断时将 undefined 类型放宽为了 any 类型。示例如下:

let a = undefined;   // any
const b = undefined; // any

let c = null;        // any
const d = null;      // any

严格类型检查模式

在启用了 --strictNullChecks 编译选项时,编译器不再放宽 undefined 类型和 null 类型,它们将保持各自的类型。我们可以在 tsconfig.json 配置文件中启用严格类型检查模式,如下所示:

{
    "compilerOptions": {
        "strictNullChecks": true
    }
}

下例中,变量 ab 推断出的类型为 undefined 类型,变量 cd 推断出的类型为 null 类型,编译器不会将它们的类型放宽。示例如下:

let a = undefined;   // undefined
const b = undefined; // undefined

let c = null;        // null
const d = null;      // null

字面量类型放宽

字面量类型放宽是指编译器在进行类型推断时会将字面量类型放宽为基础原始类型,例如将数字字面量类型 0 放宽为原始类型 number。但实际上,字面量类型放宽远不是像描述的这样简单。关于字面量类型的详细介绍请参考 5.5 节。

细分字面量类型

对于每一个字面量类型可以再将其细分为两种,即可放宽的字面量类型和不可放宽的字面量类型,如图7-1所示。

image 2024 05 17 11 28 36 365
Figure 1. 图7-1 细分字面量类型

每个字面量类型都通过一些内部标识来表示其是否为可放宽的字面量类型。在一个字面量类型被创建时,就已经确定了其是否为可放宽的字面量类型,并且不能再改变。判断是否为可放宽的字面量类型的规则如下:

  • 若字面量类型源自类型,那么它是不可放宽的字面量类型。

  • 若字面量类型源自表达式,那么它是可放宽的字面量类型。

在下例中,常量 zero 的类型为数字字面量类型 0,该类型是通过类型注解定义的,即源自类型。因此,类型注解中的数字字面量类型 0 是不可放宽的字面量类型。示例如下:

const zero: 0 = 0;
//          ~
//          类型为:数字字面量类型 0

下面再来看另外一个例子。下例中,赋值运算符右侧为数字字面量 0,它是一个表达式并且其类型为数字字面量类型 0。因为该数字字面量类型 0 源自表达式,所以它是可放宽的字面量类型。示例如下:

const zero = 0;
//           ~
//           类型为:数字字面量类型0

放宽的字面量类型

放宽的字面量类型指的是对字面量类型执行放宽操作后得到的结果类型。若字面量类型是不可放宽的字面量类型,那么对其执行放宽操作的结果不变,仍为字面量类型本身;若字面量类型是可放宽的字面量类型,那么对其执行放宽操作的结果为相应的基础原始类型,两者的对应关系如表 7-1 所示。

image 2024 05 17 11 30 58 813
Figure 2. 表7-1 放宽的字面量类型

字面量类型放宽的场景

当编译器进行类型推断时,如果当前表达式的值是可变的,那么将推断出放宽的字面量类型;反之,如果当前表达式的值是不可变的,那么不放宽字面量类型。

var 声明和 let 声明中,若给变量赋予了初始值,那么推断出的变量类型为放宽的初始值类型。下例中,变量 a 和变量 b 的初始值类型为可放宽的数字字面量类型 0。因为变量 a 和变量 b 的值是可变的,所以两者的推断类型为放宽的字面量类型,即 number 类型。示例如下:

var a = 0;
//  ~
//  推断的类型为:number

let b = 0;
//  ~
//  推断的类型为:number

const 声明中,由于常量的值一经设置就不允许再修改,因此在推断 const 声明的类型时不会执行类型放宽操作。下例中,编译器在推断常量 a 的类型时不会执行类型放宽操作,而是直接使用初始值的类型作为常量 a 的类型。示例如下:

const a = 0;
//    ~
//    推断的类型为:可放宽的数字字面量类型 0

这里要强调一下,常量 a 的推断类型为可放宽的数字字面量类型 0。

数组字面量中的元素是可以修改的,因此数组字面量元素的推断类型为放宽的字面量类型。下例中,foo 数组元素 0 的推断类型为放宽的数字字面量类型,即 number 类型。因此,foo 数组的类型为 number[]。同理,bar 数组元素的推断类型为联合类型 string | number。因此,bar 数组的推断类型为 (string | number)[] 类型。示例如下:

const foo = [0];
//           ~
//           推断的类型为:number[]

const bar = ['foo', 0];
//           ~~~~~~~~
//           推断的类型为:(string | number)[]

在对象字面量中,属性值是可变的,因此对象字面量属性的推断类型为放宽的字面量类型。下例中,常量 foo 的值是对象字面量,属性 a 的推断类型为放宽的数字字面量类型,即 number 类型;属性 b 的推断类型为放宽的字符串字面量类型,即 string 类型。最终,推断的常量 foo 的类型为 { a: number; b: string; } 类型。示例如下:

const foo = {
    a: 0,
//  ~
//  推断的类型为:number

    b: 'b'
//  ~
//  推断的类型为:string
};

在类的定义中,若非只读属性具有初始值,那么推断出的属性类型为初始值的放宽的字面量类型。下例中,Foo 类的属性 a 是非只读属性,并且带有初始值 0。因此,属性 a 的推断类型为放宽的数字字面量类型,即 number 类型。属性 b 是只读属性,在推断类型时不执行放宽操作,因此推断类型为其初始值的类型,即可放宽的数字字面量类型 0。示例如下:

class Foo {
    a = 0;
//  ~
//  推断的类型为:number

    readonly b = 0;
//           ~
//           推断的类型为:0
}

在函数或方法的参数列表中,若形式参数定义了默认值,那么推断出的参数类型为默认值的放宽的字面量类型。下例中,foo 函数和 baz 方法都定义了一个形式参数 x 并且默认值为 0。因此参数 x 的推断类型为放宽的数字字面量类型,即 number 类型。示例如下:

function foo(x = 0) {
    //       ~
    //       推断的类型为:number
}

const bar = {
    baz(x = 0) {
    //  ~
    //  推断的类型为:number
    }
};

在函数或方法中,若返回值的类型为字面量类型(不包含字面量类型的联合类型),那么推断的返回值类型为放宽的字面量类型。下例中,foo 函数返回值的类型为数字字面量类型 0。因此,foo 函数的推断返回值类型为放宽的数字字面量类型,即 number 类型。bar 函数的返回值类型为字面量类型联合类型 0 | 1。因此,bar 函数的推断返回值类型不进行放宽操作,仍为字面量类型联合类型 0 | 1。示例如下:

function foo() {
    //   ~~~
    //   推断的返回值类型为:number

    return 0;
}

function bar() {
    //   ~~~
    //   推断的返回值类型为:0 | 1

    return Math.random() < 0.5 ? 0 : 1;
}

全新的字面量类型

每个字面量类型都有一个内置属性表示其是否可以被放宽。在 TypeScript 语言的内部实现中,将源自表达式的字面量类型标记为全新的(fresh)字面量类型,只有全新的字面量类型才是可放宽的字面量类型。

当全新的字面量类型出现在代码中可变值的位置时才会执行类型放宽操作。示例如下:

const a = 0;
//    ~
//    推断的类型为:0

let b = a;
//  ~
//  推断的类型为:number

此例第 1 行,常量 a 初始值的类型为可放宽的数字字面量类型 0,同时也是全新的字面量类型。const 声明属于不可变的值。因此,推断常量 a 的类型时不进行字面量类型放宽操作,常量 a 的推断类型与初始值类型相同,即全新的可放宽的数字字面量类型 0。

此例第 5 行,常量 a 的类型为全新的可放宽的数字字面量类型 0,并且 let 声明属于可变的值。因此,推断变量 b 的类型时将进行字面量类型放宽操作,变量 b 的推断类型为放宽的全新的可放宽数字字面量类型 0,即 number 类型。

下面再来看另一个例子,如下所示:

const c: 0 = 0;
//    ~
//    类型为:0

let d = c;
//  ~
//  推断的类型为:0

此例第 1 行,虽然常量 c 的初始值类型为全新的可放宽的数字字面量类型 0,但是常量 c 使用了类型注解明确指定了数字字面量类型 0,因此,常量 c 的类型为不可放宽的数字字面量类型 0,同时它也是非全新的字面量类型。

此例第 5 行,虽然 let 声明属于可变位置,但是常量 c 的类型为非全新的字面量类型,因此,推断变量 d 的类型时不进行字面量类型放宽操作,变量 d 的推断类型与常量 c 的类型相同,均为非全新的不可放宽的数字字面量类型 0。

如果在代码中可变的位置上使用了 as const 断言,那么可变位置将变成不可变位置,同时也不再进行字面量类型放宽操作。示例如下:

let a = 0;
//  ~
//  推断的类型为:number

let b = 0 as const;
//  ~
//  推断的类型为:0

此例第 5 行,在 let 声明中使用了 as const 断言,从而可变位置成为不可变位置,因此推断变量 b 的类型时不再使用放宽的字面量类型。