全栈 TypeScript——基本类型与注解

6 阅读47分钟

一个戴着兜帽的人物手中拿着一个玻璃小瓶,瓶中装满了深色、不断冒泡的液体,并散发出烟雾。

现在,我们已经讲完了 TypeScript 大部分“为什么”的问题,是时候开始讲“怎么做”了。在本章中,我们会介绍一些关键概念,例如类型注解和类型推断,以及如何开始编写类型安全的函数。

打好坚实的基础非常重要,因为你后面将要学习的一切,都会建立在本章内容之上。

类型注解

作为 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 函数,它接收一个字符串类型的 title,一个数字类型的 trackCount,以及一个布尔类型的 isReleased

const logAlbumInfo = (
  title: string,

  trackCount: number,

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

每个参数的类型注解,都能让 TypeScript 检查传给函数的参数是否属于正确类型。如果类型对不上,TypeScript 就会在任何有问题的参数下面显示红色波浪线:

logAlbumInfo("Black Gold", false, 15); // 先在 false 下面出现红色波浪线,然后在 15 下面出现

在前面的例子中,你首先会在 false 下面得到一个错误,因为布尔值不能被传到一个期望数字的位置。TypeScript 的错误会把这种情况描述为不能“赋值给”布尔类型。如果你像下面这样修复它:

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 足够聪明,可以在没有注解的地方推断出类型。这就叫作类型推断。

变量并不总是需要注解

我们再看一下前面的变量注解示例,但这次去掉注解:

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

我们没有添加注解,但 TypeScript 并没有报错。发生了什么?

试着把光标悬停在每个变量上,你会看到下面的信息:

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

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

即使没有显式注解,TypeScript 也能够推断出每个变量的类型。

TypeScript 仍然会表现得像这些变量已经被注解过一样。如果你试图把它重新赋值为一个与最初赋值不同的类型,它会给出警告。例如,如果你把布尔值 true 赋给 isReleased,然后又试图把它重新赋值为字符串,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,把它们当作练习中的测试。在这个例子中,你期望 result 是一个数字。

目前,代码中有几个错误被红色波浪线标出。第一个错误在 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 上检查它。

参见:totalts.link/essentials-…

解决方案

常识会告诉你,add 函数中的布尔类型应该被替换成某种数字类型。如果你来自其他语言,可能会想尝试使用 intfloat,但 TypeScript 只有 number 类型:

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

做出这个修改后,错误会被解决,而且你还会获得一些额外好处。

如果你试图用字符串而不是数字来调用 add 函数,就会得到一个错误: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.

"Hello""World" 调用 concatTwoStrings 并检查它是否为字符串时,并不会显示任何错误:

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

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

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

参见:totalts.link/essentials-…

解决方案

正如我们前面提到的,在 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'.

修改每个变量上的注解类型,让这些错误消失。

参见:totalts.link/essentials-…

解决方案

下面每个示例都代表 TypeScript 的一种基本类型,并且应该像这样添加注解:

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

你已经见过 stringnumberbooleansymbol 类型用于符号,符号可以用来确保属性键是唯一的。bigint 类型用于 JavaScript 的大整数。

注意

JavaScript 对数字大小有一些限制。大于 Number.MAX_SAFE_INTEGER 的数字,无法可靠地相加。Bigint 通过让我们在 JavaScript 中表示大数字,解决了这个问题。

不过,在实践中,你通常不会像这样给变量添加注解。如果你移除显式类型注解,完全不会出现错误:

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

这些基本类型值得了解,即使你并不总是在变量声明中需要它们。

练习 4-4:any 类型

下面这个函数名为 handleFormData,它接收一个被标注为 anye。这个函数会阻止表单的默认提交行为,然后从表单数据中创建一个对象并返回它:

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 没有在这里给你报错?

我们给你一个提示。我们在里面藏了一个很讨厌的拼写错误。你能修复它吗?

参见:totalts.link/essentials-…

解决方案

在这个例子中,使用 any 完全没有帮上忙。事实上,any 注解似乎实际上关闭了类型检查!有了 any 类型之后,你可以对变量做任何想做的事情,而 TypeScript 不会阻止你。使用 any 还会禁用一些有用的功能,比如自动补全,而自动补全可以帮助你避免拼写错误。没错,练习代码中的错误,是因为在创建 FormData 时把 e.target 拼成了 e.terget

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 可能看起来像是一个快速修复方案,但它之后可能会反过来坑你。

对象字面量类型

现在我们已经讲完了基本类型,接下来转向对象类型。对象类型用来描述对象的形状。对象的每个属性都可以有自己的类型注解。在定义对象类型时,你会使用花括号包含属性及其类型:

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

这种花括号语法叫作对象字面量类型。

可选对象属性

你可以使用 ? 操作符把 age 属性标记为可选:

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

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

对象字面量的类型注解有一个很酷的地方:当你输入属性名时,它们会提供自动补全。例如,当调用 talkToAnimal 时,它会提供一个自动补全下拉菜单,建议 nametypeage 属性。这可以为你节省大量时间,也能帮助你避免拼写错误,尤其是在你有几个名称相似的属性时。

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

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

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");
});

不过,在 concatName 函数的 user 参数上有一个熟悉的错误:

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

你可以从 concatName 函数体中看出,它期望 user.firstuser.last 都是字符串。你应该如何给 user 参数添加类型,以确保它拥有这些属性,并且这些属性属于正确的类型?

参见:totalts.link/essentials-…

解决方案

要把 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 需要是可选的。

你会如何更新这个函数来修复错误?

参见:totalts.link/essentials-…

解决方案

类似于把函数参数设置为可选,你可以使用问号 ? 来指定对象的某个属性是可选的:

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

添加 ?: 会告诉 TypeScript:这个属性不一定需要存在。

如果你在函数体中悬停到 last 属性上,会看到 last 属性是 string | undefined

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

这意味着它要么是 string,要么是 undefined。这是一个很有用的 TypeScript 语法,你未来会更频繁地看到它。

类型别名

到目前为止,你一直在内联声明所有类型。对于这些简单示例来说,这没问题。但在真实应用中,你会有很多类型在整个应用中重复出现。它们可能是用户、产品,或者其他领域特定类型,而你并不想在每个需要它的文件中重复同一份类型定义。

这就是 type 关键字发挥作用的地方。它允许你定义一次类型,然后在多个地方使用它:

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

这是一种给类型命名的方式。换句话说,它是一个类型别名。之后,你就可以在任何需要这个类型的地方使用这个名称。

要创建一个 Animal 类型的新变量,可以把它作为类型注解添加到变量名后面:

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

你也可以在函数中用 Animal 类型别名替代对象类型注解:

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

然后用你的 pet 变量调用这个函数:

const desc = getAnimalDescription(pet);

类型别名可以是对象,也可以使用基本类型:

type Id = string | number;

我们稍后会更详细地讲这种语法,它叫作联合类型。但它基本上是在说:一个 Id 可以是字符串,也可以是数字。

使用类型别名是确保某个类型定义只有一个事实来源的好方法,这会让未来修改起来更加容易。

跨模块共享类型

类型别名可以创建在自己的 .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 函数都接收一个带有 widthheight 属性的 rectangle 对象。

每个函数的测试都按预期通过:

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 关键字,让这段代码更易读?

参见:totalts.link/essentials-…

解决方案

你可以使用 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

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

// 创建一个 Albums 数组
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 下面出现红色波浪线
// 错误信息:
// Argument of type '{name: string; type: string;}' is not
//   assignable to parameter of type 'Album'.

元组

元组允许你指定一个具有固定元素数量的数组,而且每个元素都有自己的类型。创建元组类似于数组的方括号语法,只不过方括号中包含的是类型,而不是紧贴在变量名后面:

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

// 数组
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 处是 10000

命名元组

为了让元组更清晰,你可以在方括号中为每个类型添加名称:

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

当一个元组有很多元素,或者你想让代码更易读时,这会很有帮助。

注意

元组中的名称在运行时不起任何作用;它们只是描述,用来说明每个元素中应该放什么。

练习 4-8:数组类型

看看下面这段购物车代码:

type ShoppingCart = {
  userId: string;
};

const processCart = (cart: ShoppingCart) => {
  // 在这里处理 cart
};

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

我们有一个 ShoppingCart 类型别名,目前它只有一个类型为 stringuserId 属性。processCart 函数接收一个类型为 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 的属性。你会如何修改类型来修复这个错误?

参见:totalts.link/essentials-…

解决方案

对于 ShoppingCart 示例,使用方括号语法定义一个由字符串组成的 items 数组,看起来会像下面这样:

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

有了这个定义后,你必须把 items 作为数组传入。单个字符串或其他类型都会导致类型错误。

另一种语法是显式写出 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

参见:totalts.link/essentials-…

解决方案

有几种不同方式可以表达对象数组。一种做法是创建一个新的 Ingredient 类型,用它来表示数组中的对象:

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

然后,可以更新 Recipe 类型,让它包含一个类型为 Ingredient[]ingredients 属性:

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

这个方案读起来很好,修复了错误,也有助于你建立领域模型的心智地图。

如前所示,使用 Array<Ingredient> 语法也可以:

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

也可以使用方括号,把 ingredients 属性指定为 Recipe 类型中的内联对象字面量:

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,取出第二个元素并赋给 ysetRange 函数中的两个测试目前失败了。

通过使用 // @ts-expect-error 指令,你又发现了几个需要修复的错误。回忆一下,这个指令告诉 TypeScript:你知道下一行会有错误,它应该忽略这个错误。不过,如果你说自己预期会有错误,但实际上没有错误,那么红色波浪线就会出现在真正的 // @ts-expect-error 那一行下面:

// 下面两处都会在 ts-expect-error 指令下显示红色波浪线

// @ts-expect-error 参数太少
setRange([0]);

// @ts-expect-error 参数太多
setRange([0, 10, 20]);

setRange 函数的代码需要一个更新后的类型注解,用来指定它只接受由两个数字组成的元组。

参见:totalts.link/essentials-…

解决方案

在这个例子中,你应该更新 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 参数的类型注解,指定它应该是一个由三个数字组成的元组,其中第三个数字是可选的。

参见:totalts.link/essentials-…

解决方案

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

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,但你知道它以后会保存字符串。

对于这种情况,可以使用尖括号语法向 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。调用 document.getElementById 时,一个常见场景是你可能想传递一个类型。这里你试图获取一个音频元素:

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

你知道 audioElement 将会是一个 HTMLAudioElement,所以看起来你应该能够把这个类型传给 document.getElementById。然而,你不能这样做:

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

当你悬停在 HTMLAudioElement 上时,错误会告诉你它期望 0 个类型参数,但你传入了 1 个。在这个例子中,也就是 player

你可以通过悬停在函数上,查看它是否可以接收类型参数。我们试着悬停在 .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 属性的对象?

参见:totalts.link/essentials-…

解决方案

有几种不同方法可以解决这个问题,但我们从最直接的一种开始。第一件事是创建一个 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 类型的测试目前失败了,因为它被标注为 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 函数传递一个类型参数,但在这个例子中它似乎不起作用。测试错误告诉你,parsed 的类型不是你期望的类型。属性 nameage 没有被识别出来。

为什么会这样?还有什么不同方式可以修正这些类型错误?

参见:totalts.link/essentials-…

解决方案

我们更仔细地看一下把类型参数传给 JSON.parse 时得到的错误信息:

Expected 0 type arguments, but got 1.

这条信息表明,当调用 JSON.parse 时,TypeScript 并不期望在尖括号中传入任何东西。要解决这个错误,可以移除尖括号:

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

现在,.parse 收到了正确数量的类型参数,TypeScript 就满意了。不过,你希望解析出来的数据具有正确类型。悬停在 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 会关闭类型检查。因此,你可以把它赋给任何你想要的类型。你甚至可以把它赋给一个不合理的类型,比如 number,TypeScript 也不会抱怨:

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

这更像是“类型信仰”,而不是“类型安全”。你是在希望 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 可以从所提供的值中推断出 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 支持使用 ... 语法声明最后一个参数为剩余参数,从而允许你向函数传入任意数量的参数。例如,下面这个 printAlbumFormats 被设置为接收一张专辑和任意数量的格式:

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

...formats 语法声明参数,并结合字符串数组类型,可以让我们向函数传入任意数量的字符串:

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;

这是一个函数的类型别名。这个函数接收一个字符串,并返回一个数字。

然后,你可以用它来描述传给另一个函数的回调函数:

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),就会在控制台中看到它。但 TypeScript 针对这种情况有一个特殊类型:当一个函数的返回值应该被有意忽略时,就使用它。这个类型叫作 void。如果你悬停在 console.log 中的 .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;
};

现在,你的函数必须返回一个最终解析为 UserPromise

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

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

如果没有传入姓氏,就只返回名字。否则,它会返回 firstlast 拼接后的结果:

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

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

当用名字和姓氏调用 concatName 时,函数会按预期工作,没有错误:

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

然而,当只用名字调用 concatName 时,会得到一个错误:

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

错误信息是:

Expected 2 arguments, but got 1.

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

参见:totalts.link/essentials-…

解决方案

通过在参数末尾添加问号,可以把它标记为可选:

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 作为默认姓氏。

参见:totalts.link/essentials-…

解决方案

要在 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.

你会如何更新这个剩余参数,指定它应该是一个字符串数组?

参见:totalts.link/essentials-…

解决方案

使用剩余参数时,传给函数的所有参数都会被收集到一个数组中。这意味着 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”错误。

需要更新 makeChange 函数的类型注解,让它返回一个修改后的用户。例如,你不应该能够返回一个值为 123name,因为在 User 类型中,name 是字符串:

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

你会如何把 makeChange 标注为一个函数,让它接收一个 User 并返回一个 User

参见:totalts.link/essentials-…

解决方案

我们先把 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 下面会出现错误,因为你说过 makeChange 不接收任何参数:

// 在 user.map() 函数中

return makeChange(u)

// 悬停在 u 上会显示:

Expected 0 arguments, but got 1.

这说明你需要给 makeChange 函数类型添加一个参数。

在这个例子中,你会指定 userUser 类型:

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 添加类型,让每个错误都被解决?

参见:totalts.link/essentials-…

解决方案

我们先把 listener 参数注解为一个函数。暂时指定它返回一个字符串:

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

问题是,现在当你用一个什么都不返回的函数调用 addClickEventListener 时,会出现错误:

addClickEventListener(() => {
  // `() => {` 下面出现红色波浪线

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

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

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

错误信息告诉你,监听函数返回的是 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 的类型来修复这个错误吗?

参见:totalts.link/essentials-…

解决方案

解决方案是把 callback 的类型改成 () => void

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

现在你可以毫无问题地传入 returnString。这是因为 returnString 返回一个字符串,而 void 会告诉 TypeScript:在比较它们时忽略返回值。如果你真的不关心一个函数的结果,就把它标注为 () => 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 关键字,返回类型也会变成 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

在不修改 fetchresponse.json() 调用的情况下,你如何把 data 标注为数字?这里有两种可能的解决方案。

参见:totalts.link/essentials-…

解决方案

你可能会想尝试向 fetch 传递一个类型参数,就像使用 MapSet 那样。不过,悬停在 fetch 上,你会看到它并不接受类型参数:

// 这样行不通!

const response = fetch<number>("https://api.example.com/data");
// number 下面出现红色波浪线

// 悬停在 fetch 上会显示:

function fetch(
  input: RequestInfo | URL,
  init?: RequestInit | undefined,
): Promise;

你也不能给 response.json() 添加类型注解,因为它同样不接受类型参数:

// 这样也行不通!

const data: number = await response.json<number>();
// number 下面出现红色波浪线

// 悬停在 number 上会显示:

Expected 0 type arguments, but got 1.

一种可行的方法,是指定 data 是一个数字:

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

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

不过,解决这个问题的最佳方式,是给函数添加返回类型。在这个例子中,它应该是数字:

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

现在 data 被标注为数字了,只是你的返回类型注解下面会出现一个错误:

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<> 中,你可以确保在确定类型之前,数据已经被 await 处理。

总结

本章介绍了 TypeScript 类型系统中的基础概念,重点是类型注解和类型推断。你学习了什么时候应该显式给代码添加注解,以及什么时候 TypeScript 可以自动推断类型。

TypeScript 提供了像 stringnumberbooleansymbolbigintnullundefined 这样的基本类型,它们对应 JavaScript 的原始值。这些类型构成了更复杂类型注解的基础构件。

函数参数总是需要类型注解,因为 TypeScript 无法推断它们应该接收什么类型。变量注解通常是可选的,因为 TypeScript 可以根据赋值推断类型,但函数参数需要显式类型。

any 类型会完全禁用 TypeScript 的类型检查,允许任何东西被赋值或访问,并且不会出现错误。虽然它作为逃生出口很有用,但过度使用 any 会违背使用 TypeScript 的目的。

对象字面量类型使用花括号语法描述对象形状,可选属性则使用 ? 操作符标记。通过 type 关键字创建的类型别名,可以让你定义可复用类型,并在模块之间共享它们。

数组可以使用方括号语法来标注,例如 string[],也可以使用泛型语法 Array<string>。元组指定固定长度的数组,其中每个位置都有自己的类型,这对于在不创建新类型的情况下组合相关数据非常有用。

有些函数,例如 SetMap,可以使用尖括号接收类型参数,而另一些函数,例如 JSON.parse,则不能。你可以通过悬停在函数上,查看它们是否接受类型参数。

函数类型描述函数本身的形状,包括参数类型和返回类型。可选参数使用 ?,默认参数使用 =,剩余参数则使用带有数组类型的展开语法。

void 类型表示函数不会返回有意义的值,而异步函数必须返回 Promise<T>,其中 T 是最终解析出来的值类型。这些概念构成了有效使用 TypeScript 类型系统的必要基础。