TypeScript——类型基础(二)

类型基础

10、元组类型

元组(Tuple)表示由有限元素构成的有序列表。在JavaScript中,没有提供原生的元组数据类型。TypeScript对此进行了补充,提供了元组数据类型。由于元组与数组之间存在很多共性,因此TypeScript使用数组来表示元组。

在TypeScript中,元组类型是数组类型的子类型。元组是长度固定的数组,并且元组中每个元素都有确定的类型。

10.1、元组的定义

定义元组类型的语法与定义数组字面量的语法相似,具体语法如下所示:

ts 复制代码
[T0, T1, ..., Tn]

该语法中的T0、T1和Tn表示元组中元素的类型,针对元组中每一个位置上的元素都需要定义其数据类型。

下例中,我们使用元组来表示二维坐标系中的一个点。该元组中包含两个number类型的元素,分别表示点的横坐标和纵坐标。示例如下:

ts 复制代码
const point: [number, number] = [0, 0];

元组中每个元素的类型不必相同。例如,可以定义一个表示考试成绩的元组,元组的第一个元素是string类型的科目名,第二个元素是number类型的分数。示例如下:

ts 复制代码
const score: [string, number] = ['math', 100];

元组的值实际上是一个数组,在给元组类型赋值时,数组中每个元素的类型都要与元组类型的定义保持兼容。例如,对于"​[number,number]​"类型的元组,它只接受包含两个number类型元素的数组。示例如下:

ts 复制代码
const point: [number, number] = [0, 0];

若数组元素的类型与元组类型的定义不匹配,则会产生编译错误。示例如下:

ts 复制代码
let point: [number, number];

point = [0, 'y'];    // 编译错误
point = ['x', 0];    // 编译错误
point = ['x', 'y'];  // 编译错误

在给元组类型赋值时,还要保证数组中元素的数量与元组类型定义中元素的数量保持一致,否则将产生编译错误。示例如下:

ts 复制代码
let point: [number, number];

point = [0];        // 编译错误
point = [0, 0, 0];  // 编译错误

10.2、只读元组

元组可以定义为只读元组,这与只读数组是类似的。只读元组类型是只读数组类型的子类型。定义只读元组有以下两种方式:

  • 使用readonly修饰符。
  • 使用"Readonly、"工具类型。

以上两种定义只读元组的方式只是语法不同,它们在功能上没有任何差别。

readonly

TypeScript 3.4版本中引入了一种新语法,使用readonly修饰符能够定义只读元组。在定义只读元组时,将readonly修饰符置于元组类型之前即可。示例如下:

ts 复制代码
const point: readonly [number, number] = [0, 0];

Readonly<T>

由于TypeScript 3.4支持了使用readonly修饰符来定义只读元组,所以从TypeScript 3.4开始可以使用"Readonly<T>"工具类型来定义只读元组。示例如下:

ts 复制代码
const point: Readonly<[number, number]> = [0, 0];

注意事项:

在给只读元组类型赋值时,允许将常规元组类型赋值给只读元组类型,但是不允许将只读元组类型赋值给常规元组类型。换句话说,不能通过赋值操作来放宽对只读元组的约束。示例如下:

ts 复制代码
const a: [number] = [0];
const ra: readonly [number] = [0];

const x: readonly [number] = a; // 正确

const y: [number] = ra;         // 编译错误

10.3、访问元组中的元素

由于元组在本质上是数组,所以我们可以使用访问数组元素的方法去访问元组中的元素。在访问元组中指定位置上的元素时,编译器能够推断出相应的元素类型。示例如下:

ts 复制代码
const score: [string, number] = ['math', 100];

const course = score[0];   // string
const grade = score[1];    // number

const foo: boolean = score[0];
//    ~~~
//    编译错误!类型 'string' 不能赋值给类型 'boolean'

const bar: boolean = score[1];
//    ~~~
//    编译错误!类型 'number' 不能赋值给类型 'boolean'

当访问数组中不存在的元素时不会产生编译错误。与之不同的是,当访问元组中不存在的元素时会产生编译错误。示例如下:

ts 复制代码
const score: [string, number] = ['math', 100];

const foo = score[2];
//          ~~~~~~~~
//          编译错误!该元组类型只有两个元素,找不到索引为'2'的元素

修改元组元素值的方法与修改数组元素值的方法相同。示例如下:

ts 复制代码
const point: [number, number] = [0, 0];

point[0] = 1;
point[1] = 1;

10.4、元素类型中的可选元素

在定义元组时,可以将某些元素定义为可选元素。定义元组可选元素的语法是在元素类型之后添加一个问号"?"​,具体语法如下所示:

ts 复制代码
[T0?, T1?, ..., Tn?]

该语法中的T0、T1和Tn表示元组中元素的类型。如果元组中同时存在可选元素和必选元素,那么可选元素必须位于必选元素之后,具体语法如下所示:

ts 复制代码
[T0, T1?, ..., Tn?]

该语法中的T0表示必选元素的类型,T1和Tn表示可选元素的类型。下例中定义了一个包含三个元素的元组tuple,其中第一个元素是必选元素,后两个元素是可选元素:

ts 复制代码
const tuple: [boolean, string?, number?] = [true, 'yes', 1];

在给元组赋值时,可以不给元组的可选元素赋值。例如,对于上例中的tuple元组,它的值可以为仅包含一个元素的数组,或者是包含两个元素的数组,再或者是包含三个元素的数组。示例如下:

ts 复制代码
let tuple: [boolean, string?, number?] = [true, 'yes', 1];

tuple = [true];
tuple = [true, 'yes'];
tuple = [true, 'yes', 1];

10.5、元素类型中的剩余元素

在定义元组类型时,可以将最后一个元素定义为剩余元素。定义元组剩余元素类型的语法如下所示:

ts 复制代码
[...T[]]

该语法中,元组的剩余元素是数组类型,T表示剩余元素的类型。下例中,在元组tuple的定义中包含了剩余元素。其中,元组的第一个元素为number类型,其余的元素均为string类型。示例如下:

ts 复制代码
const tuple: [number, ...string[]] = [0, 'a', 'b'];

如果元组类型的定义中含有剩余元素,那么该元组的元素数量是开放的,它可以包含零个或多个指定类型的剩余元素。示例如下:

ts 复制代码
let tuple: [number, ...string[]];

tuple = [0];
tuple = [0, 'a'];
tuple = [0, 'a', 'b'];
tuple = [0, 'a', 'b', 'c'];

10.6、元组的长度

对于经典的元组类型,即不包含可选元素和剩余元素的元组而言,元组中元素的数量是固定的。也就是说,元组拥有一个固定的长度。TypeScript编译器能够识别出元组的长度并充分利用该信息来进行类型检查。示例如下:

ts 复制代码
function f(point: [number, number]) {
    // 编译器推断出length的类型为数字字面量类型2
    const length = point.length;

    if (length === 3) {       // 编译错误!条件表达式永远为 false
        // ...
    }
}

此例第3行,TypeScript编译器能够推断出常量length的类型为数字字面量类型2。第5行在if条件表达式中,数字字面量类型2与数字字面量类型3没有交集。因此,编译器能够分析出该比较结果永远为false。在这种情况下,编译器将产生编译错误。

当元组中包含了可选元素时,元组的长度不再是一个固定值。编译器能够根据元组可选元素的数量识别出元组所有可能的长度,进而构造出一个由数字字面量类型构成的联合类型来表示元组的长度。示例如下:

ts 复制代码
const tuple: [boolean, string?, number?] = [true, 'yes', 1];

let len = tuple.length;      // 1 | 2 | 3

len = 1;
len = 2;
len = 3;

len = 4;                     // 编译错误!类型'4'不能赋值给类型'1 | 2 | 3'

此例第1行,元组tuple中共包含3个元素,其中第一个元素是必选元素,后面两个元素是可选元素,元组tuple中可能的元素数量为1、2或3个。TypeScript编译器能够推断出此信息并构造出联合类型"1 |2 | 3"作为该元组length属性的类型。

第5、6、7行,允许将数字1、2和3赋值给len变量。第9行,不允许将数字4赋值给len变量,因为数字字面量类型4与联合类型"1 | 2 |3"不兼容。若元组类型中定义了剩余元素,那么该元组拥有不定数量的元素。因此,该元组length属性的类型将放宽为number类型。示例如下:

ts 复制代码
const tuple: [number, ...string[]] = [0, 'a'];

const len = tuple.length; // number

10.7、元组类型与数组类型的兼容性

前文提到过,元组类型是数组类型的子类型,只读元组类型是只读数组类型的子类型。在进行赋值操作时,允许将元组类型赋值给类型兼容的元组类型和数组类型。示例如下:

ts 复制代码
const point: [number, number] = [0, 0];

const nums: number[] = point; // 正确

const strs: string[] = point; // 编译错误

此例中,元组point的两个元素都是number类型,因此允许将point赋值给number数组类型,而不允许将point赋值给string数组类型。

元组类型允许赋值给常规数组类型和只读数组类型,但只读元组类型只允许赋值给只读数组类型。示例如下:

ts 复制代码
const t: [number, number] = [0, 0];
const rt: readonly [number, number] = [0, 0];

let a: number[] = t;

let ra: readonly number[];
ra = t;
ra = rt;

由于数组类型是元组类型的父类型,因此不允许将数组类型赋值给元组类型。示例如下:

ts 复制代码
const nums: number[] = [0, 0];

let point: [number, number] = nums;
//  ~~~~~
//  编译错误

11、对象类型

在JavaScript中存在这样一种说法,那就是"一切皆为对象"​。有这种说法是因为JavaScript中的绝大多数值都可以使用对象来表示。例如,函数、数组和对象字面量等本质上都是对象。对于原始数据类型,如String类型,JavaScript也提供了相应的构造函数来创建能够表示原始值的对象。例如,下例中使用内置的String构造函数创建了一个表示字符串的对象,示例如下:

ts 复制代码
const hi = new String('hi');

某些操作中,原始值还会自动地执行封箱[插图]操作,将原始数据类型转换为对象数据类型。例如,在字符串字面量上直接调用内置的"toUpperCase()"方法时,JavaScript会先将字符串字面量转换为对象类型,然后再调用字符串对象上的"toUpperCase()"方法。示例如下:

ts 复制代码
// 自动封箱,将'hi'转换为String对象类型
'hi'.toUpperCase();

// 自动封箱,将3转换为Number对象类型
// 注意:这里使用了两个点符号
3..toString()

由于对象类型的应用非常广泛,因此TypeScript提供了多种定义对象类型的方式。首先介绍三种基本的对象类型:

  • Object类型(首字母为大写字母O)
  • object类型(首字母为小写字母o)
  • 对象类型字面量

11.1、Object

这里的Object指的是Object类型,而不是JavaScript内置的"Object()"构造函数。请一定要注意区分这两者,Object类型表示一种类型,而"Object()"构造函数则表示一个值。因为"Object()"构造函数是一个值,因此它也有自己的类型。但要注意的是,​"Object()"构造函数的类型不是Object类型。为了更好地理解Object类型,让我们先了解一下"Object()"构造函数。

JavaScript提供了内置的"Object()"构造函数来创建一个对象。示例如下:

ts 复制代码
const obj = new Object();

在实际代码中,使用"Object()"构造函数来创建对象的方式并不常用。在创建对象时,我们通常会选择使用更简洁的对象字面量。虽然不常使用"Object()"构造函数来创建对象,但是"Object()"构造函数提供了一些非常常用的静态方法,例如"Object.assign()"方法和"Object.create()"方法等。

接下来,让我们深入分析一下TypeScript源码中对"Object()"构造函数的类型定义。下面仅摘取一部分着重关注的类型定义:

ts 复制代码
interface ObjectConstructor {

    readonly prototype: Object;

    // 省略了其他成员
}

declare var Object: ObjectConstructor;

由该定义能够直观地了解到"Object()"构造函数的类型是ObjectConstructor类型而不是Object类型,它们是不同的类型。第3行,prototype属性的类型为Object类型。构造函数的prototype属性值决定了实例对象的原型。此外,​"Object.prototype"是一个特殊的对象,它是JavaScript中的公共原型对象。也就是说,如果程序中没有刻意地修改一个对象的原型,那么该对象的原型链上就会有"Object.prototype"对象,因此也会继承"Object.prototype"对象上的属性和方法。

现在,我们可以正式地引出Object类型。Object类型是特殊对象"Object.prototype"的类型,该类型的主要作用是描述JavaScript中几乎所有对象都共享(通过原型继承)的属性和方法。

通过该类型定义能够了解到,Object类型里定义的方法都是通用的对象方法,如"valueOf()"方法。

类型兼容性:

ts 复制代码
let obj: Object;

// 正确
obj = { x: 0 };
obj = true;
obj = 'hi';
obj = 1;

// 编译错误
obj = undefined;
obj = null;

对象能够赋值给Object类型是理所当然的,但为什么原始值也同样能够赋值给Object类型呢?实际上,这样设计正是为了遵循JavaScript语言的现有行为。我们在本章开篇处介绍了 JavaScript语言中存在自动封箱操作。当在原始值上调用某个方法时,JavaScript会对原始值执行封箱操作,将其转换为对象类型,然后再调用相应方法。Object类型描述了所有对象共享的属性和方法,而JavaScript允许在原始值上直接访问这些方法,因此TypeScript允许将原始值赋值给Object类型。示例如下:

ts 复制代码
'str'.valueOf();

const str: Object = 'str';
str.valueOf();

常见错误:

在使用Object类型时容易出现的一个错误是,将Object类型应用于自定义变量、参数或属性等的类型。示例如下:

ts 复制代码
const point: Object = { x: 0, y: 0 };

此例中,将常量point的类型定义为Object类型。虽然该代码不会产生任何编译错误,但它是一个明显的使用错误。原因刚刚介绍过,Object类型的用途是描述"Object.prototype"对象的类型,即所有对象共享的属性和方法。在描述自定义对象类型时有很多更好的选择,完全不需要使用Object类型,例如接下来要介绍的object类型和对象字面量类型等。在TypeScript官方文档[插图]中也明确地指出了不应该使用Object类型,而是应该使用object类型来代替。

11.2、object

在TypeScript 2.2版本中,增加了一个新的object类型表示非原始类型。object类型使用object关键字作为标识,object类型名中的字母全部为小写。示例如下:

ts 复制代码
const point: object = { x: 0, y: 0 };

object类型的关注点在于类型的分类,它强调一个类型是非原始类型,即对象类型。object类型的关注点不是该对象类型具体包含了哪些属性,例如对象类型是否包含一个名为name的属性,因此,不允许读取和修改object类型上的自定义属性。示例如下:

ts 复制代码
const obj: object = { foo: 0 };

// 编译错误!属性'foo'不存在于类型'object'上
obj.foo;

// 编译错误!属性'foo'不存在于类型'object'上
obj.foo = 0;

在object类型上仅允许访问对象的公共属性和方法,也就是Object类型中定义的属性和方法。示例如下:

ts 复制代码
const obj: object = {};

obj.toString();
obj.valueOf();

类型兼容性:

我们知道,JavaScript中的数据类型可以划分为原始数据类型和对象数据类型两大类。针对JavaScript中的每一种原始数据类型,TypeScript都提供了对应的类型:

  • boolean
  • string
  • number
  • bigint
  • symbol
  • undefined
  • null

但是在以前的版本中,TypeScript唯独没有提供一种类型用来表示非原始类型,也就是对象类型。上一节介绍的Object类型无法表示非原始类型,因为允许将原始类型赋值给Object类型。例如,将字符串赋值给Object类型不会产生错误。示例如下:

ts 复制代码
const a: Object = 'hi';

新的object类型填补了这个功能上的缺失。object类型能够准确地表示非原始类型,因为原始类型不允许赋给object类型。示例如下:

ts 复制代码
let nonPrimitive: object;

// 下列赋值语句均会产生编译错误
nonPrimitive = true;
nonPrimitive = 'hi';
nonPrimitive = 1;
nonPrimitive = 1n;
nonPrimitive = Symbol();
nonPrimitive = undefined;
nonPrimitive = null;

只有非原始类型,也就是对象类型能够赋给object类型。示例如下:

ts 复制代码
let nonPrimitive: object;

// 正确
nonPrimitive = {};
nonPrimitive = { x: 0 };
nonPrimitive = [0];
nonPrimitive = new Date();
nonPrimitive = function () {};

object类型仅能够赋值给以下三种类型:

  • 顶端类型any和unknown。
  • Object类型。
  • 空对象类型字面量"{}"。
ts 复制代码
const nonPrimitive: object = {};

const a: any = nonPrimitive;
const b: unknown = nonPrimitive;

const nonPrimitive: object = {};

const obj: Object = nonPrimitive;

const nonPrimitive: object = {};

const obj: {} = nonPrimitive;

实例应用:

在JavaScript中,有一些内置方法只接受对象作为参数。例如,我们前面提到的"Object.create()"方法,该方法的第一个参数必须传入对象或者null值作为新创建对象的原型。如果传入了原始类型的值,例如数字1,那么将产生运行时的类型错误。示例如下:

ts 复制代码
// 正确
const a = Object.create(Object.prototype);
const b = Object.create(null);

// 类型错误
const c = Object.create(1);

在没有引入object类型之前,没有办法很好地描述"Object.create()"方法签名的类型。TypeScript也只好将该方法第一个参数的类型定义为any类型。如此定义参数类型显然不够准确,而且对类型检查也没有任何帮助。示例如下:

ts 复制代码
interface ObjectConstructor {
    create(o: any, ...): any;

    // 省略了其他成员
}

在引入了object类型之后,TypeScript更新了"Object.create()"方法签名的类型,使用object类型来替换any类型。示例如下:

ts 复制代码
interface ObjectConstructor {
    create(o: object | null, ...): any;

    // 省略了其他成员
}

现在,我们能够正确描述"Object.create()"方法的参数类型。如果传入了原始类型的参数,编译器在进行静态类型检查时就能够发现这个错误。示例如下:

ts 复制代码
const a = Object.create(1);
//                      ~
//                      编译错误

11.3、对象类型字面量

对象类型字面量是定义对象类型的方法之一。下例中,我们使用对象类型字面量定义了一个对象类型。该对象类型中包含了两个属性成员x和y,它们的类型均为number类型。示例如下:

ts 复制代码
const point: { x: number; y: number } = { x: 0, y: 0 };
//             ~~~~~~~~~~~~~~~~~~~~~~~~
//             对象类型字面量
11.3.1、基础语法

对象类型字面量的语法与对象字面量的语法相似。在定义对象类型字面量时,需要将类型成员依次列出。对象类型字面量的语法如下所示:

ts 复制代码
{
    TypeMember;
    TypeMember;
    ...
}

在该语法中,TypeMember表示对象类型字面量中的类型成员,类型成员置于一对大括号"{}"之内。

在各个类型成员之间,不但可以使用分号";"进行分隔,还可以使用逗号","进行分隔,这两种分隔符不存在功能上的差异。示例如下:

ts 复制代码
{
    TypeMember,
    TypeMember,
    ...
}

类型成员列表中的尾后分号和尾后逗号是可选的。示例如下:

ts 复制代码
{
    TypeMember;
    TypeMember;
}

{
    TypeMember;
    TypeMember
}

对象类型字面量的类型成员可分为以下五类:

  • 属性签名
  • 调用签名
  • 构造签名
  • 方法签名
  • 索引签名

属性签名:

属性签名声明了对象类型中属性成员的名称和类型。它的语法如下所示:

ts 复制代码
{
    PropertyName: Type;
}

在该语法中,PropertyName表示对象属性名,可以为标识符、字符串、数字和可计算属性名;Type表示该属性的类型。

下例中,我们使用对象类型字面量定义了Point对象类型,该类型表示二维坐标系中的点。Point对象类型包含两个属性签名类型成员,分别为表示横坐标的属性x和表示纵坐标的属性y,两者的类型均为number类型。示例如下:

ts 复制代码
let point: { x: number; y: number } = { x: 0, y: 0 };

属性签名中的属性名可以为可计算属性名,但需要该可计算属性名满足以下条件之一:

  • 可计算属性名的类型为string字面量类型或number字面量类型。示例如下:
ts 复制代码
 const a: 'a' = 'a';
 const b: 0 = 0;
   
 let obj: {
     [a]: boolean;
     [b]: boolean;
 
     ['c']: boolean;
     [1]: boolean;
 };
  • 可计算属性名的类型为"unique symbol"类型。示例如下:
ts 复制代码
 const s: unique symbol = Symbol();
 
 let obj: {
     [s]: boolean;
 };
  • 可计算属性名符合"Symbol.xxx"的形式。示例如下:
ts 复制代码
 let obj: {
     [Symbol.toStringTag]: string;
 };

在属性签名的语法中,表示类型的Type部分是可以省略的,允许只列出属性名而不定义任何类型。在这种情况下,该属性的类型默认为any类型。示例如下:

ts 复制代码
{
    x;
    y;
}

// 等同于:

{
    x: any;
    y: any;
}

注意,此例中的代码仅在没有启用"--noImplicitAny"编译选项的情况下才能够正常编译。若启用了"--noImplicitAny"编译选项,则会产生编译错误,因为对象属性隐式地获得了any类型。

可选属性:

在默认情况下,通过属性签名定义的对象属性是必选属性。如果在属性签名中的属性名之后添加一个问号"?"​,那么将定义一个可选属性。定义可选属性成员的语法如下所示:

ts 复制代码
{
    PropertyName?: Type;
}

在给对象类型赋值时,可选属性可以被忽略。下例中,我们修改了前面定义的Point对象类型,添加一个可选属性z来表示点的Z轴坐标。这样Point对象类型也能够表示三维坐标系中的点。示例如下:

ts 复制代码
let point: { x: number; y: number; z?: number };
//           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//           Point对象类型

point = { x: 0, y: 0 };
point = { x: 0, y: 0, z: 0 };

此例中,Point对象类型的属性z是可选属性。在给point变量赋值时,既可以为属性z赋予一个number类型的值,也可以完全忽略属性z。

在"--strictNullChecks"模式下,TypeScript会自动在可选属性的类型定义中添加unde-fined类型。因此,下例中两个Point对象类型的定义是等价的:

ts 复制代码
{
    x: number;
    y: number;
    z?: number;
};

// 等同于:
{
    x: number;
    y: number;
    z?: number | undefined;
};

该行为的结果是,我们可以为可选属性传入undefined值来明确地表示忽略该属性的值,示例如下:

ts 复制代码
let point: { x: number; y: number; z?: number };

point = { x: 0, y: 0 };
point = { x: 0, y: 0, z: undefined };
point = { x: 0, y: 0, z: 0 };

同时也要注意,在"--strictNullChecks"模式下,null类型与undefined类型是区别对待的。此例中,不允许给属性z赋予null值,如下所示:

ts 复制代码
let point: { x: number; y: number; z?: number };

point = {
    x: 0,
    y: 0,
    z: null,
//  ~
//  编译错误!类型'null'不能赋值给类型'number | undefined'
};

在非"--strictNullChecks"模式下,null值与undefined值均可以赋值给可选属性。因为在该模式下,null值与undefined值几乎可以赋值给任意类型。

在操作对象类型的值时,只允许读写对象类型中已经定义的必选属性和可选属性。若访问了未定义的属性,则会产生编译错误。例如,下例中point的类型里没有定义属性t,因此不允许读写属性t:

ts 复制代码
let point: { x: number; y: number; z?: number };

// 正确
point = { x: 0, y: 0 };
point.x;
point.y;

// 正确
point = { x: 0, y: 0, z: 0 };
point.x;
point.y;
point.z;

point = { x: 0, y: 0, z: 0, t: 0 }; // 编译错误
point.t;                            // 编译错误

只读属性:

在属性签名定义中添加readonly修饰符能够定义对象只读属性。定义只读属性的语法如下所示:

ts 复制代码
{
    readonly PropertyName: Type;
}

下例中,我们将Point对象类型中的属性x和属性y定义为只读属性:

ts 复制代码
let point: {
    readonly x: number;
    readonly y: number;
};

point = { x: 0, y: 0 };

只读属性的值在初始化后不允许再被修改,示例如下:

ts 复制代码
let point: {
    readonly x: number;
    readonly y: number;
};

// 正确,初始化
point = { x: 0, y: 0 };

point.x = 1;
//    ~
//    编译错误!不允许给x赋值,因为它是只读属性

point.y = 1;
//    ~
//    编译错误!不允许给y赋值,因为它是只读属性

空对象类型字面量:

如果对象类型字面量没有定义任何类型成员,那么它就成了一种特殊的类型,即空对象类型字面量"{}"​。空对象类型字面量表示不带有任何属性的对象类型,因此不允许在"{}"类型上访问任何自定义属性。示例如下:

ts 复制代码
const point: {} = { x: 0, y: 0 };

point.x;
//    ~
//    编译错误!属性 'x' 不存在于类型 '{}'

point.y;
//    ~
//    编译错误!属性 'y' 不存在于类型 '{}'

空对象类型字面量"{}"与Object类型十分相似。而事实上也正是如此,单从行为上来看两者是可以互换使用的。例如,除了undefined值和null值外,其他任何值都可以赋值给空对象类型字面量"{}"和Object类型。同时,空对象类型字面量"{}"和Object类型之间也允许互相赋值。示例如下:

ts 复制代码
let a: Object = 'hi';
let b: {} = 'hi';

a = b;
b = a;

两者的区别主要在于语义上。全局的Object类型用于描述对象公共的属性和方法,它相当于一种专用类型,因此程序中不应该将自定义变量、参数等类型直接声明为Object类型。空对象类型字面量"{}"强调的是不包含属性的对象类型,同时也可以作为Object类型的代理来使用。最后,也要注意在某些场景中新的object类型可能是更加合适的选择。

11.4、弱类型

弱类型(Weak Type)是TypeScript 2.4版本中引入的一个概念。

弱类型指的是同时满足以下条件的对象类型:

  • 对象类型中至少包含一个属性。
  • 对象类型中所有属性都是可选属性。
  • 对象类型中不包含字符串索引签名、数值索引签名、调用签名和构造签名。

例如,下例中config变量的类型是一个弱类型:

ts 复制代码
let config: {
    url?: string;
    async?: boolean;
    timeout?: number;
};

11.5、多余属性

对象多余属性可简单理解为多出来的属性。多余属性会对类型间关系的判定产生影响。例如,一个类型是否为另一个类型的子类型或父类型,以及一个类型是否能够赋值给另一个类型。显然,多余属性是一个相对的概念,只有在比较两个对象类型的关系时谈论多余属性才有意义。

假设存在源对象类型和目标对象类型两个对象类型,那么当满足以下条件时,我们说源对象类型相对于目标对象类型存在多余属性,具体条件如下:

  • 源对象类型是一个"全新(Fresh)的对象字面量类型"。
  • 源对象类型中存在一个或多个在目标对象类型中不存在的属性。

"全新的对象字面量类型"指的是由对象字面量推断出的类型

此例中,由赋值语句右侧的对象字面量"{ x: 0, y: 0 }"推断出的类型为全新的对象字面量类型"{ x: 0, y: 0 }"​。同时也要注意区分,赋值语句左侧类型注解中的"{ x: number, y: number }"不是全新的对象字面量类型。如果我们将赋值语句右侧的类型视作源对象类型,将赋值语句左侧的类型视作目标对象类型,那么不存在多余属性。

我们对这段代码稍加修改,如下所示:

ts 复制代码
const point: { x: number; y: number } = {
    x: 0,
    y: 0,
    z: 0,
//  ~~~~
//  z是多余属性
};

我们为赋值语句右侧的对象字面量增加了一个z属性。这时,赋值语句右侧的类型仍为全新的对象字面量类型。若仍将"{ x: number, y:number }"视为目标对象类型,那么源对象类型"{ x: 0, y: 0, z: 0 }"存在一个多余属性z。

目标对象类型中的可选属性与必选属性是被同等对待的。例如,下例中point的类型为弱类型,而赋值语句右侧源类型中的属性z仍然是多余属性:

ts 复制代码
const point: { x?: number; y?: number } = {
    x: 0,
    y: 0,
    z: 0,
//  ~~~~
//  z是多余属性
};

12、函数类型

12.1、常规参数类型

在函数形式参数列表中,为参数添加类型注解就能够定义参数的类型。例如,下例中将add函数声明中的参数x和参数y的类型都定义为number类型:

ts 复制代码
function add(x: number, y: number) {
    return x + y;
}

针对函数表达式和匿名函数,我们也可以使用相同的方法来定义参数的类型。示例如下:

ts 复制代码
const f = function (x: number, y: number) {
    return x + y;
};

如果在函数形式参数列表中没有明确指定参数类型,并且编译器也无法推断参数类型,那么参数类型将默认为any类型。示例如下:

ts 复制代码
function add(x, y) {
//           ~~~~
//           参数x和y隐式地获得了'any'类型

    return x + y;
}

注意,如果启用了"--noImplicitAny"编译选项,那么此例中的代码将会产生编译错误。我们必须指明参数的类型,如果期望的类型就是any类型,则需要使用类型注解来明确地标注。示例如下:

ts 复制代码
function add(x: any, y: any) {
    return x + y;
}

12.2、可选参数类型

在JavaScript中,函数的每一个参数都是可选参数,而在TypeScript中,默认情况下函数的每一个参数都是必选参数。在调用函数时,编译器会检查传入实际参数的个数与函数定义中形式参数的个数是否相等。如果两者不相等,则会产生编译错误。如果一个参数是可选参数,那么就需要在函数类型定义中明确指定。

在函数形式参数名后面添加一个问号"?"就可以将该参数声明为可选参数。例如,下例中我们将add函数的参数y定义为可选参数:

ts 复制代码
function add(x: number, y?: number) {
    return x + (y ?? 0);
}

我们也可以同时定义多个可选参数。示例如下:

ts 复制代码
function add(x: number, y?: number, z?: number) {
    return x + (y ?? 0) + (z ?? 0);
}

函数的可选参数必须位于函数参数列表的末尾位置。在可选参数之后不允许再出现必选参数,否则将产生编译错误。例如,下例中add函数的第一个参数x是可选参数,在它之后的参数y是必选参数,因此将产生编译错误。示例如下:

ts 复制代码
function add(x?: number, y: number) {
    //                   ~
    //                   编译错误!必选参数不能出现在可选参数之后
}

如果函数的某个参数是可选参数,那么在调用该函数时既可以传入对应的实际参数,也可以完全不传入任何实际参数。例如,下例中参数x是必选参数,y是可选参数。在调用add函数时,既可以传入一个实际参数,也可以传入两个实际参数。但是,若没有传入参数或者传入了多于两个的参数,则将产生编译错误。示例如下:

ts 复制代码
function add(x: number, y?: number) {
    return x + (y ?? 0);
}

add();        // 编译错误
add(1);       // 正确
add(1, 2);    // 正确
add(1, 2, 3); // 编译错误

在"--strictNullChecks"模式下,TypeScript会自动为可选参数添加undefined类型。因此,上例中add函数的定义等同于如下定义:

ts 复制代码
/**
 * --strictNullChecks=true
 */
function add(x: number, y?: number | undefined) {
    return x + (y ?? 0);
}

TypeScript允许给可选参数传入一个undefined值。示例如下:

ts 复制代码
/**
 * --strictNullChecks=true
 */
function add(x: number, y?: number) {
    return x + (y ?? 0);
}

add(1);            // 1
add(1, 2);         // 3
add(1, undefined); // 1

需要注意的是,为参数添加undefined类型不等同于该参数是可选参数。若省略了"?"符号,则参数将成为必选参数,在调用时必须传入一个实际参数值。

12.3、默认参数类型

函数默认参数类型可以通过类型注解定义,也可以根据默认参数值自动地推断类型。例如,下例中函数默认参数x的类型通过类型注解明确定义,而默认参数y的类型则是根据默认值0推断出的类型,最后两个参数的类型均为number类型。示例如下:

ts 复制代码
function add(x: number = 0, y = 0) {
    return x + y;
}

如果函数定义了默认参数,并且默认参数处于函数参数列表末尾的位置,那么该参数将被视为可选参数,在调用该函数时可以不传入对应的实际参数值。例如,下例中参数y是默认参数,且处于参数列表的末尾,因此参数y成了可选参数。在调用add函数时,允许不传入参数y的实际参数值。示例如下:

ts 复制代码
function add(x: number, y: number = 0) {
    return x + y;
}

add(1);    // 1
add(1, 2); // 3

在语法上,同一个函数参数不允许同时声明为可选参数和默认参数,否则将产生编译错误。示例如下:

ts 复制代码
function f(x?: number = 0) {
    //     ~
    //     编译错误!参数不能同时使用?符号和初始化值
}

如果默认参数之后存在必选参数,那么该默认参数不是可选的参数,在调用函数时必须传入对应的实际参数值。示例如下:

ts 复制代码
function add(x: number = 0, y: number) {
    return x + y;
}

add(1);            // 编译错误
add(1, 2);         // 正确
add(undefined, 2); // 正确

12.4、剩余参数类型

必选参数、可选参数和默认参数处理的都是单个参数,而剩余参数处理的则是多个参数。如果函数定义中声明了剩余参数,那么在调用函数时会将多余的实际参数收集到剩余参数列表中。因此,剩余参数的类型应该为数组类型或元组类型。虽然剩余参数也可以定义为顶端类型或尾端类型,但是实际意义不大,因此不展开介绍。

12.4.1、数组类型的剩余参数

最常见的做法是将剩余参数的类型声明为数组类型。例如,下例中的f函数定义了"number[​]​"类型的剩余参数:

ts 复制代码
function f(...args: number[]) {}

在调用定义了剩余参数的函数时,剩余参数可以接受零个或多个实际参数。示例如下:

ts 复制代码
function f(...args: number[]) {}

f();
f(0);
f(0, 1);
12.4.2、元组类型的剩余参数

剩余参数的类型也可以定义为元组类型。例如,下例中剩余参数args的类型为包含两个元素的元组类型:

ts 复制代码
function f(...args: [boolean, number]) {}

如果剩余参数的类型为元组类型,那么编译器会将剩余参数展开为独立的形式参数声明,主要包括以下几种情况:

  • 常规元组类型,示例如下:
ts 复制代码
 function f0(...args: [boolean, number]) {}
 
 // 等同于:
 
 function f1(args_0: boolean, args_1: number) {}
  • 带有可选元素的元组类型,示例如下:
ts 复制代码
 function f0(...args: [boolean, string?]) {}
 
 // 等同于:
 
 function f1(args_0: boolean, args_1?: string) {}
  • 带有剩余元素的元组类型,示例如下:
ts 复制代码
 function f0(...args: [boolean, ...string[]]) {}
 
 // 等同于:
 
 function f1(args_0: boolean, ...args_1: string[]) {}

在了解了元组类型剩余参数的展开行为后,我们也就清楚了该如何传入对应的实际参数,如下所示:

ts 复制代码
function f0(...args: [boolean, number, string]) {}
f0(true, 0, '');

function f1(...args: [boolean, number, string?]) {}
f1(true, 0, '');
f1(true, 0);

function f2(...args: [boolean, number, ...string[]]) {}
f2(true, 0);
f2(true, 0, '');
f2(true, 0, '', 'hello');

function f3(...args: [boolean, number?, ...string[]]) {}
f3(true);
f3(true, 0);
f3(true, 0, '');
f3(true, 0, '', 'hello');

12.5、解构参数类型

解构还可以应用在函数参数列表中。示例如下:

ts 复制代码
function f0([x, y]){}
f0([0, 1]);

function f1({x, y}){}
f1({x: 0, y: 1});

我们可以使用类型注解为解构参数添加类型信息。示例如下:

ts 复制代码
function f0([x, y]: [number, number]) {}
f0([0, 1]);

function f1({ x, y }: { x: number; y: number }) {}
f1({ x: 0, y: 1 });

12.6、返回值类型

在函数形式参数列表之后,可以使用类型注解为函数添加返回值类型。例如,下例中定义了add函数的返回值类型为number类型:

ts 复制代码
function add(x: number, y: number): number {
//                                  ~~~~~~
//                                  函数返回值类型
    return x + y;
}

在绝大多数情况下,TypeScript能够根据函数体内的return语句等自动推断出返回值类型,因此我们也可以省略返回值类型。示例如下:

ts 复制代码
function add(x: number, y: number) {
    return x + y;
}

此例中,我们没有为add函数添加返回值类型,但是TypeScript能够根据表达式"x + y"的类型推断出add函数的返回值类型为number类型。

在TypeScript的原始类型里有一个特殊的空类型void,该类型唯一有意义的使用场景就是作为函数的返回值类型。如果一个函数的返回值类型为void,那么该函数只能返回undefined值。这意味着函数明确地返回了一个undefined值,或者函数没有调用return语句,在这种情况下函数默认返回undefined值。示例如下:

ts 复制代码
// f0和f1是正确的使用场景

function f0(): void {
    return undefined;
}
function f1(): void {}


// f2, f3和f4是错误的使用场景

function f2(): void {
    return false;
}

function f3(): void {
    return 0;
}

function f4(): void {
    return '';
}

如果没有启用"--strictNullChecks"编译选项,那么void返回值类型也允许返回null值。示例如下:

ts 复制代码
/**
 * --strictNullChecks=false
 */
function f0(): void {
    return null;
}

12.7、函数类型字面量

函数类型字面量是定义函数类型的方法之一,它能够指定函数的参数类型、返回值类型以及将在6.1节中介绍的泛型类型参数。函数类型字面量的语法与箭头函数的语法相似,具体语法如下所示:

ts 复制代码
(ParameterList) => Type

在该语法中,ParameterList表示可选的函数形式参数列表;Type表示函数返回值类型;形式参数列表与返回值类型之间使用胖箭头"=>"连接。

下例中,变量f的类型为函数类型,这代表变量f的值是一个函数。该函数类型通过函数类型字面量进行定义,表示一个不接受任何参数且返回值类型为void的函数。示例如下:

ts 复制代码
let f: () => void;
//     ~~~~~~~~~~
//     函数类型字面量

f = function () { /* no-op */ };

函数类型字面量中的形式参数名与实际函数值中的形式参数名不必相同。例如,下例中函数类型字面量中声明的形式参数名为x,而实际函数值的形式参数名为y:

ts 复制代码
let f: (x: number) => number;

f = function (y: number): number {
    return y;
};

12.8、调用签名

函数在本质上是一个对象,但特殊的地方在于函数是可调用的对象。因此,可以使用对象类型来表示函数类型。若在对象类型中定义了调用签名类型成员,那么我们称该对象类型为函数类型。调用签名的语法如下所示:

ts 复制代码
{
    (ParameterList): Type
}

在该语法中,ParameterList表示函数形式参数列表类型,Type表示函数返回值类型,两者都是可选的。

下例中,我们使用对象类型字面量和调用签名定义了一个函数类型,该函数类型接受两个number类型的参数,并返回number类型的值:

ts 复制代码
let add: { (x: number, y: number): number };

add = function (x: number, y: number): number {
    return x + y;
};

实际上,上一节介绍的函数类型字面量完全等同于仅包含一个类型成员并且是调用签名类型成员的对象类型字面量。换句话说,函数类型字面量是仅包含单个调用签名的对象类型字面量的简写形式,如下所示:

ts 复制代码
 ParameterList ): Type }

//简写为:

arameterList ) => Type

例如,​"Math.abs()"是一个内置函数,它接受一个数字参数并返回该参数的绝对值。下面,我们分别使用函数类型字面量和带有调用签名的对象类型字面量来定义"Math.abs()"函数的类型:

ts 复制代码
const abs0: (x: number) => number = Math.abs;

const abs1: { (x: number): number } = Math.abs;

abs0(-1) === abs1(-1);  // true

函数类型字面量的优点是简洁,而对象类型字面量的优点是具有更强的类型表达能力。我们知道函数是一种对象,因此函数可以拥有自己的属性。下例中,函数f除了可以被调用以外,还提供了一个version属性:

ts 复制代码
function f(x: number) {
    console.log(x);
}

f.version = '1.0';

f(1); // 1
f.version;   // '1.0'

若使用函数类型字面量,则无法描述string类型的version属性,因此也就无法准确地描述函数f的类型。示例如下:

ts 复制代码
function f(x: number) {
    console.log(x);
}
f.version = '1.0';

let foo: (x: number) => void = f;

const version = foo.version;
//                  ~~~~~~~
//                  编译错误:'(x: number) => void' 类型
//                  上不存在 'version' 属性

12.9、构造函数类型字面量

在面向对象编程中,构造函数是一类特殊的函数,它用来创建和初始化对象。JavaScript中的函数可以作为构造函数使用,在调用构造函数时需要使用new运算符。例如,我们可以使用内置的Date构造函数来创建一个日期对象,示例如下:

ts 复制代码
const date = new Date();

构造函数类型字面量是定义构造函数类型的方法之一,它能够指定构造函数的参数类型、返回值类型以及将在6.1节中介绍的泛型类型参数。构造函数类型字面量的具体语法如下所示:

ts 复制代码
new ( ParameterList ) => Type

在该语法中,new是关键字,ParameterList表示可选的构造函数形式参数列表类型,Type表示构造函数返回值类型。

JavaScript提供了一个内置的Error构造函数,它接受一个可选的message作为参数并返回新创建的Error对象。示例如下:

ts 复制代码
const a = new Error();
const b = new Error('Error message.');

我们可以使用如下构造函数类型字面量来表示Error构造函数的类型。该构造函数有一个可选参数message并返回Error类型的对象。示例如下:

ts 复制代码
let ErrorConstructor: new (message?: string) => Error;

12.10、构造签名

构造签名的用法与调用签名类似。若在对象类型中定义了构造签名类型成员,那么我们称该对象类型为构造函数类型。构造签名的语法如下所示:

ts 复制代码
{
    new (ParameterList): Type
}

在该语法中,new是运算符关键字,ParameterList表示构造函数形式参数列表类型,Type表示构造函数返回值类型,两者都是可选的。

下例中,我们使用对象类型字面量和构造签名定义了一个构造函数类型,该构造函数接受一个string类型的参数,并返回新创建的对象:

ts 复制代码
let Dog: { new (name: string): object };

Dog = class {
    private name: string;
    constructor(name: string) {
        this.name = name;
    }
};

let dog = new Dog('huahua');

此例中,Dog的类型为构造函数类型,它接受一个string类型的参数并返回object类型的值。

构造函数类型字面量完全等同于仅包含一个类型成员并且是构造签名类型成员的对象类型字面量。换句话说,构造函数类型字面量是仅包含单个构造签名的对象类型字面量的简写形式,如下所示:

ts 复制代码
{ new ( ParameterList ): Type }

// 简写为:

new ( ParameterList ) => Type

12.11、调用签名与构造签名

有一些函数被设计为既可以作为普通函数使用,同时又可以作为构造函数来使用。例如,JavaScript内置的"Number()"函数和"String()"函数等都属于这类函数。示例如下:

ts 复制代码
const a: number = Number(1);

const b: Number = new Number(1);

若在对象类型中同时定义调用签名和构造签名,则能够表示既可以被直接调用,又可以作为构造函数使用的函数类型。示例如下:

ts 复制代码
{
    new (x: number): Number;  // <- 构造签名
    (x: number): number;      // <- 调用签名
}

此例中,对象类型字面量定义了一个构造签名"new (x: number):Number;"​,它接受一个number类型的参数,并返回Number类型的值。同时,该对象类型字面量还定义了一个调用签名"(x: number):number;"​,它接受一个number类型的参数,并返回number类型的值。示例如下:

ts 复制代码
declare const F: {
    new (x: number): Number;  // <- 构造签名
    (x: number): number;      // <- 调用签名
};

// 作为普通函数调用
const a: number = F(1);

// 作为构造函数调用
const b: Number = new F(1);

12.12、重载函数

重载函数是指一个函数同时拥有多个同类的函数签名。例如,一个函数拥有两个及以上的调用签名,或者一个构造函数拥有两个及以上的构造签名。当使用不同数量和类型的参数调用重载函数时,可以执行不同的函数实现代码。

TypeScript中的重载函数与其他编程语言中的重载函数略有不同。首先,让我们看一个重载函数的例子。下例中定义了一个重载函数add。它接受两个参数,若两个参数的类型为number,则返回它们的和;若两个参数的类型为数组,则返回合并后的数组。在调用add函数时,允许使用这两个调用签名之一并且能够得到正确的返回值类型。示例如下:

ts 复制代码
function add(x: number, y: number): number;
function add(x: any[], y: any[]): any[];
function add(x: number | any[], y: number | any[]): any {
    if (typeof x === 'number' && typeof y === 'number') {
        return x + y;
    }
    if (Array.isArray(x) && Array.isArray(y)) {
        return [...x, ...y];
    }
}

const a: number = add(1, 2);
const b: number[] = add([1], [2]);

在使用函数声明定义函数时能够定义重载函数。重载函数的定义由以下两部分组成:

  • 一条或多条函数重载语句。
  • 一条函数实现语句。
12.12.1、函数重载

不带有函数体的函数声明语句叫作函数重载。例如,下例中的add函数声明没有函数体,因此它属于函数重载:

ts 复制代码
function add(x: number, y: number): number;

函数重载的语法中不包含函数体,它只提供了函数的类型信息。函数重载只存在于代码编译阶段,在编译生成JavaScript代码时会被完全删除,因此在最终生成的JavaScript代码中不包含函数重载的代码。

函数重载允许存在一个或多个,但只有多于一个的函数重载才有意义,因为若只有一个函数重载,则可以直接定义函数实现。在函数重载中,不允许使用默认参数。函数重载应该位于函数实现(将在下一节中介绍)之前,每一个函数重载中的函数名和函数实现中的函数名必须一致。例如,下例中第1行和第2行分别定义了两个函数重载,第3行是函数实现。它们具有相同的函数名add,并且每一个函数重载都位于函数实现之前。示例如下:

ts 复制代码
function add(x: number, y: number): number;
function add(x: any[], y: any[]): any[];
function add(x: number | any[], y: number | any[]): any {
    // 省略了实现代码
}

同时需要注意,在各个函数重载语句之间以及函数重载语句与函数实现语句之间不允许出现任何其他语句,否则将产生编译错误。示例如下:

ts 复制代码
function add(x: number, y: number): number;

const a = 0; // <-- 编译错误

function add(x: any[], y: any[]): any[];

const b = 0; // <-- 编译错误

function add(x: number | any[], y: number | any[]): any {
    // 省略了实现代码
}
12.12.2、函数实现

函数实现包含了实际的函数体代码,该代码不仅在编译时存在,在编译生成的JavaScript代码中同样存在。每一个重载函数只允许有一个函数实现,并且它必须位于所有函数重载语句之后,否则将产生编译错误。示例如下:

ts 复制代码
function add(x: number, y: number): number;
function add(x: any[], y: any[]): any[];

// 函数实现必须位于最后
function add(x: number | any[], y: number | any[]): any {
    // 省略了实现代码
}

TypeScript中的重载函数最令人迷惑的地方在于,函数实现中的函数签名不属于重载函数的调用签名之一,只有函数重载中的函数签名能够作为重载函数的调用签名。例如,下例中的add函数只有两个调用签名,分别为第1行与第2行定义的两个重载签名,而第3行函数实现中的函数签名不是add函数的调用签名,如下所示:

ts 复制代码
function add(x: number, y: number): number;
function add(x: any[], y: any[]): any[];
function add(x: number | any[], y: number | any[]): any {
    // 省略了实现代码
}

因此,我们可以使用两个number类型的值来调用add函数,或者使用两个数组类型的值来调用add函数。但是,不允许使用一个number类型和一个数组类型的值来调用add函数,尽管在函数实现的函数签名中允许这种调用方式。示例如下:

ts 复制代码
// 正确的调用方式
add(1, 2);
add([1], [2]);

// 错误的调用方式
add(1, [2]);
add([1], 2);

函数实现需要兼容每个函数重载中的函数签名,函数实现的函数签名类型必须能够赋值给函数重载的函数签名类型。示例如下:

ts 复制代码
function foo(x: number): boolean;
//       ~~~
//       编译错误:重载签名与实现签名的返回值类型不匹配
function foo(x: string): void;
//       ~~~
//       编译错误:重载签名与实现签名的参数类型不匹配
function foo(x: number): void {
    // 省略函数体代码
}

此例中,重载函数foo可能的参数类型为number类型或string类型,同时返回值类型可能为boolean类型或void类型。因此,在函数实现中的参数x必须同时兼容number类型和string类型,而返回值类型则需要兼容boolean类型和void类型。我们可以使用联合类型来解决这些问题,示例如下:

ts 复制代码
function foo(x: number): boolean;
function foo(x: string): void;
function foo(x: number | string): any {
    // 省略函数体代码
}

在其他一些编程语言中允许存在多个函数实现,并且在调用重载函数时编程语言负责选择合适的函数实现执行。在TypeScript中,重载函数只存在一个函数实现,开发者需要在这个唯一的函数实现中实现所有函数重载的功能。这就需要开发者自行去检测参数的类型及数量,并根据判断结果去执行不同的操作。示例如下:

ts 复制代码
function add(x: number, y: number): number;
function add(x: any[], y: any[]): any[];
function add(x: number | any[], y: number | any[]): any {
    if (typeof x === 'number' && typeof y === 'number') {
        return x + y;
    }

    if (Array.isArray(x) && Array.isArray(y)) {
        return [...x, ...y];
    }
}

TypeScript不支持为不同的函数重载分别定义不同的函数实现。从这点上来看,TypeScript中的函数重载不是特别便利。

12.12.3、函数重载解析顺序

当程序中调用了一个重载函数时,编译器将首先构建出一个候选函数重载列表。一个函数重载需要满足如下条件才能成为本次函数调用的候选函数重载:

  • 函数实际参数的数量不少于函数重载中定义的必选参数的数量。
  • 函数实际参数的数量不多于函数重载中定义的参数的数量。
  • 每个实际参数的类型能够赋值给函数重载定义中对应形式参数的类型。

候选函数重载列表中的成员将以函数重载的声明顺序作为初始顺序,然后进行简单的排序,将参数类型中包含字面量类型的函数重载排名提前。示例如下:

ts 复制代码
function f(x: string): void;         // <- 函数重载1
function f(y: 'specialized'): void;  // <- 函数重载2
function f(x: string) {
  // 省略函数体代码
}

f('specialized');

此例第7行,使用字符串参数'specialized'调用重载函数f时,函数重载1和函数重载2都满足候选函数重载的条件,因此两者都在候选函数重载列表中。但是因为函数重载2的函数签名中包含字面量类型,所以比函数重载1的优先级更高。最终,构造出来的有序候选函数重载列表如下:

  1. 函数重载2:"function f(y: 'specialized'): void;"
  2. 函数重载1:"function f(x: string): void;"。

若候选函数重载列表中存在一个或多个函数重载,则使用列表中第一个函数重载。因此,此例中将使用函数重载2。

如果构建的候选函数重载列表为空列表,则会产生编译错误。例如,当使用number类型的参数调用此例中的函数f时不存在候选函数重载,因此会产生编译错误,如下所示:

ts 复制代码
f(1);                         // 编译错误

通过以上的介绍我们能够知道,函数重载的解析顺序依赖于函数重载的声明顺序以及函数签名中是否包含字面量类型。因此,TypeScript中的函数重载功能可能没有其他一些编程语言那么"智能"​。这就要求开发者在编写函数重载代码时一定要将最精确的函数重载定义放在最前面,因为它们定义的顺序将影响函数调用签名的选择。示例如下:

ts 复制代码
function f(x: any): number;   // <- 函数重载1
function f(x: string): 0 | 1; // <- 函数重载2
function f(x: any): any {
    // ...
}

const a: 0 | 1 = f('hi');
//    ~
//    编译错误!类型 'number' 不能赋值给类型 '0 | 1'

此例中,函数重载2比函数重载1更加精确,但函数重载2是在函数重载1之后定义的。由于函数重载2的参数中不包含字面量类型,因此编译器不会对候选函数重载列表进行重新排序。第7行,当使用字符串调用函数f时,函数重载1位于候选函数重载列表的首位,并被选为最终使用的函数重载。我们能看到"f('hi')"的返回值类型为number类型,而不是更精确的"0 | 1"联合类型。若想要修复这个问题,只需将函数重载1和函数重载2的位置互换即可。示例如下:

ts 复制代码
function f(x: string): 0 | 1;
function f(x: any): number;
function f(x: any): any {
    // ...
}

const a: 0 | 1 = f('hi');  // 正确

到这里,我们已经介绍了重载函数的大部分功能。因为TypeScript语言的自身特点,所以它提供的函数重载功能可能不如其他编程语言那样便利。实际上在很多场景中我们并不需要声明重载函数,尤其是在函数返回值类型不变的情况下。示例如下:

ts 复制代码
function foo(x: string): boolean;
function foo(x: string, y: number): boolean;
function foo(x: string, y?: number): boolean {
  // ...
}

const a = foo('hello');
const b = foo('hello', 2);


function bar(x: string, y?: number): boolean {
  // ...
}

const c = bar('hello');
const d = bar('hello', 1);

此例中,foo函数是重载函数,而bar函数则为普通函数声明。两个函数在功能上以及可接受的参数类型和函数返回值类型都是相同的。但是,bar函数的声明代码更少也更加清晰。

12.12.4、重载函数的类型

重载函数的类型可以通过包含多个调用签名的对象类型来表示。例如,有以下重载函数定义:

ts 复制代码
function f(x: string): 0 | 1;
function f(x: any): number;
function f(x: any): any {
    // ...
}

我们可以使用如下对象类型字面量来表示重载函数f的类型。在该对象类型字面量中,定义了两个调用签名类型成员,分别对应于重载函数的两个函数重载。示例如下:

ts 复制代码
{
    (x: string): 0 | 1;
    (x: any): number;
}

在定义重载函数的类型时,有以下两点需要注意:

  • 函数实现的函数签名不属于重载函数的调用签名之一。
  • 调用签名的书写顺序是有意义的,它决定了函数重载的解析顺序,一定要确保更精确的调用签名位于更靠前的位置。
    对象类型字面量以及后面会介绍的接口都能够用来定义重载函数的类型,但是函数类型字面量无法定义重载函数的类型,因为它只能够表示一个调用签名。

12.13、函数中this值的类型

在默认情况下,编译器会将函数中的this值设置为any类型,并允许程序在this值上执行任意的操作。因为,编译器不会对any类型进行类型检查。例如,下例中在this值上的所有访问操作都是允许的:

ts 复制代码
function f() {
    // 以下语句均没有错误
    this.a = true;
    this.b++;
    this.c = () => {};
}

--noImplicitThis

将this值的类型设置为any类型对类型检查没有任何帮助。因此,TypeScript提供了一个"--noImplicitThis"编译选项。当启用了该编译选项时,如果this值默认获得了any类型,那么将产生编译错误;如果函数体中没有引用this值,则没有任何影响。示例如下:

ts 复制代码
/**
 * --noImplicitThis=true
 */
function f0() {
    this.a = true;     // 编译错误
    this.b++;          // 编译错误
    this.c = () => {}; // 编译错误
}

// 没有错误
function f1() {
    const a = true;
}

函数中this值的类型可以通过一个特殊的this参数来定义。下面我们将介绍这个特殊的this参数。

函数的this参数

TypeScript支持在函数形式参数列表中定义一个特殊的this参数来描述该函数中this值的类型。示例如下:

ts 复制代码
function foo(this: { name: string }) {
    this.name = 'Patrick';

    this.name = 0;
//  ~~~~~~~~~
//  编译错误!类型 0 不能赋值给类型 'string'
}

当调用定义了this参数的函数时,若this值的实际类型与函数定义中的期望类型不匹配,则会产生编译错误。示例如下:

ts 复制代码
function foo(this: { bar: string }, baz: number) {
    // ...
}

// 编译错误
// 'this'类型为'void',不能赋值给 '{ bar: string }' 类型的this
foo(0);

foo.call({ bar: 'hello' }, 0); // 正确

此例第1行,将foo函数this值的类型设置为对象类型"{ bar: string}"​。第7行,调用foo函数时this值的类型为void类型,它与期望的类型不匹配,因此产生编译错误。第9行,在调用foo函数时指定了this值为"{ bar: 'hello' }"​,其类型符合this参数的类型定义,因此不会产生错误。​"Function.prototype.call()"方法是JavaScript内置的 方法,它能够指定调用函数时使用的this值。

相关推荐
vvw&2 小时前
如何从 Ubuntu 24.04 升级到 Ubuntu 25.04
linux·运维·服务器·ubuntu
tobebetter95272 小时前
Debian / Ubuntu openclaw 浏览器自动化
ubuntu·自动化·debian
暴力求解2 小时前
Linux---进程池
linux·运维·服务器
嵌入小生0073 小时前
ARM --- 基础知识汇总
linux·嵌入式·arm·指令集
IMPYLH3 小时前
Linux 的 du 命令
linux·运维·服务器
!chen4 小时前
[Linux][虚拟串口]0x03一个特殊的字节
linux·运维·服务器
林姜泽樾4 小时前
Linux入门第十七章,systemctl软件启动和软连接
linux·运维·服务器·centos
济6174 小时前
ARM Linux 驱动开发篇--- Linux 并发与竞争实验(自旋锁实现 LED 设备互斥访问)--- Ubuntu20.04自旋锁实验
linux·嵌入式·嵌入式linux驱动开发
艾莉丝努力练剑4 小时前
alarm系统调用的一次性原理揭秘
linux·运维·服务器·开发语言·网络·人工智能·学习