全栈 TypeScript——联合类型、字面量类型与收窄

0 阅读31分钟

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

在本章中,你会看到:当一个值可能属于多种类型之一时,TypeScript 如何提供帮助。你首先会学习如何使用联合类型来声明这些类型,然后会看到 TypeScript 如何根据你的运行时代码,把一个值的类型收窄下来。

联合类型

联合类型是 TypeScript 用来表达“一个值可以是这种类型,也可以是那种类型”的方式。这种情况在 JavaScript 中经常出现。想象一下,你有一个值,在星期二是字符串,其余时间是 null

const message = Date.now() % 2 === 0 ? "Hello Tuesdays!" : null;

如果你悬停在 message 上,会看到 TypeScript 推断出它的类型是 string | null

// 悬停在 message 上会显示:
const message: string | null;

这就是联合类型。它意味着 message 可以是字符串,也可以是 null

声明联合类型

要创建联合类型,可以使用 | 操作符分隔不同类型。你指定的每一种类型,都叫作这个联合类型的一个成员。

例如,假设你有一个接收 idlogId 函数:

const logId = (id: string | number) => {
  console.log(id);
};

这个语法意味着,logId 可以接收字符串或数字作为参数,但不能接收布尔值或其他任何类型:

logId("abc"); // "abc"

logId(123); // 123

logId(true); // true 下面出现红色波浪线

在创建自己的类型别名时,联合类型同样适用。例如,你可以把前面的定义重构成一个类型别名:

type Id = number | string;

function logId(id: Id) {
  console.log(id);
}

联合类型可以包含很多不同类型;它们不一定都必须是原始类型,也不需要彼此相关。如果联合类型变得太大,可以使用下面这种语法,也就是把 | 放在联合类型第一个成员之前,让它更易读:

type AllSortsOfStuff =
  | string
  | number
  | boolean
  | object
  | null
  | {
      name: string;
      age: number;
    };

联合类型是创建灵活类型定义的一个很好用的工具。

字面量类型

就像 TypeScript 允许你从多个类型中创建联合类型一样,它也允许你创建表示某个具体原始值的类型。这些类型叫作字面量类型,它们可以用来表示具有特定值的字符串、数字或布尔值,例如:

type YesOrNo = "yes" | "no";
type StatusCode = 200 | 404 | 500;

YesOrNo 类型中,| 操作符用于创建字符串字面量 "yes""no" 的联合。这意味着,YesOrNo 类型的值只能是这两个字符串之一。

你之前在 document.addEventListener 这样的函数中看到的自动补全,正是由这个特性驱动的:

document.addEventListener(
  // 自动补全会建议 DOMContentLoaded、mouseover 等等。
  "click",
  () => {},
);

addEventListener 的第一个参数是字符串字面量的联合类型,所以你会得到不同事件类型的自动补全。

将联合类型与联合类型组合

当你把两个联合类型再组成一个联合类型时,会发生什么?它们会合并成一个更大的联合类型。例如,你可以创建 DigitalFormatPhysicalFormat 类型,它们各自包含一组字面量值的联合:

type DigitalFormat = "MP3" | "FLAC";

type PhysicalFormat = "LP" | "CD" | "Cassette";

然后,你可以把 AlbumFormat 指定为 DigitalFormatPhysicalFormat 的联合:

type AlbumFormat = DigitalFormat | PhysicalFormat;

有了这些联合类型之后,你就可以确保每个函数只处理它应该处理的情况:DigitalFormat 类型可以用于处理数字格式的函数,PhysicalFormat 类型可以用于处理模拟格式的函数,而 AlbumFormat 类型可以用于处理所有情况的函数。

如果你试图向函数传入一个错误的格式,TypeScript 会抛出错误:

const getAlbumFormats = (format: PhysicalFormat) => {
  // 函数体
};

getAlbumFormats("MP3"); // "MP3" 下面出现红色波浪线

练习 5-1:string 或 null

这里有一个名为 getUsername 的函数,它接收一个 username 字符串。如果 username 不等于 null,你就返回一个新的插值字符串。否则,返回 "Guest"

function getUsername(username: string) {
  if (username !== null) {
    return `User: ${username}`;
  } else {
    return "Guest";
  }
}

在第一个测试中,你会调用 getUsername 并传入字符串 "Alice",它会按预期通过。不过,在第二个测试中,当你把 null 传给 getUsername 时,null 下面会出现红色波浪线:

const result = getUsername("Alice");

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

const result2 = getUsername(null); // null 下面出现红色波浪线

type test2 = Expect<Equal<typeof result2, string>>;

悬停在 null 上,会显示下面的错误信息:

Argument of type 'null' is not assignable to parameter of type 'string'.

通常,你不会显式地用 null 调用 getUsername 函数,但在这个例子中,处理 null 值很重要。例如,你可能是从数据库中的用户记录里获取用户名,而用户有没有名字,取决于他们是如何注册的。

目前,username 参数只接受 string 类型,而对 null 的检查并没有起任何作用。请更新函数参数的类型,让错误被解决,并让函数能够处理 null

参见:totalts.link/essentials-…

解决方案

解决方案是把 username 参数更新为 stringnull 的联合类型:

function getUsername(username: string | null) {
  // 函数体
}

做出这个修改后,getUsername 函数现在会把 null 作为 username 参数的有效值接受,错误也会被解决。

练习 5-2:限制函数参数

下面是一个 move 函数,它接收一个类型为 stringdirection,以及一个类型为 numberdistance

function move(direction: string, distance: number) {
  // 向给定方向移动指定距离。
}

函数的实现并不重要,重点是你希望能够向上、向下、向左或向右移动。

调用 move 函数可能看起来像这样:

move("up", 10);

move("left", 5);

为了测试这个函数,你有一些 @ts-expect-error 指令,用来告诉 TypeScript:你期望下面这些代码行会抛出错误。然而,由于 move 函数的 direction 参数接收的是一个字符串,你可以传入任何你想要的字符串,即使它不是一个有效方向。

这会导致 TypeScript 在 @ts-expect-error 指令下面画出红色波浪线:

move(
  // @ts-expect-error - "up-right" 不是有效方向
  // @ts-expect-error 下面出现红色波浪线
  "up-right",
  10,
);

move(
  // @ts-expect-error - "down-left" 不是有效方向
  // @ts-expect-error 下面出现红色波浪线
  "down-left",
  20,
);

请更新 move 函数,让它只接受 "up""down""left""right" 这些字符串。这样,当传入任何其他字符串时,TypeScript 就会抛出错误。

参见:totalts.link/essentials-…

解决方案

要限制 direction 可以是什么,可以使用字符串字面量值组成的联合类型。它看起来是这样的:

function move(direction: "up" | "down" | "left" | "right", distance: number) {
  // 向给定方向移动指定距离。
}

做出这个修改后,现在就会为可能的方向值提供自动补全建议。为了让代码更整洁,可以创建一个名为 Direction 的新类型别名,并相应地更新参数:

type Direction = "up" | "down" | "left" | "right";

function move(direction: Direction, distance: number) {
  // 向给定方向移动指定距离。
}

宽类型与窄类型

TypeScript 有“宽”类型和“窄”类型的概念。

有些类型是其他类型的更宽版本。例如,string 比字符串字面量 "small" 更宽。这是因为 string 可以是任何字符串,而 "small" 只能是字符串 "small"

反过来说,你可以说 "small" 是比 string 更窄的类型,因为它是字符串的一个更具体版本。类似地,404 是比 number 更窄的类型,true 是比 boolean 更窄的类型。

注意,这只适用于那些具有某种共享关系的类型。例如,"small" 并不是 number 的更窄版本,因为 "small" 本身不是数字。

在 TypeScript 中,一个类型的更窄版本,总是可以替代它的更宽版本。例如,如果一个函数接收 string,你可以传入 "small"

const logSize = (size: string) => {
  console.log(size.toUpperCase());
};

logSize("small");

但如果一个函数接收的是字面量 "small" 类型,你就不能传入任意字符串:

const recordOfSizes = {
  small: "small",
  large: "large",
};

const logSize = (size: "small" | "large") => {
  console.log(recordOfSizes[size]);
};

logSize("medium"); // "medium" 下面出现红色波浪线

如果你熟悉集合论中的子类型和超类型概念,这里是类似的思想:"small"string 的子类型,也就是更具体;而 string"small" 的超类型。

联合类型比它的成员更宽

联合类型比它的成员更宽。例如,string | number 比单独的 string 或单独的 number 更宽。这意味着,你可以把字符串或数字传给一个接收 string | number 的函数:

function logId(id: string | number) {
  console.log(id);
}

logId("abc");
logId(123);

不过,反过来就不行了。你不能把 string | number 传给一个只接收 string 的函数。例如,如果你把 logId 函数改成只接收数字,当你尝试把 string | number 传给它时,TypeScript 会抛出错误:

function logId(id: number) {
  console.log(`The id is ${id}`);
}

type User = {
  id: string | number;
};

const user: User = {
  id: 123,
};

logId(user.id); // user.id 下面出现红色波浪线

悬停在 user.id 上,会显示下面的信息:

Argument of type 'string | number' is not assignable to parameter of type 'number'.
  Type 'string' is not assignable to type 'number'.

所以,把联合类型理解为比它的成员更宽的类型非常重要。

收窄的过程

TypeScript 允许你使用运行时代码,把一个更宽的类型变得更窄。当你想根据一个值的类型做不同事情时,这会很有用。例如,你可能会用不同方式处理字符串和数字,或者用不同方式处理 "small""large"

你可以使用 typeof 操作符结合 if 语句,来收窄一个值的类型。看一个 getAlbumYear 函数,它接收一个参数 year,这个参数可以是字符串,也可以是数字。下面展示了如何使用 typeof 操作符收窄 year 的类型:

const getAlbumYear = (year: string | number) => {
  if (typeof year === "string") {
    console.log(`The album was released in ${year.toUpperCase()}.`); // year 是 string
  } else if (typeof year === "number") {
    console.log(`The album was released in ${year.toFixed(0)}.`); // year 是 number
  }
};

这看起来很直接,但关于幕后发生的事情,有几个重要点需要意识到。

作用域在收窄中扮演了很重要的角色。在第一个 if 块中,TypeScript 明白 year 是字符串,因为我们使用了 typeof 操作符检查它的类型。在 else if 块中,TypeScript 明白 year 是数字,因为我们同样使用了 typeof 操作符检查它的类型。

由于这种收窄,当 year 是字符串时,TypeScript 会允许你在它上面调用 toUpperCase;当 year 是数字时,TypeScript 会允许你在它上面调用 toFixed。不过,在条件块之外的任何地方,year 的类型仍然是联合类型 string | number。这是因为收窄只在代码块的作用域内生效。

为了说明这一点,如果你把 boolean 加入 year 的联合类型,第一个 if 块中的类型仍然会是 string,但 else 块中的类型会变成 number | boolean

const getAlbumYear = (year: string | number | boolean) => {
  if (typeof year === "string") {
    console.log(`The album was released in ${year}.`); // year 是 string
  } else if (typeof year === "number") {
    console.log(`The album was released in ${year}.`); // year 是 number
  }

  console.log(year); // year 是 string | number | boolean
};

typeof 操作符只是收窄类型的一种方式。TypeScript 还可以使用 &&|| 这样的条件操作符,并把真值性纳入考虑,用于布尔值强制转换。也可以使用其他操作符,比如 instanceof,以及用于检查对象属性的 in。你甚至可以通过抛出错误或提前返回来收窄类型。

你会在下面的练习中更仔细地观察这些方式。

练习 5-3:使用 if 语句收窄

这里有一个名为 validateUsername 的函数,它接收一个字符串或 null,并且总是返回一个布尔值:

function validateUsername(username: string | null): boolean {
  return username.length > 5; // username 下面出现红色波浪线

  return false;
}

检查用户名长度的测试会按预期通过:

it("should return true for valid usernames", () => {
  expect(validateUsername("Matt1234")).toBe(true);

  expect(validateUsername("Alice")).toBe(false);

  expect(validateUsername("Bob")).toBe(false);
});

不过,在函数体里的 username 下面有一个错误,因为你正在尝试从一个可能是 null 的对象上访问属性。

这意味着下面这个测试会失败:

it("Should return false for null", () => {
  expect(validateUsername(null)).toBe(false);
});

请重写 validateUsername 函数,添加收窄逻辑,让 null 的情况被处理,并让所有测试都通过。

参见:totalts.link/essentials-…

解决方案

你可以用几种不同方式验证用户名长度。

选项 1

你可以使用 if 语句检查 username 是否为真值。如果是,就返回 username.length > 5。否则,返回 false

function validateUsername(username: string | null): boolean {
  // 重写这个函数,让错误消失。
  if (username) {
    return username.length > 5;
  }

  return false;
}

这段逻辑有一个需要注意的地方:如果 username 是空字符串,它会返回 false,因为空字符串是假值。这符合本练习中你想要的行为,但一定要记住这一点。

选项 2

你可以使用 typeof 检查 username 是否是字符串:

function validateUsername(username: string | null): boolean {
  if (typeof username === "string") {
    return username.length > 5;
  }
  return false;
}

这会避免空字符串带来的问题。

选项 3

类似于前一个选项,你可以检查 typeof username !== "string"。在这种情况下,如果 username 不是字符串,你就知道它是 null,可以立刻返回 false。否则,就返回长度是否大于 5 的检查结果:

function validateUsername(username: string | null | undefined): boolean {
  if (typeof username !== "string") {
    return false;
  }
  return username.length > 5;
}

这表明 TypeScript 理解一个检查的反面。非常聪明。

选项 4

JavaScript 有一个奇怪之处:null 的类型等于 "object"。TypeScript 知道这一点,所以你可以利用它。检查 username 是否是一个对象,如果是,就返回 false

function validateUsername(username: string | null): boolean {
  if (typeof username === "object") {
    return false;
  }
  return username.length > 5;
}

选项 5

最后,出于可读性和可复用性的考虑,你可以把这个检查存储在自己的变量 isUsernameOK 中。TypeScript 足够聪明,可以理解这个变量的值对应着 username 是否是字符串:

function validateUsername(username: string | null): boolean {
  const isUsernameOK = typeof username === "string";
  if (isUsernameOK) {
    return username.length > 5;
  }

  return false;
}

所有这些选项都使用 if 语句,通过 typeof 收窄类型来执行检查。无论你选择哪种方式,都要记住:你可以使用 if 语句收窄类型,并在条件成立的情况下添加代码。

练习 5-4:通过抛出错误来收窄

下面这行代码使用 document.getElementById 获取一个 HTML 元素,而它可能返回 HTMLElement,也可能返回 null

const appElement = document.getElementById("app");

目前,一个用来检查 appElement 是否为 HTMLElement 的测试会失败:

type Test = Expect<Equal<typeof appElement, HTMLElement>>; // Equal<> 下面出现红色波浪线

请使用 throw,在测试检查之前收窄 appElement 的类型。

参见:totalts.link/essentials-…

解决方案

这段代码的问题在于,document.getElementById 返回的是 null | HTMLElement。在尝试使用 appElement 之前,你需要确保它是一个 HTMLElement

这里需要一个检查,确保 appElement 确实存在。添加一个 if 语句,检查 appElement 是否是假值,并在适当的时候抛出错误:

const appElement = document.getElementById("app");
if (!appElement) {
  throw new Error("Could not find app element");
}

通过添加这个错误条件,你可以确保如果 appElementnull,应用永远不会执行到后续代码。如果你在 if 语句之后悬停在 appElement 上,会看到 TypeScript 现在知道 appElement 是一个 HTMLElement;它不再是 null。这意味着测试现在会通过:

console.log(appElement);

// 悬停在 appElement 上会显示:
const appElement: HTMLElement;

type Test = Expect<Equal<typeof appElement, HTMLElement>>; // 通过

在这个例子中,TypeScript 会在直接的 if 语句作用域之外收窄代码,并通过抛出错误帮助你在运行时识别问题。

练习 5-5:使用 in 进行收窄

这里有一个 handleResponse 函数,它接收一个 APIResponse 类型,而这个类型是两种对象类型的联合。handleResponse 函数的目标是检查传入的对象是否有 data 属性。如果有,函数应该返回 id 属性。如果没有,就应该使用 error 属性中的消息抛出一个 Error

type APIResponse =
  | {
      data: {
        id: string;
      };
    }
  | {
      error: string;
    };

const handleResponse = (response: APIResponse) => {
  if (true) {
    return response.data.id;
  } else {
    throw new Error(response.error);
  }
};

目前,会抛出几个错误,下面的测试展示了这一点。第一个错误是:Property 'data' does not exist on type 'APIResponse'

test("passes the test even with the error", () => {
  // 运行时没有错误,所以这个测试通过:
  expect(() =>
    handleResponse({
      error: "Invalid argument",
    }),
  ).not.toThrowError();

  // 但这里返回的是 data,而不是错误,所以这个测试失败:
  expect(
    handleResponse({
      error: "Invalid argument",
    }),
  ).toEqual("Should this be 'Error'?");
});

然后还有一个相反的错误,也就是:Property 'error' does not exist on type 'APIResponse'

Property data does not exist on type 'APIResponse'.

请在 handleResponse 函数的 if 条件中找到正确语法,用来收窄类型。修改应该发生在函数内部,不要修改代码的其他部分。

参见:totalts.link/essentials-…

解决方案

你的第一反应可能是检查 response.data 是否为真值,如下所示:

const handleResponse = (response: APIResponse) => {
  // Property 'data' does not exist on type 'APIResponse'.
  // response.data 下面出现红色波浪线
  if (response.data) {
    return response.data.id;
  } else {
    throw new Error(response.error);
  }
};

但你会得到一个错误。这是因为 response.data 只存在于联合类型的其中一个成员上。TypeScript 并不知道 response 就是那个带有 data 的成员。处理这个问题有几种方式。

选项 1

你可能会想修改 APIResponse 类型,给联合类型的两个成员都添加一个 data 属性:

type APIResponse =
  | {
      data: {
        id: string;
      };
    }
  | {
      data?: undefined;
      error: string;
    };

这是一种处理方式,但也有一种内置方式,可以检查某个具体的键是否存在。

选项 2

你可以使用 in 操作符,检查 response 上是否存在某个具体键。

在这个例子中,它会检查 data 这个键:

const handleResponse = (response: APIResponse) => {
  if ("data" in response) {
    return response.data.id;
  } else {
    throw new Error(response.error);
  }
};

如果 response 不是带有 data 的那个成员,那么它一定就是带有 error 的那个成员,所以你可以用错误消息抛出一个 Error

你可以通过在 if 语句的两个分支中分别悬停在 .data.error 上来验证这一点。TypeScript 会显示它知道每种情况下 response 的类型。在这里使用 in,可以很好地收窄那些可能彼此具有不同键的对象。

unknown 和 never 类型

接下来介绍另外两个类型,它们在 TypeScript 中扮演着重要角色,尤其是在讨论宽类型和窄类型时。

最宽的类型:unknown

TypeScript 中最宽的类型是 unknown,它表示某个你并不知道的东西。

如果你想象一条刻度,最宽的类型在最上面,最窄的类型在最下面,那么 unknown 就在最上面。所有其他类型,比如 stringnumberbooleannullundefined,以及它们各自的字面量,都可以赋值给 unknown。这一点如它的可赋值性图所示,见图 5-1。

可赋值性图中,unknown 位于顶部,并显示所有类型,例如对象、原始类型和字面量,都可以赋值给 unknown

image.png

图 5-1:可赋值性图展示了哪些类型可以赋值给 unknown

考虑下面这个示例函数 fn,它接收一个类型为 unknowninput 参数:

const fn = (input: unknown) => {};

// 任何东西都可以赋值给 unknown!
fn("hello");
fn(42);
fn(true);
fn({});
fn([]);
fn(() => {});
fn(unknown);
fn(null);

这些函数调用都是有效的,因为任何其他类型都可以赋值给 unknown。当你想表示 JavaScript 中某个真正未知的东西时,unknown 类型是首选。例如,当你的应用中有来自外部来源的东西时,它非常有用,比如表单输入,或者一次 webhook 调用。

unknown 和 any 的区别

你可能会疑惑,unknownany 有什么区别。它们都是宽类型,但有一个关键区别。any 并不适合你对“宽”和“窄”类型的定义;它实际上会禁用类型系统,因为它其实并不算真正的类型。相反,它是一种退出 TypeScript 类型检查的方式。

any 可以赋值给任何东西,任何东西也都可以赋值给 anyany 既比所有其他类型更窄,也比所有其他类型更宽。另一方面,unknown 遵守 TypeScript 类型系统的规则。如果你试图访问它上面的任何属性,它会显示错误。你可以通过下面这个例子看到:

const handleWebhookInput = (input: unknown) => { // input 下面出现红色波浪线
  input.toUppercase();
};
// 悬停在 input 上会显示:
'input' is of type 'unknown'.

const handleWebhookInputWithAny = (input: any) => {  // 没有错误
  input.toUppercase();
};

handleWebhookInput 中,你不能对 input 做任何事情,因为它是 unknown。如果你访问它上面的任何属性,或者调用它的任何方法,TypeScript 都会给你警告。但正如你在前面 any 的例子中看到的那样,它允许你对 input 做任何事情。

这意味着,unknown 是安全类型,而 any 不是。你可以把它理解为:unknown 的意思是“我不知道这是什么”,而 any 的意思是“我不在乎这是什么”。

最窄的类型:never

如果说 unknown 是 TypeScript 中最宽的类型,那么 never 就是最窄的类型。never 表示永远不会发生的事情。它位于类型层级结构的最底部。

你很少会自己使用 never 类型注解。相反,它常常会出现在错误信息和悬停提示中,通常是在收窄时出现。你后面会看到这一点。但首先,我们来看一个 never 类型的简单例子。

我们考虑一个永远不会返回任何东西的函数:

const getNever = () => {
  // 这个函数永远不会返回!
};

当你悬停在这个函数上时,TypeScript 会推断它返回 void,表示它本质上不返回任何东西:

// 悬停在 getNever 上会显示:

const getNever: () => void;

不过,如果你在函数中抛出一个错误,那么这个函数就永远不会返回:

const getNever = () => {
  throw new Error("This function never returns");
};

做出这个修改后,TypeScript 会推断该函数的类型是 never

// 悬停在 getNever 上会显示:

const getNever: () => never;

never 类型表示某件事永远不可能发生。never 类型有一些奇怪的影响;除了 never 本身以外,你不能把任何东西赋值给 never

const fn = (input: never) => {};

// 括号里的所有东西下面都会出现红色波浪线:
fn("hello");
fn(42);
fn(true);
fn({});
fn([]);
fn(() => {});

// 这里没有错误,因为我们把 never 赋值给 never:
fn(getNever());

不过,你可以把 never 赋值给任何东西:

const str: string = getNever();

const num: number = getNever();

const bool: boolean = getNever();

const arr: string[] = getNever();

这种行为一开始看起来有点奇怪,但你后面会看到它为什么有用。我们来更新一下图表,把 never 也加入进去,如图 5-2 所示。

可赋值性图显示 never 位于底部,可以赋值给所有类型;unknown 位于顶部,原始类型、对象和字面量位于两者之间。

image.png

图 5-2:加入 never 后的可赋值性图。

这给出了 TypeScript 类型层级结构的完整图景。

练习 5-6:使用 instanceof 收窄错误

在 TypeScript 中,最常遇到 unknown 类型的地方之一,是使用 try...catch 语句处理潜在危险代码时。我们来看一个例子:

const somethingDangerous = () => {
  if (Math.random() > 0.5) {
    throw new Error("Something went wrong");
  }

  return "all good";
};

try {
  somethingDangerous();
} catch (error) {
  if (true) {
    console.error(error.message); // error.message 中的 error 下面出现红色波浪线
  }
}

在前面的代码片段中,你有一个名为 somethingDangerous 的函数,它有 50% 的概率抛出错误,也有 50% 的概率成功完成。注意,catch 子句中的 error 变量被标注为 unknown

现在假设你只想在 error 包含 message 属性时,使用 console.error() 记录这个错误。可以假设错误会带有 message 属性,就像下面这个例子:

const error = new Error("Some error message");

console.log(error.message);

请更新 if 语句,使它具有正确的条件,在记录错误之前检查 error 是否具有 message 属性。可以看看练习标题获取提示……并且记住,Error 是一个类。

参见:totalts.link/essentials-…

解决方案

你可以通过使用 instanceof 操作符收窄 error 来解决这个挑战。更新错误消息检查,判断 error 是否是 Error 类的实例:

if (error instanceof Error) {
  console.log(error.message);
}

instanceof 操作符也会覆盖那些继承自 Error 类的其他类,例如 TypeError。在这个例子中,你是在把错误消息记录到控制台,但在你的应用中,你也可以用它来展示不同内容,或者把错误记录到外部服务中。虽然它在这个例子中适用于各种 Error,但对于有人抛出非 Error 对象的奇怪情况,它并不能保护你,例如:

throw "This is not an error!";

为了避免这些边缘情况,应该包含一个 else 块,用来抛出 error 变量,如下所示:

if (error instanceof Error) {
  console.log(error.message);
} else {
  throw error;
}

使用这种技术,你可以以安全方式处理错误,并避免潜在的运行时错误。

练习 5-7:把 unknown 收窄为一个值

这里有一个 parseValue 函数,它接收一个类型为 unknown 的值:

const parseValue = (value: unknown) => {
  if (true) {
    return value.data.id; // value 下面出现红色波浪线
  }

  throw new Error("Parsing error!");
};

这个函数返回 value 对象的 data 属性中的 id 属性。如果 value 对象没有 data 属性,就应该抛出一个错误。

下面是这个函数的一些测试,它们展示了在 parseValue 函数中需要完成多少收窄:

it("Should handle a {data: {id: string}}", () => {
  const result = parseValue({
    data: {
      id: "123",
    },
  });

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

  expect(result).toBe("123");
});

it("Should error when anything else is passed in", () => {
  expect(() => parseValue("123")).toThrow("Parsing error!");

  expect(() => parseValue(123)).toThrow("Parsing error!");
});

请修改 parseValue 函数,让测试通过,并让错误消失。挑战一下自己:只在函数内部收窄 value 的类型,不修改类型定义。这会需要一个很大的 if 语句!

参见:totalts.link/essentials-…

解决方案

这是你的起点:

const parseValue = (value: unknown) => {
  if (true) {
    return value.data.id; // value 下面出现红色波浪线
  }

  throw new Error("Parsing error!");
};

要修复这个错误,你需要使用条件检查收窄类型。我们一步一步来。

首先,用类型检查替换 true,检查 value 的类型是否是对象:

if (typeof value === "object") {
  return value.data.id; // value 和 data 下面出现红色波浪线
}

然后,使用 in 操作符检查 value 参数是否有 data 属性:

if (typeof value === "object" && "data" in value) {
  // value 下面出现红色波浪线

  return value.data.id; // value 和 data 下面出现红色波浪线
}

做出这个修改后,TypeScript 会抱怨 value 可能是 null。这是因为,当然,typeof null"object"。谢谢你,JavaScript!

要修复这个问题,可以在第一个条件中添加 && value,确保它不是 null

if (typeof value === "object" && value && "data" in value) {
  return value.data.id; // value 和 data 下面出现红色波浪线
}

现在你的条件检查通过了,但你仍然会收到一个错误:value.data 被标注为 unknown

你需要把 value.data 的类型收窄为对象,并确保它不是 null。到这一步,你还会想指定返回类型为 string,以避免返回 unknown 类型:

const parseValue = (value: unknown): string => {
  if (
    typeof value === "object" &&
    value !== null &&
    "data" in value &&
    typeof value.data === "object" &&
    value.data !== null
  ) {
    return value.data.id; // id 下面出现红色波浪线
  }

  throw new Error("Parsing error!");
};

最后,添加一个检查,确保 id 是字符串。如果不是,TypeScript 会抛出错误:

const parseValue = (value: unknown): string => {
  if (
    typeof value === "object" &&
    value !== null &&
    "data" in value &&
    typeof value.data === "object" &&
    value.data !== null &&
    "id" in value.data &&
    typeof value.data.id === "string"
  ) {
    return value.data.id;
  }

  throw new Error("Parsing error!");
};

现在,当你悬停在 parseValue 上时,会看到它接收一个 unknown 输入,并且总是返回一个字符串:

// 悬停在 parseValue 上会显示:
const parseValue: (value: unknown) => string;

多亏了这个巨大的条件判断,测试通过了,错误信息也消失了。通常,这不是你真正想写代码的方式,因为它有点混乱。你可以使用像 Zod 这样的库,通过一个好得多的 API 来完成这件事。不过,使用大条件判断是理解 TypeScript 中 unknown 和收窄如何工作的一个很好方式。

可辨识联合类型

在这一节中,你会看到可辨识联合类型。这是 TypeScript 开发者常用来组织代码的一种模式。要理解什么是可辨识联合类型,我们先来看它解决的问题。

问题:一袋可选项

想象一下对一次数据获取进行建模。这里有一个 State 类型,它带有一个 status 属性,这个属性可以处于三种状态之一:loadingsuccesserror

type State = {
  status: "loading" | "success" | "error";
};

这很有用,但还需要捕获额外数据:数据获取返回的数据,或者获取失败时的错误消息。为了处理这个问题,你可以给 State 类型添加可选的 errordata 属性:

type State = {
  status: "loading" | "success" | "error";
  error?: string;
  data?: string;
};

假设你有一个 renderUI 函数,它接收 State,并根据输入返回一个字符串:

const renderUI = (state: State) => {
  if (state.status === "loading") {
    return "Loading. . .";
  }

  if (state.status === "error") {
    return `Error: ${state.error.toUpperCase()}`; // state.error 下面出现红色波浪线
  }

  if (state.status === "success") {
    return `Data: ${state.data}`;
  }
};

这看起来不错,除了 state.error 下面有一个错误。在这里,TypeScript 告诉你 state.error 可能是 undefined,而你不能在 undefined 上调用 toUpperCase

你会得到这个错误,是因为 State 类型中的 errordata 属性,与它们出现时对应的状态并没有关联起来。换句话说,State 类型被声明成了一种允许创建应用中永远不会发生的类型的方式。例如,一个状态可以同时包含 errordata,但这不应该被允许:

const state: State = {
  status: "loading",
  error: "This is an error", //"loading" 状态下不应该发生!
  data: "This is data", //"loading" 状态下不应该发生!
};

State 类型太松散了,可以被描述成“一袋可选项”。它需要被收紧,使得 error 只能在 error 状态下出现,而 data 只能在 success 状态下出现。

解决方案:可辨识联合类型

要修正这个问题,你需要把 State 类型改造成一个可辨识联合类型。可辨识联合类型是一种具有公共属性的类型,这个公共属性叫作判别字段,它是一个字面量类型,并且对联合类型中的每个成员来说都是唯一的。在这个例子中,status 属性就是判别字段。

我们把每一种 status 分离成单独的对象字面量:

type State =
  | {
      status: "loading";
    }
  | {
      status: "error";
    }
  | {
      status: "success";
    };

现在,你可以分别把 errordata 属性与 errorsuccess 状态关联起来:

type State =
  | {
      status: "loading";
    }
  | {
      status: "error";
      error: string;
    }
  | {
      status: "success";
      data: string;
    };

现在,在 renderUI 函数中悬停在 state.error 上,你会看到 TypeScript 知道 state.error 是一个字符串:

const renderUI = (state: State) => {
  if (state.status === "loading") {
    return "Loading. . .";
  }

  if (state.status === "error") {
    return `Error: ${state.error.toUpperCase()}`;
  }

  if (state.status === "success") {
    return `Data: ${state.data}`;
  }
};

这之所以发生,是因为 TypeScript 的收窄机制;它知道 state.status"error",所以也知道在这个 if 块中,state.error 是字符串。

类型能正常工作之后,还可以进一步整理,为每一种状态使用类型别名:

type LoadingState = {
  status: "loading";
};

type ErrorState = {
  status: "error";
  error: string;
};

type SuccessState = {
  status: "success";
  data: string;
};

type State = LoadingState | ErrorState | SuccessState;

如果你发现自己的类型像“一袋可选项”问题那样,不妨考虑把它们改造成可辨识联合类型。

练习 5-8:解构可辨识联合类型

考虑一个名为 Shape 的可辨识联合类型,它由两种类型组成:CircleSquare。这两种类型都有一个 kind 属性,充当判别字段:

type Circle = {
  kind: "circle";
  radius: number;
};

type Square = {
  kind: "square";
  sideLength: number;
};

type Shape = Circle | Square;

这个 calculateArea 函数从传入的 Shape 中解构出 kindradiussideLength 属性,并据此计算形状面积:

function calculateArea({kind, radius, sideLength}: Shape) {
  // radius 和 sideLength 下面出现红色波浪线

  if (kind === "circle") {
    return Math.PI * radius * radius;
  } else {
    return sideLength * sideLength;
  }
}

不过,TypeScript 会在 radiussideLength 下面显示错误:

// 悬停在 radius 上会显示:
Property 'radius' does not exist on type 'Shape'.

// 悬停在 sideLength 上会显示:
Property 'sideLength' does not exist on type 'Shape'.

请更新 calculateArea 函数的实现,让从传入的 Shape 中解构属性时不会出现错误。提示:本章前面的示例没有使用解构,但某些解构是可行的。

参见:totalts.link/essentials-…

解决方案

在你查看可工作的解决方案之前,先来看一个行不通的尝试。

由于你知道 kind 存在于可辨识联合类型的所有分支中,所以可以尝试使用剩余参数语法,把其他属性带过来:

function calculateArea({kind, ...shape}: Shape) {
  // 函数剩余部分
}

然后,在条件分支中,你可以指定 kind,并从 shape 对象中解构:

if (kind === "circle") {
  const {radius} = shape; // radius 下面出现红色波浪线

  return Math.PI * radius * radius;
} else {
  const {sideLength} = shape; // sideLength 下面出现红色波浪线

  return sideLength * sideLength;
}

然而,这种方式不起作用,因为 kind 属性已经和 shape 的其余部分分离开了。因此,TypeScript 无法追踪 kindshape 其他属性之间的关系。

radiussideLength 都有错误:

Property 'radius' does not exist on type '{radius: number;} | {sideLength: number;}'.
Property 'sideLength' does not exist on type '{radius: number;} | {sideLength: number;}'.

TypeScript 会给出这些错误,是因为它仍然无法在函数参数中保证这些属性存在,因为它还不知道自己面对的是 Circle 还是 Square

可工作的解构方案

不要在函数参数层面解构,而是把函数参数恢复为 shape

function calculateArea(shape: Shape) {
  // 函数剩余部分
}

然后,把解构移动到条件分支中进行:

if (shape.kind === "circle") {
  const {radius} = shape;

  return Math.PI * radius * radius;
} else {
  const {sideLength} = shape;

  return sideLength * sideLength;
}

if 条件内部,TypeScript 会识别出 shape 的确是一个 Circle,并允许你安全地访问 radius 属性。对于 else 条件中的 Square,也采用类似方式。

这种方式能够工作,是因为当解构发生在条件分支内部时,TypeScript 可以追踪 kindshape 其他属性之间的关系。注意,在使用可辨识联合类型时,常见做法是避免解构;但如果你确实想解构,就应该在条件分支内部进行。

练习 5-9:使用 switch 语句收窄可辨识联合类型

下面是上一练习中的 calculateArea 函数,但没有使用任何解构:

function calculateArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius * shape.radius;
  } else {
    return shape.sideLength * shape.sideLength;
  }
}

你的任务是把这个函数重构为使用 switch 语句,而不是 if...else 语句。switch 语句应该收窄 shape 的类型,并相应地计算面积。

参见:totalts.link/essentials-…

解决方案

首先,清空 calculateArea 函数,添加 switch 关键字,并把 shape.kind 指定为 switch 条件:

function calculateArea(shape: Shape) {
  switch (shape.kind) {
    case "circle": {
      return Math.PI * shape.radius * shape.radius;
    }
    case "square": {
      return shape.sideLength * shape.sideLength;
    }
    // 未来可以为更多形状添加额外 case
  }
}

注意,TypeScript 会为 switch 语句中的 case 提供自动补全。这是确保你处理了可辨识联合类型所有情况的一个很好方式。

作为实验,可以把 kindsquare 的那个 case 注释掉:

function calculateArea(shape: Shape) {
  switch (shape.kind) {
    case "circle": {
      return Math.PI * shape.radius * shape.radius;
    }
    // case "square": {
    // return shape.sideLength * shape.sideLength;
    //}
    // 未来可以为更多形状添加额外 case
  }
}

现在,当你悬停在函数上时,会看到返回类型是 number | undefined。这是因为 TypeScript 知道,如果你没有为 square 情况返回值,那么任何方形形状的输出都会是 undefined

// 悬停在 calculateArea 上会显示:
function calculateArea(shape: Shape): number | undefined;

switch 语句非常适合与可辨识联合类型一起使用!

练习 5-10:可辨识元组

这里有一个 fetchData 函数,它返回一个 promise,最终解析为一个 APIResponse 元组。这个元组由两个元素组成。第一个元素是一个字符串,用来表示响应类型。第二个元素在成功获取数据时使用 User 对象数组,在发生错误时使用字符串:

type APIResponse = [string, User[] | string];

下面是 fetchData 函数的样子:

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

    if (!response.ok) {
      return [
        "error",
        // 想象这里有一些改进后的错误处理
        "An error occurred",
      ];
    }

    const data = await response.json();

    return ["success", data];
  } catch (error) {
    return ["error", "An error occurred"];
  }
}

不过,正如下列测试所示,APIResponse 类型会允许其他一些你并不想要的组合。例如,它允许在返回数据时传入错误消息:

async function exampleFunc() {
  const [status, value] = await fetchData();

  if (status === "success") {
    console.log(value);

    type test = Expect<Equal<typeof value, User[]>>; // Equal<> 下面出现红色波浪线
  } else {
    console.error(value);

    type test = Expect<Equal<typeof value, string>>; // Equal<> 下面出现红色波浪线
  }
}

问题来自于 APIResponse 类型的形状像“一袋可选项”。APIResponse 类型需要被更新,使返回的元组只有两种可能组合:

如果第一个元素是 "error",那么第二个元素应该是错误消息。

如果第一个元素是 "success",那么第二个元素应该是 User 对象数组。

请重新定义 APIResponse 类型,让它成为一个只允许前面定义的成功和错误状态中特定组合的元组。

参见:totalts.link/essentials-…

解决方案

解决方案是让 APIResponse 类型看起来像这样:

type APIResponse = ["error", string] | ["success", User[]];

这个语法使用元组而不是对象,为 APIResponse 类型创建两种可能组合:错误状态和成功状态。

现在,APIResponse 类型是一个可辨识元组,其中判别字段就是第一个元素。

exampleFunc 函数中,可以使用数组解构语法,从 APIResponse 元组中取出 statusvalue

const [status, value] = await fetchData();

即使 statusvalue 变量是分离的,TypeScript 也会追踪它们背后的关系。如果检查 status 并确认它等于 "success",TypeScript 就能自动把 value 收窄为 User[] 类型:

// 悬停在 status 上会显示:
const status: "error" | "success";

注意,这种智能行为是可辨识元组特有的,不适用于可辨识对象,正如前一个练习所展示的那样。

练习 5-11:使用可辨识联合类型处理默认值

我们回到你的 calculateArea 函数:

function calculateArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius * shape.radius;
  } else {
    return shape.sideLength * shape.sideLength;
  }
}

到目前为止,测试用例都是检查 Shapekind 是圆形还是方形,然后相应地计算面积。不过,这里有一个新的测试用例:当没有向函数传入 kind 时,会发生什么:

it("Should calculate the area of a circle when no kind is passed", () => {
  const result = calculateArea({
    radius: 5, // radius 下面出现红色波浪线
  });

  expect(result).toBe(78.53981633974483);

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

TypeScript 会在测试中的 radius 下面显示错误:

// 悬停在 radius 上会显示:
Argument of type '{radius: number;}' is not assignable to parameter of type 'Shape'.
Property 'kind' is missing in type '{radius: number;}' but required in type 'Circle'.

这个测试期望的是:如果没有传入 kind,那么这个形状应该被视为圆形。不过,当前实现并没有考虑这种情况。

你的挑战是完成下面两件事:

  1. 更新 Shape 可辨识联合类型,使它允许省略 kind
  2. 调整 calculateArea 函数,确保 TypeScript 的类型收窄在函数内部正常工作。

参见:totalts.link/essentials-…

解决方案

在查看可工作的解决方案之前,先看看几个不太行得通的尝试。

尝试 1

一个可能的第一步,是创建一个 OptionalCircle 类型,去掉 kind 属性:

type OptionalCircle = {
  radius: number;
};

然后,更新 Shape 类型,把这个新类型包含进去:

type Shape = Circle | OptionalCircle | Square;

这个方案看起来似乎可行,因为它解决了 radius 测试用例中的错误。不过,这种方式会在 calculateArea 函数中重新引入错误。可辨识联合类型被破坏了,因为并不是每个成员都有 kind 属性:

function calculateArea(shape: Shape) {
  if (shape.kind === "circle") { // shape.kind 下面出现红色波浪线
    return Math.PI * shape.radius * shape.radius;
  } else {
    return shape.sideLength * shape.sideLength;
  }
}

尝试 2

与其创建一个新类型,也可以修改 Circle 类型,让 kind 属性变成可选:

type Circle = {
  kind?: "circle";
  radius: number;
};
type Square = {
  kind: "square";
  sideLength: number;
};
type Shape = Circle | Square;

这个修改允许你区分圆形和方形。可辨识联合类型仍然保持完整,同时也兼容了没有指定 kind 的可选情况。不过,现在 calculateArea 函数中出现了一个新错误:

// 在 calculateArea 函数中

if (shape.kind === "circle") {
  return Math.PI * shape.radius * shape.radius;
} else {
  return shape.sideLength * shape.sideLength; // sideLength 下面出现红色波浪线
}

这个错误告诉你,TypeScript 现在无法把 shape 的类型收窄为 Square,因为这里没有检查 shape.kind 是否为 undefined

这个错误可以通过添加对 kind 的检查来修复,但你也可以直接调换条件检查的顺序,先检查是否为 square,然后把剩余情况默认当作圆形:

if (shape.kind === "square") {
  return shape.sideLength * shape.sideLength;
} else {
  return Math.PI * shape.radius * shape.radius;
}

通过先检查 square,所有不是方形的 shape 情况都会默认落到圆形中。圆形被视为可选情况,这既保留了可辨识联合类型,也让函数保持灵活。有时候,翻转运行时逻辑就能让 TypeScript 满意!

总结

本章探索了 TypeScript 的联合类型和类型收窄,这是两个基础概念,可以帮助你管理那些可能属于多种类型的值。

联合类型使用 | 操作符来指定一个值可以是“这种类型或那种类型”。你可以直接声明它们,例如 string | number;也可以为了复用而创建类型别名。字面量类型进一步扩展了这一点,它可以表示具体的原始值,例如 "yes" | "no"200 | 404 | 500

TypeScript 有一套“宽”类型和“窄”类型的层级结构。unknown 类型位于顶部,是最宽的类型,它接受任何值,但在使用前需要先被收窄。never 类型位于底部,是最窄的类型,表示不可能出现的值。联合类型比它们的单个成员更宽。

类型收窄允许你使用运行时检查,把更宽的类型变得更窄。你可以使用 typeof 操作符、真值性检查、用于对象属性的 in 操作符、用于类实例的 instanceof 操作符,或者通过抛出错误来进行收窄。每一种收窄技术都在特定作用域内生效。

可辨识联合类型通过使用一个公共字面量属性作为判别字段,解决了“一袋可选项”问题。你不再使用可能创建不可能状态的可选属性,而是为每一种情况创建单独的对象类型,然后把它们联合在一起。

本章通过错误处理、API 响应和形状面积计算等实际练习展示了这些概念。switch 语句特别适合与可辨识联合类型一起使用,甚至元组也可以使用第一个元素作为判别字段来形成可辨识元组。