彻底掌握 TypeScript——核心类型与类型注解

0 阅读50分钟

现在,我们已经讲完了 TypeScript 中“大部分为什么”的问题,也该开始进入“怎么做”的部分了。本章将介绍一些关键概念,例如类型注解(type annotations)类型推断(type inference) ,以及如何开始编写**类型安全(type-safe)**的函数。

打好一个扎实的基础非常重要,因为你后续学到的几乎所有内容,都会建立在本章所讲的内容之上。

类型注解

作为一名 TypeScript 开发者,你最常做的一件事之一,就是给代码添加类型注解。类型注解会告诉 TypeScript:某个值应该是什么类型。注解通常会使用一个冒号(:),它的作用就是告诉 TypeScript,某个变量或函数参数属于某种特定类型。

基础类型

TypeScript 提供了若干种基础类型,可以用来给代码添加注解。下面是其中一些最常见的:

let example1: string = "Hello World!";
let example2: number = 42;
let example3: boolean = true;
let example4: symbol = Symbol();
let example5: bigint = 123n;
let example6: null = null;
let example7: undefined = undefined;

这些类型中的每一种,都是为了告诉 TypeScript:某个变量或函数参数应该是什么类型。像 stringbooleannumber 这样的基础类型比较常见;但 TypeScript 也支持一些你可能不太熟悉的 JavaScript 特性对应的类型,比如 bigintsymbol。它还为 nullundefined 分别提供了单独的类型。

当然,在 TypeScript 中你还可以表达远比这复杂的类型,比如数组、对象、函数等等(这些内容我们会在后面的章节中介绍)。

函数参数注解

你会使用到的最重要的一类注解之一,就是函数参数注解。例如,下面有一个 logAlbumInfo 函数,它接收一个标题字符串、一个曲目数数字,以及一个表示是否已发布的布尔值:

const logAlbumInfo = (
  title: string,

  trackCount: number,

  isReleased: boolean,
) => {
  // 实现部分
};

每个参数上的类型注解,都会让 TypeScript 检查传入函数的参数类型是否正确。如果类型不匹配,TypeScript 就会在出问题的参数下方显示红色波浪线:

logAlbumInfo("Black Gold", false, 15); // false 下方先报错,然后 15 下方也会报错

在上面的例子中,首先会在 false 下方出现错误,因为布尔值不能传到本应是数字的位置上。TypeScript 会把这类错误描述为“不能赋值给(not assignable to) ”某种类型。

如果你把它修正成这样:

logAlbumInfo("Black Gold", 20, 15); // 15 下方仍然会报错

那么 15 下方依然会报错,因为数字同样不能赋值给布尔类型。

变量注解

和函数参数一样,你也可以给变量添加注解。变量注解的作用是:显式地告诉 TypeScript,你期望这些变量应该是什么类型。来看下面这个例子:

let albumTitle: string = "Midnights";
let isReleased: boolean = true;
let trackCount: number = 13;

注意,每个变量名后面,都会先跟一个 : 和它的基础类型,然后才是赋值。

一旦某个变量被声明为带有特定类型注解,TypeScript 就会保证这个变量之后始终与该类型兼容。比如,下面这种重新赋值是合法的:

let albumTitle: string = "Midnights";

albumTitle = "1989"; // albumTitle 被重新赋值,不会报错

但下面这种就会报错:

let isReleased: boolean = true;

isReleased = "yes"; // isReleased 下方会出现红色波浪线

TypeScript 的静态类型检查能够在编译阶段发现错误,而这个过程实际上是在你编写代码时就在后台持续发生的。

在前面那个 isReleased 的例子里,错误信息是这样的:

Type 'string' is not assignable to type 'boolean'.

换句话说,TypeScript 的意思是:它本来期望 isReleased 是一个布尔值,但你却给了它一个字符串。能在代码运行之前就收到这样的警告,确实很有帮助。

类型推断

TypeScript 允许你为代码中的几乎任意值、变量或函数添加类型注解。你可能会想:“等等,那是不是意味着我得给所有东西都写上注解?那也太多额外代码了吧。”不用担心。在很多情况下,TypeScript 足够聪明,可以在没有显式注解的地方自动推断出类型。这就叫做类型推断(type inference)

变量并不总是需要注解

我们再来看一次刚才的变量例子,不过这次把注解都去掉:

let albumTitle = "Midnights";
let isReleased = true;
let trackCount = 13;

我们没有写任何注解,但 TypeScript 也没有报错。这是怎么回事?

试着把鼠标悬停到每个变量上,你会看到下面这些信息:

// 悬停在每个变量名上会显示:

let albumTitle: string;
let isReleased: boolean;
let trackCount: number;

虽然它们没有显式注解,但 TypeScript 依然能够推断出每个变量的类型。

TypeScript 仍然会像这些变量已经写过注解一样工作:如果你试图把变量重新赋成和原始类型不同的值,它就会发出警告。比如,isReleased 一开始被赋值为布尔值 true,如果你之后试图把它改成字符串,TypeScript 就会报错:

let isReleased = true;
isReleased = "yes"; // isReleased 下方会出现红色波浪线
// 悬停在 isReleased 上会显示:
Type 'string' is not assignable to type 'boolean'.

当 TypeScript 推断出一个变量的类型之后,它还会为该类型对应的方法提供自动补全。比如,在这个例子里,因为 albumTitle 被推断为字符串,所以你输入 albumTitle.toUpper 时,就会自动补全出 toUpperCase

这是 TypeScript 极其强大的一点。它意味着:大多数时候,你并不需要给变量显式写类型注解,但 IDE 依然知道它们是什么类型。

函数参数始终需要注解

不过,类型推断并不是在任何地方都有效。来看一下,如果你把 logAlbumInfo 函数参数上的类型注解去掉,会发生什么:

const logAlbumInfo = (
  title, // 错误:Parameter 'title' implicitly has an 'any' type.
  trackCount, // 错误:Parameter 'trackCount' implicitly has an 'any' type.
  isReleased, // 错误:Parameter 'isReleased' implicitly has an 'any' type.
) => {
  // 函数体剩余部分
};

如果函数参数没有类型注解,TypeScript 就会开始报错。这是因为,函数和变量非常不同。对于变量,TypeScript 能看到它被赋了什么值,因此可以比较可靠地猜测其类型;但对于函数参数,单看参数本身,TypeScript 并不知道它应该接收什么类型。

它也无法单纯从函数的使用方式中判断出类型。比如你有一个 add 函数,它接收两个参数,TypeScript 并不能因此就知道这两个参数应该是数字:

function add(a, b) {
  return a + b;
}

这里的 ab 也可能是字符串、布尔值,或者别的任何类型。TypeScript 无法仅根据函数体判断出它们应该是什么类型。

所以,尽管类型推断在大多数场景下都很好用,但函数参数是一个例外。在 TypeScript 中,你通常需要为函数参数添加显式注解。

注意
这个规则也有少数例外。比如说,你正在遍历一个字符串数组,并把一个函数传给 .map;这个函数会接收当前遍历到的字符串作为参数:

const strings = ['a', 'b'];
strings.map(str => str.toUppercase());

在这个上下文里,参数 str 不需要写注解,因为 TypeScript 能够推断出它是一个字符串。相当聪明!

any 类型

你刚才在 logAlbumInfo 函数参数里遇到的那个错误,看起来可能有点吓人:

Parameter 'title' implicitly has an 'any' type.

当 TypeScript 不知道某个东西的类型是什么时,它就会把它赋成 any 类型

any 会直接关闭 TypeScript 的类型系统。它会把赋给它的那个值上的类型安全彻底禁用掉——这意味着,任何值都可以赋给它、你可以访问或赋值它身上的任意属性,它甚至还能像函数一样被调用:

let anyVariable: any = "This can be anything!";

anyVariable(); // 不报错

anyVariable.deep.property.access; // 不报错

这段代码在运行时其实会报错,但 TypeScript 却不会给你任何警告!

你可以用 any 来关闭 TypeScript 的报错机制,这在某些场景下会是个有用的“逃生舱”,比如某个类型实在太复杂、很难描述的时候;它在把老旧的 JavaScript 代码库迁移到 TypeScript 时也很有帮助。

但如果你过度使用 any,那就等于把使用 TypeScript 的意义给抵消掉了,因此最好尽量避免它。

练习 4-1:函数参数中的基础类型

我们先看一个 add 函数,它接收两个布尔类型参数 ab,并返回 a + b

export const add = (a: boolean, b: boolean) => {
  return a + b; // a + b 下方会出现红色波浪线
};

调用这个 add 函数时,会生成一个 result 变量,然后再检查它是否等于一个数字类型:

const result = add(1, 2); // 1 下方会出现红色波浪线

type test = Expect<Equal<typeof result,
  number>>; // Equal 到 number 整段下方都会报错

这里你在用 @totaltypescript/helpers 这个库中的两个类型工具 ExpectEqual,把它们当作练习里的“测试”。在这个例子里,你期望 resultnumber 类型。

目前代码里有几处红色波浪线。第一处出现在 add 函数的返回语句中,也就是 a + b 这里:

// 悬停在 a + b 上会显示:

Operator '+' cannot be applied to types 'boolean' and 'boolean'

此外,在调用 add 函数时,参数 1 后面也有错误:

Argument of type 'number' is not assignable to parameter of type 'boolean'

最后,你会看到测试结果也报错了,因为现在 result 被推断成了 any,而这并不等于 number

你的任务是思考:应该如何修改这些类型,才能让所有错误消失,并确保 result 最终是一个数字。可以悬停在 result 上查看它的类型。

参见 https://totalts.link/essentials-4-1/

解答

常识会告诉你:add 函数里的布尔类型显然应该改成某种数字类型。如果你来自其他编程语言,可能会下意识地想到 intfloat,但 TypeScript 里只有一个统一的数字类型:number

function add(a: number, b: number) {
  return a + b;
}

这样一改,错误就都消失了,而且还带来了一些额外好处。

比如,如果你试图给 add 传入字符串而不是数字,TypeScript 就会报错,提示 string 不能赋值给 number

add("something", 2); // something 下方会出现红色波浪线

不仅如此,函数的返回值类型现在也会被自动推断出来:

const result = add(1, 2); // result 会被推断为 number!

TypeScript 不仅能推断变量类型,也能推断函数的返回类型。

练习 4-2:为“空参数”添加注解

在这个练习中,你有一个 concatTwoStrings 函数,它在结构上和前面的 add 函数很相似。它接收两个参数 ab,并返回一个字符串:

const concatTwoStrings = (a, b) => { // a 和 b 下方会出现红色波浪线
  return [a, b].join(" ");
};

参数 ab 都有错误,因为它们还没有被添加类型注解:

Parameter 'a' implicitly has an 'any' type.

Parameter 'b' implicitly has an 'any' type.

而调用 concatTwoStrings("Hello", "World") 之后,再检查它的结果是否为字符串时,并没有报错:

const result = concatTwoStrings("Hello", "World");

type test = Expect<Equal<typeof result, string>>;

你的任务是:给 concatTwoStrings 的参数添加函数参数注解,让这些错误消失。

参见 https://totalts.link/essentials-4-2/

解答

正如前面提到的,函数参数在 TypeScript 中始终需要注解。既然如此,这里就应该把函数声明里的 ab 都明确标注为 string

const concatTwoStrings = (a: string, b: string) => {
  return [a, b].join(" ");
};

这样改完之后,错误就被修复了。

额外再想一步:这个函数的返回类型会被推断成什么?

const result = concatTwoStrings("Hello", "World"); // result 会被推断为 string!

练习 4-3:基础类型

正如你已经看到的,当类型不匹配时,TypeScript 会报错。下面这组例子展示了 TypeScript 用来描述 JavaScript 的那些基础类型:

export let example1: string = "Hello World!";
export let example2: string = 42; // example2 下方会出现红色波浪线
export let example3: string = true; // example3 下方会出现红色波浪线
export let example4: string = Symbol(); // example4 下方会出现红色波浪线
export let example5: string = 123n; // example5 下方会出现红色波浪线

你会注意到,这里有好几处错误。把鼠标悬停在每个带下划线的变量上,就会看到对应的错误信息。例如,悬停在 example2 上时,会显示:

Type 'number' is not assignable to type 'string'.

example3 的类型错误则是:

Type 'boolean' is not assignable to type 'string'.

请把每个变量上的类型注解改正确,让这些错误都消失。

参见 https://totalts.link/essentials-4-3/

解答

下面这些例子分别对应 TypeScript 的几种基础类型,正确的注解写法如下:

let example1: string = "Hello World!";
let example2: number = 42;
let example3: boolean = true;
let example4: symbol = Symbol();
let example5: bigint = 123n;

你已经见过 stringnumberboolean 了。symbol 类型用于表示符号(symbol),它常被用来确保对象属性键的唯一性。bigint 类型则用于表示 JavaScript 的大整数。

注意
JavaScript 在数字大小上有一些限制。大于 Number.MAX_SAFE_INTEGER 的数值,已经不能可靠地进行加法运算了。bigint 正是为了解决这个问题,它允许我们在 JavaScript 中表示更大的整数。

不过,在实际开发中,你通常并不会像这样手动给变量一个个写基础类型注解。因为如果你把显式注解删掉,TypeScript 其实根本不会报错:

let example1 = "Hello World!";
let example2 = 42;
let example3 = true;
let example4 = Symbol();
let example5 = 123n;

这些基础类型依然值得掌握,即便你并不总需要在变量声明里显式写出它们。

练习 4-4:any 类型

下面这个名为 handleFormData 的函数,接收一个类型为 any 的参数 e。它会先阻止表单的默认提交行为,然后从表单数据中创建一个对象并将其返回:

const handleFormData = (e: any) => {
  e.preventDefault();

  const data = new FormData(e.terget);

  const value = Object.fromEntries(data.entries());

  return value;
};

这里还有一个针对该函数的测试。测试中会创建一个表单,设置它的 innerHTML 来插入一个输入框,然后手动提交表单。提交时,你期望返回的值应该等于你注入表单里的那个值:

it("Should handle a form submit", () => {
  const form = document.createElement("form");

  form.innerHTML = `

`;

  form.onsubmit = (e) => {
    const value = handleFormData(e);

    expect(value).toEqual({name: "John Doe"});
  };

  form.requestSubmit();

  expect.assertions(1);
});

这当然不是你平时测试表单的常规方式,但它提供了一个更充分地测试示例函数 handleFormData 的机会。

在当前这段代码里,并没有任何红色波浪线。然而,当你使用 Vitest 运行测试时,却会看到类似下面的错误:

This error originated in "any.problem.ts" test file.
It doesn't mean the error was thrown inside the file itself, but while it was running.
The latest test that might've caused the error is "Should handle a form submit".
It might mean one of the following:
- The error was thrown, while Vitest was running this test.
- This was the last recorded test before the error was thrown,
if error originated after test finished its execution.

为什么会发生这个错误?为什么 TypeScript 没有在这里给你报错?

给你一个提示:代码里藏了一个很恶劣的拼写错误。你能修好吗?

参见 https://totalts.link/essentials-4-4/

解答

在这个例子里,使用 any 完全没有帮上忙。事实上,any 似乎反而把类型检查给彻底关掉了!一旦某个值被标成 any,你几乎就可以对它做任何事,而 TypeScript 都不会阻止你。与此同时,any 也会关闭自动补全等有用功能,而这些功能本来恰恰可以帮助你避免拼写错误。

没错,这道练习里的 bug 就是因为拼错了:在创建 FormData 时,写成了 e.terget,而正确的应该是 e.target

const handleFormData = (e: any) => {
  e.preventDefault();

  const data = new FormData(e.terget); // e.terget!拼错了!

  const value = Object.fromEntries(data.entries());

  return value;
};

如果 e 被正确地标注了类型,TypeScript 会立即把这个错误揪出来。后面我们还会回到这个例子,看看正确的类型写法应该是什么。

当你一时搞不清某个值该怎么正确标类型时,any 看起来像是一个快速解法,但它往往会在后面反咬你一口。

对象字面量类型

现在我们已经讲完了基础类型,接下来进入对象类型。对象类型用于描述对象的“形状(shape)”。对象中的每个属性,都可以拥有自己的类型注解。定义对象类型时,你需要使用花括号,把属性及其类型包在里面:

const talkToAnimal = (animal: {name: string; type: string; age: number}) => {
  // 函数体剩余部分
};

这种使用花括号来描述对象形状的写法,叫做对象字面量类型(object literal type)

可选对象属性

你可以使用 ? 运算符,把 age 属性标记为可选:

const talkToAnimal = (animal: {name: string; type: string; age?: number}) => {
  // 函数体剩余部分
};

这意味着,在调用这个函数时,你不一定非要提供 age 属性。

对象字面量类型还有一个很酷的地方在于:当你输入对象属性时,它会提供属性名的自动补全。比如,在调用 talkToAnimal 时,编辑器会弹出自动补全菜单,提示你可以填写 nametypeage 这些属性。这不仅能省很多时间,也能在属性名很多、而且名字彼此相近时,帮你减少拼写错误。

练习 4-5:对象字面量类型

这里有一个 concatName 函数,它接收一个 user 对象,而这个对象带有 firstlast 两个键:

const concatName = (user) => { // user 下方会出现红色波浪线
  return `${user.first} ${user.last}`;
};

测试期望返回完整姓名。因为函数确实返回了完整姓名,所以测试当前是通过的:

it("should return the full name", () => {
  const result = concatName({
    first: "John",
    last: "Doe",
  });

  type test = Expect<Equal<typeof result, string>>;

  expect(result).toEqual("John Doe");
});

不过,在 concatNameuser 参数上,仍然有一个熟悉的错误:

Parameter 'user' implicitly has an 'any' type.

concatName 的函数体中,你可以看出它期望 user.firstuser.last 都是字符串。那么,你该如何为 user 参数标类型,才能确保它具备这些属性,并且它们都是正确的类型呢?

参见 https://totalts.link/essentials-4-5/

解答

要把 user 参数标注成一个对象类型,你可以使用花括号语法。先从把 user 标成一个空对象开始:

const concatName = (user: {}) => {
  return `${user.first} ${user.last}`; // .first 和 .last 下方会出现红色波浪线
};

错误发生了变化,这说明我们已经前进了一步。现在错误出现在函数返回语句里的 .first.last 上:

Property 'first' does not exist on type '{}'.
Property 'last' does not exist on type '{}'.

要修复这些错误,你需要把 firstlast 属性加到类型注解里:

const concatName = (user: {first: string; last: string}) => {
  return `${user.first} ${user.last}`;
};

现在,TypeScript 已经知道 userfirstlast 属性都是字符串,测试也就通过了。

练习 4-6:可选属性类型

下面是更新后的 concatName 函数:如果没有提供姓氏,它就只返回名字:

const concatName = (user: {first: string; last: string}) => {
  if (!user.last) {
    return user.first;
  }

  return `${user.first} ${user.last}`;
};

和前面一样,当你测试“只返回名字”这一场景时,TypeScript 会报错:

it("should return only the first name if last name not provided", () => {
  const result = concatName({
    first: "John",
  }); // {first: "John"} 整个对象下方会出现红色波浪线

  type test = Expect<Equal<typeof result, string>>;

  expect(result).toEqual("John");
});

这一次,整个 {first: "John"} 都被红线标出来了,错误信息如下:

Argument of type '{first: string;}' is not assignable to
  parameter of type '{first: string; last: string;}'.
Property 'last' is missing in type '{first: string;}' but
  required in type '{first: string; last: string;}'.

错误信息告诉你:少了一个属性。但这个错误其实并不符合你的意图,因为你本来就希望支持只包含 first 属性的对象。换句话说,last 应该是可选的。

那么,你该如何更新这个函数,才能把错误修正过来?

参见 https://totalts.link/essentials-4-6/

解答

和给函数参数设置可选一样,你可以使用问号(?)来表示对象属性是可选的:

function concatName(user: {first: string; last?: string}) {
  // 实现部分
}

加上 ?: 就是在告诉 TypeScript:这个属性可以不存在。

如果你把鼠标悬停在函数体中的 last 属性上,会看到它的类型现在变成了 string | undefined

// 悬停在 user.last 上会显示:
(property) last?: string | undefined

这意味着它要么是 string,要么是 undefined。这是一个非常实用的 TypeScript 语法,后面你还会经常见到它。

类型别名

到目前为止,你一直都在内联声明类型。对于这些简单例子来说,这样写没有问题;但在真实应用里,很多类型会在整个项目中反复出现。它们可能是用户、商品,或者其他领域模型类型。你肯定不想在每一个需要它们的文件里都反复写同样的类型定义。

这时候,type 关键字就派上用场了。它允许你定义一次类型,然后在多个地方重复使用:

type Animal = {
  name: string;
  type: string;
  age?: number;
};

这其实就是给某个类型起了一个名字——也就是类型别名(type alias) ——然后你就可以在任何需要用到这个类型的地方复用这个名字。

要创建一个带有 Animal 类型的新变量,只需要在变量名后面把它作为类型注解加上:

let pet: Animal = {
  name: "Karma",
  type: "cat",
};

你也可以在函数里,用 Animal 这个类型别名来代替那一长串对象字面量类型:

const getAnimalDescription = (animal: Animal) => {};

然后再用 pet 去调用这个函数:

const desc = getAnimalDescription(pet);

类型别名不只能表示对象类型,它也可以用于基础类型:

type Id = string | number;

这个语法我们后面会更详细地讲(它叫做联合类型 union type),不过它的基本意思是:Id 可以是字符串,也可以是数字。

使用类型别名是确保某个类型定义拥有**单一真实来源(single source of truth)**的一个好办法,这样未来如果要修改这个类型,也会更轻松。

在模块之间共享类型

类型别名也可以被放到单独的 .ts 文件中,然后在需要它们的文件里导入。这在多个地方共享类型,或者当某个类型定义变得越来越大时,特别有用:

// 在 shared-types.ts 中

export type Animal = {
  width: number;
  height: number;
};

// 在 index.ts 中
import type {Animal} from "./shared-types";

按照一种常见约定,你甚至可以专门创建自己的 .types.ts 文件,这有助于把类型定义和其他代码分开管理。

练习 4-7:type 关键字

下面这段代码在多个地方使用了相同的类型:

const getRectangleArea = (rectangle: {width: number; height: number}) => {
  return rectangle.width * rectangle.height;
};

const getRectanglePerimeter = (rectangle: {
  width: number;
  height: number;
}) => {
  return 2 * (rectangle.width + rectangle.height);
};

getRectangleAreagetRectanglePerimeter 这两个函数都接收一个 rectangle 对象,而这个对象都有 widthheight 两个属性。

这两个函数对应的测试目前都可以正常通过:

it("should return the area of a rectangle", () => {
  const result = getRectangleArea({
    width: 10,
    height: 20,
  });

  type test = Expect<Equal<typeof result, number>>;

  expect(result).toEqual(200);
});

it("should return the perimeter of a rectangle", () => {
  const result = getRectanglePerimeter({
    width: 10,
    height: 20,
  });

  type test = Expect<Equal<typeof result, number>>;

  expect(result).toEqual(60);
});

虽然现在一切都能工作,但这里显然有一个很适合做重构的机会,可以让代码更整洁。你该如何使用 type 关键字,让这段代码更易读?

参见 https://totalts.link/essentials-4-7/

解答

你可以使用 type 关键字,定义一个带有 widthheight 属性的 Rectangle 类型:

type Rectangle = {
  width: number;
  height: number;
};

有了这个类型别名之后,就可以把 getRectangleAreagetRectanglePerimeter 两个函数更新为使用 Rectangle 类型:

const getRectangleArea = (rectangle: Rectangle) => {
  return rectangle.width * rectangle.height;
};

const getRectanglePerimeter = (rectangle: Rectangle) => {
  return 2 * (rectangle.width + rectangle.height);
};

这样一来,代码就简洁多了,而且 Rectangle 类型也拥有了一个明确的单一来源。

数组

在 TypeScript 中,你也可以描述数组的类型。这里有两种不同的写法。

第一种是方括号语法。它和你前面见过的类型注解写法类似,只不过会在类型后面再加上一对方括号([]),表示“这是一个数组”:

let albums: string[] = [  "Rubber Soul",  "Revolver",  "Sgt. Pepper's Lonely Hearts Club Band",];

let dates: number[] = [1965, 1966, 1967];

第二种写法,则是显式地使用 Array 类型,并在尖括号(< >)里写入数组所包含的数据类型:

let albums: Array<string> = [
  "Rubber Soul",
  "Revolver",
  "Sgt. Pepper's Lonely Hearts Club Band",
];

这两种语法是完全等价的。不过,在创建数组类型时,方括号语法通常更简洁一些;TypeScript 的错误信息里,也更常使用这种写法。

当然,也先记住尖括号写法,后面这一章里你还会再次见到它。

对象数组

在给“对象数组”标类型时,你有几种不同选择。你可以直接内联写出对象类型:

type ArrayOfAlbums = {artist: string; title: string; year: number}[]

也可以先定义一个类型别名,然后再用 Array<> 包起来,或者在后面追加方括号:

type Album = {artist: string; title: string; year: number}
type ArrayOfAlbums = Array<Album>; // 写法 1
type ArrayOfAlbums = Album[]; // 写法 2

最后,你就可以像下面这样使用这个类型:

// 创建一个 Album 数组
let selectedDiscography: ArrayOfAlbums = [
  {
    artist: "The Beatles",
    title: "Rubber Soul",
    year: 1965,
  },
  {
    artist: "The Beatles",
    title: "Revolver",
    year: 1966,
  },
];

如果你试图往这个数组里添加一个不符合类型定义的项,TypeScript 就会报错:

selectedDiscography.push({name: "Karma", type: "cat"}); // name 下方会出现红色波浪线

错误信息如下:

// error message:
// Argument of type '{name: string; type: string;}' is not
// assignable to parameter of type 'Album'.

元组

元组(tuple) 允许你描述一种“长度固定的数组”,并且数组中的每个位置都可以拥有各自独立的类型。创建元组的写法和数组的方括号语法很像,不同之处在于:元组的方括号中写的是元素类型本身,而不是把方括号加在变量类型后面。

// Tuple
let album: [string, number] = ["Rubber Soul", 1965];

// Array
let albums: string[] = [
  "Rubber Soul",
  "Revolver",
  "Sgt. Pepper's Lonely Hearts Club Band",
];

元组很适合把一些相关信息打包在一起,而不需要专门再去创建一个新的对象类型。比如,如果你想把一个专辑和它的播放次数放在一起,可以这样写:

let albumWithPlayCount: [Album, number] = [
  {
    artist: "The Beatles",
    title: "Revolver",
    year: 1965,
  },
  10000,
];

这样一来,albumWithPlayCount 这个元组就可以像数组那样按索引访问:索引 0 位置是 Album,索引 1 位置是播放次数。

带名字的元组

为了让元组更清晰一些,你还可以在方括号里为每个位置上的类型加上名称:

type MyTuple = [album: Album, playCount: number];

当元组元素很多,或者你希望代码更易读时,这会很有帮助。

注意
这些元组里的名称在运行时并不会产生任何作用;它们只是描述信息,用来帮助你更清楚地理解每个位置应该放什么。

练习 4-8:数组类型

考虑下面这段购物车代码:

type ShoppingCart = {
  userId: string;
};

const processCart = (cart: ShoppingCart) => {
  // 在这里对购物车做一些处理
};

processCart({
  userId: "user123",
  items: ["item1", "item2", "item3"], // items 下方会出现红色波浪线
});

我们现在有一个 ShoppingCart 类型别名,它目前只有一个 userId 属性,类型为 stringprocessCart 函数接收一个类型为 ShoppingCartcart 参数。函数体目前不重要,重要的是:在调用 processCart 时,你传入了一个包含 userIditems 的对象,而 items 是一个字符串数组。

items 下方出现了错误,错误信息如下:

Argument of type '{userId: string; items: string[];}' is not
  assignable to parameter of type 'ShoppingCart'.
Object literal may only specify known properties, and
  'items' does not exist in type 'ShoppingCart'.

正如错误信息指出的那样,ShoppingCart 类型里目前并没有 items 这个属性。那么,你应该如何修改类型,才能修复这个错误?

参见 https://totalts.link/essentials-4-8/

解答

对于这个 ShoppingCart 的例子,如果你使用方括号语法来定义一个“由字符串组成的数组”,写法会是这样:

type ShoppingCart = {
  userId: string;
  items: string[];
};

有了这个定义之后,传入的 items 就必须是一个数组。如果你传的是单个字符串,或者其他类型,TypeScript 就会报错。

另一种写法,是显式地使用 Array,并通过尖括号传入数组元素的类型:

type ShoppingCart = {
  userId: string;
  items: Array<string>;
};

练习 4-9:对象数组

再来看这个 processRecipe 函数,它接收一个 Recipe 类型的参数:

type Recipe = {
  title: string;
  instructions: string;
};

const processRecipe = (recipe: Recipe) => {
  // 在这里对 recipe 做一些处理
};

processRecipe({
  title: "Chocolate Chip Cookies",
  ingredients: [
    {name: "Flour", quantity: "2 cups"},
    {name: "Sugar", quantity: "1 cup"},
    // 其他配料...
  ],
  instructions: ". . .",
});

调用这个函数时,传入的对象包含 titleinstructionsingredients 属性,但当前会报错,因为 Recipe 类型里还没有 ingredients 这个属性:

Argument of type '{title: string; ingredients: {
  name: string;
  quantity: string;}[];
  instructions: string;
}' is not assignable to parameter of type 'Recipe'.

Object literal may only specify known properties, and 'ingredients'
does not exist in type 'Recipe'.

把你前面学到的“对象属性类型”和“数组类型”结合起来想一想:你该如何为 Recipe 类型中的 ingredients 属性写类型?

参见 https://totalts.link/essentials-4-9/

解答

表达“对象数组”有几种不同方式。其中一种更清晰的做法,是先创建一个新的 Ingredient 类型,用来表示数组里的每一个对象:

type Ingredient = {
  name: string;
  quantity: string;
};

然后,再把 Recipe 类型更新为包含一个 ingredients 属性,其类型为 Ingredient[]

type Recipe = {
  title: string;
  instructions: string;
  ingredients: Ingredient[];
};

这种写法可读性很好,能修复错误,而且也有助于你建立更清晰的领域模型心智图。

正如前面展示过的,使用 Array<Ingredient> 的写法也同样可以:

type Recipe = {
  title: string;
  instructions: string;
  ingredients: Array<Ingredient>;
};

当然,你也可以直接在 Recipe 类型里,把 ingredients 写成一个内联的对象数组:

type Recipe = {
  title: string;
  instructions: string;
  ingredients: {
    name: string;
    quantity: string;
  }[];
};

或者使用 Array<>,把对象字面量类型直接放进去:

type Recipe = {
  title: string;
  instructions: string;
  ingredients: Array<{
    name: string;
    quantity: string;
  }>;
};

内联写法当然也可以,但我们更倾向于把它提取成一个新的类型。这样一来,如果应用的其他地方也需要用到 Ingredient 类型,就可以直接复用了。

练习 4-10:元组

下面有一个 setRange 函数,它接收一个数字数组:

const setRange = (range: Array) => {
  const x = range[0];
  const y = range[1];

  // 在这里对 x 和 y 做一些处理
  // x 和 y 都应该是数字!

  type tests = [
    Expect<Equal<typeof x, number>>, // Equal<> 整句下方会出现红色波浪线
    Expect<Equal<typeof y, number>>, // Equal<> 整句下方会出现红色波浪线
  ];
};

在这个函数里,你取出数组的第一个元素赋给 x,第二个元素赋给 y。现在,函数里的两个类型测试都失败了。

借助 // @ts-expect-error 指令,你还会发现另外两处也需要修正。回忆一下,这个指令表示:你告诉 TypeScript“下一行我知道会报错,请忽略它”。但如果你明明写了“我期望报错”,结果下一行其实没有错误,那 TypeScript 反而会在 // @ts-expect-error 这一行本身标红:

// 下面这两处都会在 ts-expect-error 这一行本身出现红色波浪线

// @ts-expect-error too few arguments
setRange([0]);

// @ts-expect-error too many arguments
setRange([0, 10, 20]);

setRange 函数需要更新它的类型注解,明确表示它只接受一个包含两个数字的元组

参见 https://totalts.link/essentials-4-10/

解答

这里你需要做的,就是把 setRange 函数的参数类型从普通数组改成元组语法:

const setRange = (range: [number, number]) => {
  // 函数体剩余部分
};

如果你想让这个元组更易读,还可以给两个位置上的类型加上名字:

const setRange = (range: [x: number, y: number]) => {
  // 函数体剩余部分
};

练习 4-11:元组中的可选成员

这个 goToLocation 函数接收一个坐标数组。每个坐标都包含纬度和经度,它们都是数字;此外,还可以额外包含一个海拔值,这个海拔同样是数字,但它是可选的:

const goToLocation = (coordinates: Array) => {
  const latitude = coordinates[0];
  const longitude = coordinates[1];
  const elevation = coordinates[2];

  // 在这里对 latitude、longitude 和 elevation 做一些处理

  type tests = [
    Expect<Equal<typeof latitude, number>>, // Equal<> 语句下方会出现红色波浪线
    Expect<Equal<typeof longitude, number>>, // Equal<> 语句下方会出现红色波浪线
    Expect<Equal<typeof elevation, number | undefined>>,
  ];
};

你的任务是更新 coordinates 参数的类型注解,明确表示它应该是一个包含三个数字的元组,其中第三个数字是可选的。

参见 https://totalts.link/essentials-4-11/

解答

这里的修复方式,是给元组中的成员添加一个可选修饰符 ?

const goToLocation = (
  coordinates: [latitude: number, longitude: number, elevation?: number],
) => {};

这样一来,含义就非常清楚了:前两个值是必填数字,第三个值 elevation 是可选数字。它看起来几乎像一个对象,但它本质上仍然是元组。

当然,如果你不想使用带名字的元组,也可以直接把 ? 放在类型定义后面:

const goToLocation = (coordinates: [number, number, number?]) => {};

把类型传给函数

我们先快速回顾一下前面提到过的 Array 类型:

Array<string>;

这个类型表示“字符串数组”。要实现这一点,你其实是在把一个类型(string)作为参数,传给另一个类型(Array)。

还有很多其他类型也可以接收类型作为参数,比如 Promise<string>Record<string, string> 等等。它们都使用尖括号语法,把一个类型传给另一个类型。不过,尖括号这种语法不仅能传给类型,有时也可以传给函数。

给 Set 传入类型

Set 是 JavaScript 的一个特性,它表示一个唯一值集合。要创建一个 Set,需要使用 new 关键字并调用 Set

const formats = new Set();

如果你把鼠标悬停在 formats 变量上,会看到它的类型是 Set<unknown>

// 悬停在 formats 上会显示:
const formats: Set<unknown>;

这是因为这个 Set 并不知道自己将来应该存放什么类型的值!你还没有传任何初始值进去,所以它默认成了 unknown 类型。关于 unknown,我们会在下一章里更深入地讨论。

让 TypeScript 知道 Set 应该存什么类型的一种方式,是在创建它的时候直接传入一些初始值:

const formats = new Set(["CD", "DVD"]);

在这个例子里,因为你创建 Set 时传入了两个字符串,所以 TypeScript 能推断出 formats 是一个字符串集合:

// 悬停在 formats 上会显示:
const formats: Set<string>;

不过,并不是每次创建 Set 时,你都已经知道要往里面放哪些值。有时候你只是想先创建一个空集合,但你已经明确知道:以后它应该只存字符串。

这时,你就可以使用尖括号语法,直接把类型传给 Set

const formats = new Set<string>();

现在,formats 已经清楚地知道自己是“字符串集合”了,因此如果你往里面添加字符串,一切正常;但添加其他类型就会失败:

formats.add("Digital"); // 这可以

formats.add(8); // 8 下方会出现红色波浪线
// 悬停在 8 上会显示:

Argument of type 'number' is not assignable to parameter of type 'string'

这在 TypeScript 里是一个非常重要的概念:你不仅可以把传给函数,有时也可以把类型传给函数。

不是所有函数都能接收类型

不过,大多数 TypeScript 函数其实都不能接收类型。比如,我们来看 DOM 类型里的 document.getElementById。这是一个很常见、你可能会想给它传类型的场景。比如,你想获取一个音频元素:

const audioElement = document.getElementById("player");

你明明知道 audioElement 将会是一个 HTMLAudioElement,看起来似乎应该可以把这个类型传给 document.getElementById。但实际上,不行:

const audioElement = document.getElementById<HTMLAudioElement>("player");
// HTMLAudioElement 下方会出现红色波浪线

当你把鼠标悬停在 HTMLAudioElement 上时,错误会告诉你:这个函数预期接收 0 个类型参数,但你传了 1 个。

你可以通过悬停函数本身,来判断它是否支持接收类型参数。比如,我们来悬停看一下 .getElementById

// 悬停在 .getElementById 上会显示:
(method) Document.getElementById(elementId: string): HTMLElement | null

注意看:.getElementById 的提示信息里,并没有任何尖括号,因此你就不能给它传类型。

我们来对比一个可以接收类型参数的函数,比如 document.querySelector

const audioElement = document.querySelector("#player");
// 悬停在 .querySelector 上会显示:
(method) ParentNode.querySelector<Element>(selectors: string): Element | null

这个类型定义清楚地显示出:.querySelector 在圆括号前面有一对尖括号。在尖括号里,默认类型是 Element。所以,要修正前面的代码,你可以把 .getElementById 换成 .querySelector,并使用 #player 这个选择器来找到音频元素:

const audioElement = document.querySelector<HTMLAudioElement>("#player");

这样一来,问题就解决了。

判断一个函数能不能接收类型参数,最简单的方法就是:悬停它,看看签名里有没有尖括号

练习 4-12:给 Map 传入类型

这里你正在创建一个 MapMap 是 JavaScript 的一个特性,可以理解为一种“字典结构”。在这个例子里,你希望它的键是数字,而值是一个对象:

const userMap = new Map();

userMap.set(1, {name: "Max", age: 30});

userMap.set(2, {name: "Manuel", age: 31});

// @ts-expect-error // @ts-expect-error 下方会出现红色波浪线
userMap.set("3", {name: "Anna", age: 29});

// @ts-expect-error // @ts-expect-error 下方会出现红色波浪线
userMap.set(3, "123");

现在,红色波浪线出现在 @ts-expect-error 指令本身上,是因为当前这个 Map 允许任何类型的键和值:

// 悬停在 Map 上会显示:
var Map: MapConstructor

new () => Map<any, any> (+3 overloads)

那么,你该如何给 userMap 标类型,才能确保它的键必须是数字,而值必须是一个带有 nameage 属性的对象呢?

参见 https://totalts.link/essentials-4-12/

解答

这个问题有几种解法,不过我们先从最直接的一种开始。第一步,是创建一个 User 类型:

type User = {
  name: string;
  age: number;
};

接着,按照你前面已经见过的模式,把 numberUser 作为类型参数传给 Map

const userMap = new Map<number, User>();

没错,有些函数是可以接收多个类型参数的。在这个例子里,Map 构造函数一共接收两个类型:一个用于键,一个用于值。

这样修改之后,错误就都消失了,同时你也不能再向 userMap.set 里传入错误类型的键和值了。

当然,你也可以把 User 类型直接写成内联形式:

const userMap = new Map<number, {name: string; age: number}>();

练习 4-13:JSON.parse() 不能接收类型参数

来看下面这段代码,它使用 JSON.parse() 来解析一段 JSON:

const parsedData = JSON.parse<{
  name: string; // 整个 {} 类型参数下方会出现红色波浪线
  age: number;
}>('{"name": "Alice", "age": 30}');

当前,JSON.parse 上的类型参数会报错:

Expected 0 type arguments, but got 1.

同时,一个检查 parsedData 类型的测试也会失败,因为 parsedData 现在被标成了 any,而不是我们期望的那个对象类型:

type test = Expect<
  Equal<
    // 整个 Equal<> 下方会出现红色波浪线
    typeof parsedData,
    {
      name: string;
      age: number;
    }
  >
>;
it("Should be the correct shape", () => {
  expect(parsedData).toEqual({
    name: "Alice",
    age: 30,
  });
});

你尝试给 JSON.parse 传入类型参数,但在这里它显然并不工作。测试错误说明 parsedData 的类型并不是你期望的那样;nameage 这些属性没有被正确识别出来。

为什么会这样?又该用什么别的方式来修正这些类型错误呢?

参见 https://totalts.link/essentials-4-13/

解答

先仔细看一下,当你给 JSON.parse 传入类型参数时收到的错误信息:

Expected 0 type arguments, but got 1.

这条信息表明:TypeScript 并不期望你在调用 JSON.parse 时,在尖括号里传入任何东西。因此,第一步修复就是去掉尖括号:

const parsedData = JSON.parse('{"name": "Alice", "age": 30}');

现在,.parse 接收的类型参数数量是正确的,TypeScript 这部分就不会再报错了。但你仍然希望 parsedData 拥有正确的类型。把鼠标悬停在 JSON.parse 上,你会看到它的类型定义:

JSON.parse(text: string, reviver?: ((this:
  any, key: string, value: any) => any) | undefined): any

它总是返回 any,这就有点麻烦了。

为了绕开这个问题,你可以直接给 parsedData 添加一个变量类型注解,指定它应该具有 name: stringage: number 这两个属性:

const parsedData: {
  name: string;
  age: number;
} = JSON.parse('{"name": "Alice", "age": 30}');

现在,parsedData 就拥有了你想要的类型。

之所以这样可行,是因为 any 会关闭类型检查。也就是说,你可以把 any 赋给任意你想要的类型。甚至哪怕你写出完全不合理的类型,比如 number,TypeScript 也不会阻止你:

const parsedData: number = JSON.parse('{"name": "Alice", "age": 30}');

这更像是一种“类型信仰(type faith) ”,而不是真正的“类型安全(type safe) ”。你是在希望 parsedData 的实际值会符合你标注的类型。这依赖于你自己始终保持“类型注解”和“真实数据”同步。

给函数标类型

接下来,我们再看几种给函数参数添加类型注解的方式。

可选参数

如果某个函数参数是可选的,你可以在 : 之前加一个 ? 修饰符。比如,假设你想给 logAlbumInfo 函数增加一个可选的 releaseDate 参数,可以这样写:

const logAlbumInfo = (
  title: string,
  trackCount: number,
  isReleased: boolean,
  releaseDate?: string,
) => {
  // 函数体剩余部分
};

现在,你在调用 logAlbumInfo 时,既可以传入一个发布日期字符串,也可以省略它:

logAlbumInfo("Midnights", 13, true, "2022-10-21");

logAlbumInfo("American Beauty", 10, true);

如果你把鼠标悬停在这个可选参数 releaseDate 上,会看到它现在的类型是 string | undefined

后面我们会详细讲 | 这个符号,但这里它的意思是:这个参数可能是字符串,也可能是 undefined。也就是说,你既可以显式地传 undefined,也可以干脆把这个参数省略掉。

默认参数

除了把参数标记为可选,你还可以用 = 来为参数设置默认值。

例如,如果没有提供 format,你希望它默认是 "CD",就可以这样写:

const logAlbumInfo = (
  title: string,
  trackCount: number,
  isReleased: boolean,
  format: string = "CD",
) => {
  // 函数体剩余部分
};

这里的 : string 其实也可以省略,因为 TypeScript 能从你提供的默认值 "CD" 推断出 format 参数的类型。这又是一个类型推断的好例子:

const logAlbumInfo = (
  title: string,
  trackCount: number,
  isReleased: boolean,
  format = "CD",
) => {
  // 函数体剩余部分
};

函数返回类型

除了给参数设置类型,你也可以给函数本身的返回值设置类型。方法是在参数列表右侧的圆括号后面加上 : 和类型。比如,你可以明确指定 logAlbumInfo 函数会返回一个字符串:

const logAlbumInfo = (
  title: string,
  trackCount: number,
  isReleased: boolean,
): string => {
  // 函数体剩余部分
};

如果函数真正返回的值,与这个声明的返回类型不匹配,TypeScript 就会报错:

const logAlbumInfo = (
  title: string,
  trackCount: number,
  isReleased: boolean,
): string => {
  return 123; // 123 下方会出现红色波浪线
};

当你想强制确保某个函数返回特定类型的值时,返回类型注解会非常有用。

剩余参数

和 JavaScript 一样,TypeScript 也支持剩余参数(rest parameters) ,使用的是 ... 语法。它必须出现在最后一个参数位置,用来表示这个函数可以接收任意数量的参数。

例如,下面这个 getAlbumFormats 函数接收一个专辑对象,以及任意数量的格式字符串:

function getAlbumFormats(album: Album, ...formats: string[]) {
  return `${album.title} is available in the following formats: ${formats.join(
    ", ",
  )}`;
}

把参数声明成 ...formats 再配合 string[],意味着我们可以向这个函数传入任意多个字符串:

getAlbumFormats(
  {artist: "Radiohead", title: "OK Computer", year: 1997},
  "CD",
  "LP",
  "Cassette",
);

你甚至也可以把一个字符串数组展开后传进去:

const albumFormats = ["CD", "LP", "Cassette"];

getAlbumFormats(
  {artist: "Radiohead", title: "OK Computer", year: 1997},
  ...albumFormats,
);

这意味着,formats 传进函数之后会是一个数组。

当然,作为另一种写法,你也可以使用 Array<> 语法:

function getAlbumFormats(album: Album, ...formats: Array<string>) {
  // 函数体
}

函数类型

前面我们一直在讲,如何使用类型注解来指定函数参数的类型。但 TypeScript 还允许你直接描述“函数本身”的类型。语法如下:

type Mapper = (item: string) => number;

这是一个函数类型别名,表示:这是一个接收 string、并返回 number 的函数。

然后,你就可以用它来描述另一个函数所接收的回调函数:

const mapOverItems = (items: string[], map: Mapper) => {
  return items.map(map);
};

或者,你也可以不提前定义类型别名,而是把函数类型直接内联写出来:

const mapOverItems = (items: string[], map: (item: string) => number) => {
  return items.map(map);
};

这样,你就可以向 mapOverItems 传入一个函数,用来把数组里的字符串项转换成别的值:

const arrayOfNumbers = mapOverItems(["1", "2", "3"], (item) => {
  return parseInt(item) * 100;
});

函数类型和函数定义一样灵活。你可以在函数类型中声明多个参数、剩余参数,以及可选参数:

// 可选参数
type WithOptional = (index?: number) => number;

// 剩余参数
type WithRest = (...rest: string[]) => number;

// 多个参数
type WithMultiple = (first: string, second: string) => number;

void 类型

有些函数并不会返回任何东西。它们只是执行某种动作,而不会产出可用的值。最典型的例子就是 console.log

const logResult = console.log("Hello!");

你觉得 logResult 应该是什么类型?

在 JavaScript 中,它的值其实是 undefined。如果你再执行 console.log(logResult),控制台里看到的也会是 undefined。但在 TypeScript 中,对于这种“函数返回值应该被有意忽略”的场景,专门有一个类型来表示它,叫做 void

如果你把鼠标悬停在 console.log 上,会看到它返回的是 void

(method) Console.log(...data: any[]): void

所以,logResult 的类型其实也是 void。这是 TypeScript 在表达:“这个函数调用的结果请忽略掉。”

异步函数

前面你已经看到,如何通过返回类型显式地约束函数返回什么:

const getUser = (id: string): User => {
  // 函数体
};

那如果这个函数是异步的呢?

const getUser = async (id: string): User => { // User 下方会出现红色波浪线
  // 函数体
};
// 悬停在 User 上会显示:
The return type of an async function or method must be
the global Promise type. Did you mean to write 'Promise'?

好在,TypeScript 的错误信息在这里已经说得很清楚了:异步函数的返回类型必须是 Promise。所以,你可以把 User 放进 Promise<> 里:

const getUser = async (id: string): Promise<User> => {
  const user = await db.users.get(id);

  return user;
};

这样,函数就必须返回一个“最终解析为 User 的 Promise”。

练习 4-14:可选函数参数

这里有一个 concatName 函数,它接收两个字符串参数:firstlast

如果没有传入 last,那么返回值就应该只是 first;否则,就返回 firstlast 拼接后的结果:

const concatName = (first: string, last: string) => {
  if (!last) {
    return first;
  }

  return `${first} ${last}`;
};

当你传入名字和姓氏去调用 concatName 时,函数可以正常工作,也不会报错:

const result = concatName("John", "Doe");

但是,当你只传入名字去调用时,就会报错:

const result2 = concatName("John"); // concatName("John") 下方会出现红色波浪线

错误信息如下:

Expected 2 arguments, but got 1.

试着使用“可选参数注解”来修复这个错误。

参见 https://totalts.link/essentials-4-14/

解答

只要在参数名末尾加上一个问号,这个参数就会被标记为可选:

function concatName(first: string, last?: string) {
  // ...实现部分
}

练习 4-15:默认函数参数

这里仍然是前面的 concatName 函数,现在它的姓氏参数是可选的:

const concatName = (first: string, last?: string) => {
  if (!last) {
    return first;
  }

  return `${first} ${last}`;
};

你还有两个测试。第一个测试检查:当传入名字和姓氏时,函数应该返回完整姓名:

it("should return the full name", () => {
  const result = concatName("John", "Doe");

  type test = Expect<Equal<typeof result, string>>;

  expect(result).toEqual("John Doe");
});

而第二个测试则期望:当只传入名字时,concatName 应该默认使用 "Pocock" 作为姓氏:

it("should return the first name", () => {
  const result = concatName("John");

  type test = Expect<Equal<typeof result, string>>;

  expect(result).toEqual("John Pocock");
});

这个测试目前会失败,Vitest 的输出表明错误出现在 expect 这一行:

AssertionError: expected 'John' to deeply equal 'John Pocock'

- Expected

+ Received
- John Pocock

+ John

expect(result).toEqual("John Pocock");

请更新 concatName 函数:如果没有提供姓氏,就默认使用 "Pocock"

参见 https://totalts.link/essentials-4-15/

解答

在 TypeScript 中添加默认参数,使用的就是和 JavaScript 一样的 = 语法。在这里,你可以把 last 更新为:如果没有传值,就默认是 "Pocock"

export const concatName = (first: string, last?: string = "Pocock") => {
  return `${first} ${last}`;
};

虽然这能通过你的运行时测试,但它实际上会在 TypeScript 中报错:

Parameter cannot have question mark and initializer.

这是因为 TypeScript 不允许一个参数既带问号,又带默认值。因为一旦你给参数设置了默认值,它本身就已经隐含是可选的了。

所以,修复方法就是把 last 参数上的问号删掉:

export const concatName = (first: string, last = "Pocock") => {
  return `${first} ${last}`;
};

练习 4-16:剩余参数

这里有一个 concatenate 函数,它接收任意数量的字符串:

export function concatenate(...strings) {
  // ...strings 下方会出现红色波浪线
  return strings.join("");
}

这段代码运行起来没有问题,但 ...strings 这个剩余参数上有一个错误:

// 悬停在 ...strings 上会显示:
Rest parameter 'strings' implicitly has an 'any[]' type.

你该如何更新这个剩余参数,明确表示它应该是一个字符串数组

参见 https://totalts.link/essentials-4-16/

解答

使用剩余参数时,所有传给函数的参数最终都会被收集进一个数组中。因此,这里的 strings 参数应该被标成“字符串数组”:

export function concatenate(...strings: string[]) {
  return strings.join("");
}

当然,和前面一样,你也可以用 Array<> 语法来写:

export function concatenate(...strings: Array<string>) {
  return strings.join("");
}

练习 4-17:函数类型

这里有一个 modifyUser 函数。它接收一个用户数组、一个你想修改的用户 id,以及一个用来执行修改的 makeChange 函数:

type User = {
  id: string;
  name: string;
};

const modifyUser = (user: User[], id: string, makeChange) => {
  // makeChange 下方会出现红色波浪线

  return user.map((u) => {
    if (u.id === id) {
      return makeChange(u);
    }

    return u;
  });
};

目前,makeChange 下方的错误是:

Parameter `makeChange` implicitly has an `any` type.

下面是这个函数的一种调用方式:

const users: User[] = [
  {id: "1", name: "John"},
  {id: "2", name: "Jane"},
];

modifyUser(users, "1", (user) => {
  // user 下方会出现红色波浪线
  return {...user, name: "Waqas"};
});

在上面的例子里,回调函数里的 user 参数同样有“隐式 any”错误。

modifyUser 中的 makeChange 需要更新类型注解,使其表达“它接收一个 User,并返回一个修改后的 User”。例如,你不应该能返回 name: 123,因为在 User 类型里,name 明明是字符串:

modifyUser(
  users,
  "1",
  // @ts-expect-error
  (user) => {
    return {...user, name: 123};
  },
);

那么,你该如何把 makeChange 标成一个函数类型,使它接收 User 并返回 User 呢?

参见 https://totalts.link/essentials-4-17/

解答

先从最基础的一步开始:先把 makeChange 参数标成一个函数。暂时先让它返回 any

const modifyUser = (user: User[], id: string, makeChange: () => any) => {
  return user.map((u) => {
    if (u.id === id) {
      return makeChange(u); // u 下方会出现红色波浪线
    }

    return u;
  });
};

这样一改之后,会在调用 makeChange(u) 时,参数 u 下方出现错误,因为你刚才写的 makeChange 类型声明里,表示它不接收任何参数

// 在 user.map() 函数中

return makeChange(u)
// 悬停在 u 上会显示:

Expected 0 arguments, but got 1.

这说明你需要在 makeChange 的函数类型里,补上参数。

在这个例子里,这个参数应该是 User 类型:

const modifyUser = (
  user: User[],
  id: string,
  makeChange: (user: User) => any,
) => {
  // 函数体
};

这样已经不错了,但你还需要确保 makeChange 返回的也是一个 User

const modifyUser = (
  user: User[],
  id: string,
  makeChange: (user: User) => User,
) => {
  // 函数体
};

现在,错误就都解决了;而且当你编写 makeChange 回调函数时,也会获得 User 属性的自动补全。

当然,你还可以进一步稍微整理一下代码:把 makeChange 的函数类型提取成一个类型别名:

type MakeChangeFunc = (user: User) => User;

const modifyUser = (user: User[], id: string, makeChange: MakeChangeFunc) => {
  // 函数体
};

这两种写法本质上没有差别。但如果你之后还需要复用 makeChange 这种函数类型,那么使用类型别名会更合适。

练习 4-18:返回 void 的函数

这里来看一个经典的 Web 开发例子。你有一个 addClickEventListener 函数,它接收一个监听器函数,并把它加到 document 上:

const addClickEventListener = (listener) => {
  // listener 下方会出现红色波浪线

  document.addEventListener("click", listener);
};

addClickEventListener(() => {
  console.log("Clicked!");
});

目前,listener 下方报错,是因为它还没有函数类型签名。与此同时,当你向 addClickEventListener 传入错误类型时,居然也不会报错:

addClickEventListener(
  // @ts-expect-error // @ts-expect-error 下方会出现红色波浪线
  "abc",
);

这反而触发了你的 @ts-expect-error 指令。

那么,该如何给 addClickEventListener 标类型,才能把这些错误都修复掉呢?

参见 https://totalts.link/essentials-4-18/

解答

先从最基础的一步开始:先把 listener 参数标成一个函数。暂时先假设它返回 string

const addClickEventListener = (listener: () => string) => {
  document.addEventListener("click", listener);
};

问题在于:现在,当你传入一个什么也不返回的函数时,反而会报错:

addClickEventListener(() => {
  // () => { 下方会出现红色波浪线

  console.log("Clicked!");
});

悬停在错误上,你会看到下面这段信息:

Argument of type '() => void' is not assignable to parameter of type '() => string'.
Type 'void' is not assignable to type 'string'.

这条错误其实已经告诉你答案了:listener 返回的是 void,而不是 string。所以,正确的做法不是把 listener 标成“返回字符串的函数”,而是标成“返回 void 的函数”:

const addClickEventListener = (listener: () => void) => {
  document.addEventListener("click", listener);
};

这是一种很好的方式,可以明确告诉 TypeScript:你并不关心监听器函数的返回值。

练习 4-19:void 与 undefined

假设你有一个函数,它接收一个回调并调用它。因为这个回调不会返回任何有意义的内容,所以你把它标成了 () => undefined

const acceptsCallback = (callback: () => undefined) => {
  callback();
};

但当你尝试把 returnString 这个函数传进去时,却报错了——而 returnString 实际上是有返回值的:

const returnString = () => {
  return "Hello!";
};

// Argument of type '() => string' is not
// assignable to parameter of type '() => undefined'.
acceptsCallback(returnString); // returnString 下方会出现红色波浪线

为什么会这样?你能修改 acceptsCallback 的类型,让这个错误消失吗?

参见 https://totalts.link/essentials-4-19/

解答

修复方法就是:把 callback 的类型改成 () => void

const acceptsCallback = (callback: () => void) => {
  callback();
};

现在,你就可以顺利把 returnString 传进去,而不会有任何问题。原因在于:returnString 虽然返回的是 string,但 void 在这里表示的是“比较类型时请忽略返回值”。如果你真的不关心函数的返回结果,就应该把它标成 () => void

练习 4-20:异步函数

下面这个 fetchData 函数,会先等待一次 fetch 调用返回响应,然后再通过 response.json() 获取数据:

async function fetchData() {
  const response = await fetch("https://api.example.com/data");

  const data = await response.json();

  return data;
}

这里有两个值得注意的点。把鼠标悬停在 response 上时,你会看到它的类型是 Response,这是一个全局可用的类型:

// 悬停在 response 上会显示:
const response: Response;

再悬停到 response.json() 上时,你会看到它返回的是 Promise<any>

// 悬停在 response.json() 上会显示:
const response.json(): Promise<any>

如果你把 fetch 调用前面的 await 去掉,那么 response 本身的类型也会变成 Promise<any>

const response = fetch("https://api.example.com/data");

// 悬停在 response 上会显示:
const response: Promise<any>;

再来看下面这个示例及其测试:

const example = async () => {
  const data = await fetchData();

  type test = Expect<Equal<typeof data, number>>;
};

这个测试当前会失败,因为 data 被标成了 any,而不是 number

问题是:在不修改 fetch 调用、也不修改 response.json() 调用的前提下,你该如何把 data 标成 number?这里其实有两种可行解法。

参见 https://totalts.link/essentials-4-20/

解答

你可能会本能地想:既然前面 MapSet 可以接收类型参数,那 fetch 是不是也可以?但实际上不行。只要悬停在 fetch 上,你就会看到它并不接收类型参数:

// 这样是不行的!

const response = fetch<number>("https://api.example.com/data");
// number 下方会出现红色波浪线
// 悬停在 fetch 上会显示:

function fetch(
  input: RequestInfo | URL,
  init?: RequestInit | undefined,
): Promise<Response>

同样地,你也没法给 response.json() 添加类型参数,因为它也不接收类型参数:

// 这样也不行!

const data: number = await response.json<number>();
// number 下方会出现红色波浪线
// 悬停在 number 上会显示:

Expected 0 type arguments, but got 1.

有一种可行的方式,是直接声明 datanumber

const data: number = await response.json();

这之所以能工作,是因为在之前,dataany,而 await response.json() 返回的也是 any。现在,你只是把这个 any 放进了一个要求是 number 的槽位里。

不过,更好的做法,是直接给函数本身添加返回类型。在这个例子里,应该是 number

// number 下方会出现红色波浪线
async function fetchData(): number {
  // 函数体
}

这时,data 会被推断成 number,不过你的返回类型注解又会报错:

The return type of an async function or method must be the global Promise type.
Did you mean to write 'Promise'?

所以,你应该把返回类型改成 Promise<number>

async function fetchData(): Promise<number> {
  const response = await fetch("https://api.example.com/data");

  const data = await response.json();

  return data;
}

通过把 number 包进 Promise<> 中,你就是在告诉 TypeScript:这个异步函数最终会解析出一个 number

小结

本章介绍了 TypeScript 类型系统中的一些基础概念,重点包括类型注解类型推断。你学习了什么时候需要显式给代码写上类型,什么时候 TypeScript 能够自动推断出类型。

TypeScript 提供了若干基础类型,例如 stringnumberbooleansymbolbigintnullundefined,它们分别对应 JavaScript 中的原始值。这些基础类型构成了更复杂类型注解的基石。

函数参数始终需要类型注解,因为 TypeScript 无法推断它们应该接收什么类型;而变量注解则往往是可选的,因为 TypeScript 能根据赋值内容自动推断类型。换句话说,变量通常可以靠推断,函数参数则通常需要显式声明。

any 类型会彻底关闭 TypeScript 的类型检查,使得任何值都可以被赋值、访问或调用,而不会出现错误。虽然它在某些特殊场景下可以充当一种“逃生舱”,但如果滥用 any,就会抵消掉使用 TypeScript 的意义。

对象字面量类型使用花括号语法来描述对象的形状,可选属性则通过 ? 修饰符来表示。使用 type 关键字创建的类型别名,可以让你定义可复用的类型,并在多个模块之间共享。

数组可以使用方括号语法来标类型,例如 string[];也可以使用泛型语法 Array<string>。元组则用于描述长度固定、并且每个位置拥有各自类型的数组,这对于在不额外创建对象类型的情况下,把相关数据捆绑在一起特别有用。

有些函数,比如 SetMap,可以通过尖括号接收类型参数;而另一些,比如 JSON.parse,则不能。判断某个函数是否支持类型参数,一个简单方法就是悬停它,看它的签名里有没有尖括号。

函数类型不仅可以描述函数参数,也可以描述函数本身的形状,包括参数类型和返回类型。可选参数使用 ?,默认参数使用 =,剩余参数则使用展开语法并配合数组类型。

void 类型用于表示那些没有有意义返回值的函数;而异步函数必须返回 Promise<T>,其中 T 表示 Promise 最终解析出来的值类型。这些概念共同构成了你有效使用 TypeScript 类型系统所必需的基础。