联合类型、字面量与类型收窄

5 阅读1分钟

联合类型、字面量与类型收窄

掌握 TypeScript 联合类型和字面量类型、类型收窄、”unknown”和”never”类型、可辨识联合。

5

联合、字面量和类型收窄简介

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

联合类型和字面量类型

联合类型

联合类型是 TypeScript 表示一个值可以是“这种类型或那种类型”的方式。

这种情况在 JavaScript 中经常出现。想象一下,你有一个值,它在周二时是 string 类型,其他时间则是 null

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

如果我们将鼠标悬停在 message 上,我们可以看到 TypeScript 推断出其类型为 string | null

这是一个联合类型。它意味着 message 可以是一个 stringnull

声明联合类型

我们可以声明自己的联合类型。

例如,你可能有一个 id,它既可以是 string 也可以是 number

const logId = (id: string | number) => {

  console.log(id)

}

这意味着 logId 可以接受 stringnumber 作为参数,但不能接受 boolean

logId('abc')

logId(123)

logId(true)
// 类型 'boolean' 的参数不能赋给类型 'string | number' 的参数。2345

要创建联合类型,使用 | 操作符来分隔类型。联合类型中的每种类型都称为联合的“成员”。

联合类型在创建自己的类型别名时也有效。例如,我们可以将我们之前的定义重构为一个类型别名:

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

type TrueOrFalse = true | false

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 类型(原文为 AnalogFormat,此处根据上下文修改为 PhysicalFormat)用于处理实体格式的函数。AlbumFormat 类型可用于处理所有情况的函数。

这样,我们可以确保每个函数只处理它应该处理的情况,如果尝试将不正确的格式传递给函数,TypeScript 将会报错。

const getAlbumFormats = (format: PhysicalFormat) => {

  // 函数体

}

getAlbumFormats('MP3')
// 类型 '"MP3"' 的参数不能赋给类型 'PhysicalFormat' 的参数。2345

练习

练习 1:stringnull

这里我们有一个名为 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' 的参数不能赋给类型 'string' 的参数。2345

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

通常我们不会显式地用 null 调用 getUsername 函数,但在这种情况下,处理 null 值很重要。例如,我们可能从数据库的用户记录中获取 username,根据用户注册的方式,用户可能有名也可能没有名字。

目前,username 参数只接受 string 类型,对 null 的检查并没有起作用。更新函数参数的类型,以便解决错误并且函数可以处理 null

练习 1:stringnull

练习 2:限制函数参数

这里我们有一个 move 函数,它接受一个 string 类型的 direction 和一个 number 类型的 distance

function move(direction: string, distance: number) {

  // 在给定方向上移动指定的距离

}

函数的实现并不重要,但想法是我们希望能够向上、下、左或右移动。

以下是调用 move 函数的样子:

move('up', 10)

move('left', 5)

为了测试这个函数,我们有一些 @ts-expect-error 指令,告诉 TypeScript 我们期望以下行会抛出错误。

然而,由于 move 函数目前接受 string 类型的 direction 参数,我们可以传入任何我们想要的字符串,即使它不是一个有效的方向。还有一个测试,我们期望传入 20 作为距离是无效的,但它也被接受了。

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

move(

  // @ts-expect-error - "up-right" 不是一个有效的方向
// 未使用的 '@ts-expect-error' 指令。2578
  'up-right',

  10,

)

move(

  // @ts-expect-error - "down-left" 不是一个有效的方向
// 未使用的 '@ts-expect-error' 指令。2578
  'down-left',

  20,

)

你的挑战是更新 move 函数,使其只接受字符串 "up""down""left""right"。这样,当我们尝试传入任何其他字符串时,TypeScript 将会抛出错误。

练习 2:限制函数参数

解决方案 1:stringnull

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

function getUsername(username: string | null) {

  // 函数体

}

通过此更改,getUsername 函数现在将接受 null 作为 username 参数的有效值,并且错误将得到解决。

解决方案 2:限制函数参数

为了限制 direction 的取值,我们可以使用字面量值(在本例中为字符串)的联合类型。

具体如下所示:

function move(direction: 'up' | 'down' | 'left' | 'right', distance: number) {

  // 在给定方向上移动指定的距离

}

通过此更改,我们现在可以对可能的 direction 值进行自动补全。

为了使代码更整洁,我们可以创建一个名为 Direction 的新类型别名,并相应地更新参数:

type Direction = 'up' | 'down' | 'left' | 'right'

function move(direction: Direction, distance: number) {

  // 在给定方向上移动指定的距离

}

类型收窄

更宽泛 vs 更窄的类型

有些类型是其他类型的更宽泛版本。例如,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",我们不能传入任意的 string

const recordOfSizes = {

  small: 'small',

  large: 'large',

}

const logSize = (size: 'small' | 'large') => {

  console.log(recordOfSizes[size])

}

logSize('medium')
// 类型 '"medium"' 的参数不能赋给类型 '"small" | "large"' 的参数。2345

如果你熟悉集合论中“子类型”和“超类型”的概念,这是一个类似的想法。"small"string 的子类型(它更具体),而 string"small" 的超类型。

联合类型比其成员更宽泛

联合类型比其成员更宽泛。例如,string | number 比单独的 stringnumber 更宽泛。

这意味着我们可以将 stringnumber 传递给接受 string | number 的函数:

function logId(id: string | number) {

  console.log(id)

}

logId('abc')

logId(123)

然而,反过来则不行。我们不能将 string | number 传递给只接受 string 的函数。

例如,如果我们将这个 logId 函数更改为只接受一个 number,当我们尝试将 string | number 传递给它时,TypeScript 会抛出一个错误:

function logId(id: number) {

  console.log(\`ID 是 ${id}\`)

}

type User = {

  id: string | number

}

const user: User = {

  id: 123,

}

logId(user.id)
// 类型 'string | number' 的参数不能赋给类型 'number' 的参数。
//   类型 'string' 不能赋给类型 'number'。2345

将鼠标悬停在 user.id 上显示:

类型 'string | number' 的参数不能赋给类型 'number' 的参数。

  类型 'string' 不能赋给类型 'number'

所以,重要的是将联合类型视为比其成员更宽泛的类型。

什么是类型收窄?

TypeScript 中的类型收窄允许我们使用运行时代码将一个更宽泛的类型变得更窄。

当我们想根据值的类型执行不同的操作时,这可能很有用。例如,我们可能想以不同于处理 number 的方式处理 string,或者以不同于处理 "large" 的方式处理 "small"

使用 typeof 进行类型收窄

我们可以收窄值类型的一种方法是使用 typeof 操作符,并结合 if 语句。

考虑一个函数 getAlbumYear,它接受一个参数 year,该参数可以是 stringnumber。以下是我们如何使用 typeof 操作符来收窄 year 的类型:

const getAlbumYear = (year: string | number) => {

  if (typeof year === 'string') {

    console.log(\`专辑发行于 ${year.toUpperCase()}.\`) // \`year\` 是 string

  } else if (typeof year === 'number') {

    console.log(\`专辑发行于 ${year.toFixed(0)}.\`) // \`year\` 是 number

  }

}

这看起来很简单,但重要的是要理解幕后发生的一些事情。

作用域在类型收窄中扮演着重要角色。在第一个 if 块中,TypeScript 理解 year 是一个 string,因为我们使用了 typeof 操作符来检查它的类型。在 else if 块中,TypeScript 理解 year 是一个 number,因为我们使用了 typeof 操作符来检查它的类型。

这使我们可以在 yearstring 时调用 toUpperCase,在 yearnumber 时调用 toFixed

然而,在条件块之外的任何地方,year 的类型仍然是联合类型 string | number。这是因为类型收窄仅适用于块的作用域内。

为了说明,如果我们在 year 联合类型中添加一个 boolean,第一个 if 块最终仍将得到 string 类型,但 else 块最终将得到 number | boolean 类型:

const getAlbumYear = (year: string | number | boolean) => {

  if (typeof year === 'string') {

    console.log(\`专辑发行于 ${year}.\`) // \`year\` 是 string

  } else if (typeof year === 'number') {

    console.log(\`专辑发行于 ${year}.\`) // \`year\` 是 number | boolean

  }

  console.log(year) // \`year\` 是 string | number | boolean

}

这是一个强有力的例子,说明了 TypeScript 如何能够读取你的运行时代码,并利用它来收窄一个值的类型。

其他类型收窄的方法

typeof 操作符只是收窄类型的一种方式。

TypeScript 可以使用其他条件操作符,如 &&||,并且会考虑真值性来强制转换布尔值。也可以使用其他操作符,如 instanceofin 来检查对象属性。你甚至可以通过抛出错误或使用提前返回来收窄类型。

我们将在接下来的练习中更详细地研究这些内容。

练习

练习 1:使用 if 语句进行类型收窄

这里有一个名为 validateUsername 的函数,它接受 stringnull,并且总是返回一个 boolean

function validateUsername(username: string | null): boolean {

  return username.length > 5
// 'username' 可能为 'null'。18047

  return false

}

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

it('对于有效的用户名应返回 true', () => {

  expect(validateUsername('Matt1234')).toBe(true)

  expect(validateUsername('Alice')).toBe(false)

  expect(validateUsername('Bob')).toBe(false)

})

然而,在函数体内部的 username 下方出现了一个错误,因为它可能为 null,而我们正试图访问它的属性。

it('对于 null 应返回 false', () => {

  expect(validateUsername(null)).toBe(false)

})

你的任务是重写 validateUsername 函数,添加类型收窄,以便处理 null 的情况并且所有测试都能通过。

练习 1:使用 if 语句进行类型收窄

这里有一行代码使用 document.getElementById 来获取一个 HTML 元素,它可以返回 HTMLElementnull

const appElement = document.getElementById('app')

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

type Test = Expect<Equal<typeof appElement, HTMLElement>>
// 类型 'false' 不满足约束 'true'。2344

你的任务是使用 throwappElement 被测试检查之前收窄其类型。

练习 2:通过抛出错误来进行类型收窄

练习 3:使用 in 进行类型收窄

这里我们有一个 handleResponse 函数,它接受一个 APIResponse 类型,这是一个两种对象类型的联合。

handleResponse 函数的目标是检查提供的对象是否具有 data 属性。如果具有,函数应返回 id 属性。如果不具有,则应抛出一个 Error,其消息来自 error 属性。

type APIResponse =

  | {

      data: {

        id: string

      }

    }

  | {

      error: string

    }

const handleResponse = (response: APIResponse) => {

  // 我们如何检查 'data' 是否在 response 中?

  if (true) {

    return response.data.id

  } else {

    throw new Error(response.error)

  }

}

目前,正如以下测试所示,抛出了几个错误。

第一个错误是 Property 'data' does not exist on type 'APIResponse' (属性 'data' 在类型 'APIResponse' 上不存在)

test('即使有错误也通过测试', () => {

  // 运行时没有错误

  expect(() =>

    HandleResponseOrThrowError({ // 注意:原文此处函数名与 handleResponse 不一致,按上下文理解应为 handleResponse

      error: 'Invalid argument',

    }),

  ).not.toThrowError()

  // 但是返回了数据,而不是错误。

  expect(

    HandleResponseOrThrowError({ // 注意:原文此处函数名与 handleResponse 不一致

      error: 'Invalid argument',

    }),

  ).toEqual("Should this be 'Error'?") // "这应该是 'Error' 吗?"

})

然后我们有相反的错误,即 Property 'error' does not exist on type 'APIResponse' (属性 'error' 在类型 'APIResponse' 上不存在):

属性 data 在类型 'APIResponse' 上不存在。

你的挑战是找到正确的语法,在 handleResponse 函数的 if 条件内收窄类型。

更改应在函数内部进行,而不修改代码的任何其他部分。

练习 3:使用 in 进行类型收窄

解决方案 1:使用 if 语句进行类型收窄

有几种不同的方法来验证用户名的长度。

选项 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 是否为 "string"

我们可以使用 typeof 来检查用户名是否为字符串:

function validateUsername(username: string | null): boolean {

  if (typeof username === 'string') {

    return username.length > 5

  }

  return false

}

这避免了空字符串的问题。

选项 3:检查 typeof username 是否不为 "string"

与上面类似,我们可以检查 typeof username !== "string"

在这种情况下,如果 username 不是字符串,我们知道它是 null,可以直接返回 false。否则,我们将返回长度大于 5 的检查结果:

function validateUsername(username: string | null | undefined): boolean { // 原文为 name,应为 username

  if (typeof username !== 'string') { // 原文为 name,应为 username

    return false

  }

  return username.length > 5

}

这表明 TypeScript 理解检查的反向情况。非常聪明。

选项 4:检查 typeof username 是否为 "object"

JavaScript 的一个奇怪特性是 null 的类型等于 "object"

TypeScript 知道这一点,所以我们实际上可以利用它。我们可以检查 username 是否是一个对象,如果是,我们可以返回 false

function validateUsername(username: string | null): boolean {

  if (typeof username === 'object') {

    return false

  }

  return username.length > 5

}
选项 5:将检查提取到其自身的变量中

最后,为了可读性和可重用性,你可以将检查存储在其自身的变量 isUsernameOK 中。

具体如下所示:

function validateUsername(username: string | null): boolean {

  const isUsernameOK = typeof username === 'string'

  if (isUsernameOK) {

    return username.length > 5

  }

  return false

}

TypeScript 非常智能,能够理解 isUsernameOK 的值对应于 username 是否为字符串。非常聪明。

以上所有选项都使用 if 语句通过 typeof 来收窄类型以执行检查。

无论你选择哪个选项,请记住,你始终可以使用 if 语句来收窄类型,并在条件通过的情况下添加代码。

这段代码的问题在于 document.getElementById 返回 null | HTMLElement。但我们希望在使用 appElement 之前确保它是一个 HTMLElement

我们很确定 appElement 存在。如果它不存在,我们可能希望尽早使应用程序崩溃,以便获得有关问题所在的明确错误信息。

所以,我们可以添加一个 if 语句来检查 appElement 是否为假值,然后抛出一个错误:

if (!appElement) {

  throw new Error('找不到 app 元素')

}

通过添加此错误条件,我们可以确保如果 appElementnull,我们将永远不会到达任何后续代码。

如果在 if 语句之后将鼠标悬停在 appElement 上,我们可以看到 TypeScript 现在知道 appElement 是一个 HTMLElement —— 它不再是 null。这意味着我们的测试现在也通过了:

console.log(appElement)
                const appElement: HTMLElement

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

像这样抛出错误可以帮助你在运行时识别问题。在这个特定情况下,它收窄了紧邻的 if 语句作用域之外的代码。太棒了。

解决方案 3:使用 in 进行类型收窄

你的第一反应可能是检查 response.data 是否为真值。

const handleResponse = (response: APIResponse) => {

  if (response.data) {
// 属性 'data' 在类型 'APIResponse' 上不存在。
//   属性 'data' 在类型 '{ error: string; }' 上不存在。2339
    return response.data.id
// 属性 'data' 在类型 'APIResponse' 上不存在。
//   属性 'data' 在类型 '{ error: string; }' 上不存在。2339
  } else {

    throw new Error(response.error)
// 属性 'error' 在类型 'APIResponse' 上不存在。
//   属性 'error' 在类型 '{ data: { id: string; }; }' 上不存在。2339
  }

}

但是你会得到一个错误。这是因为 response.data 仅在联合类型的某个成员上可用。TypeScript 不知道 response 是具有 data 属性的那个。

选项 1:更改类型

你可能想更改 APIResponse 类型,为两个分支都添加 .data

type APIResponse =

  | {

      data: {

        id: string

      }

    }

  | {

      data?: undefined

      error: string

    }

这当然是一种处理方法。但有一种内置的方法可以做到。

选项 2:使用 in

我们可以使用 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 为我们提供了一种很好的方法来收窄那些可能彼此具有不同键的对象。

unknownnever

让我们暂停一下,介绍另外两个在 TypeScript 中扮演重要角色的类型,尤其是在我们讨论“宽”和“窄”类型时。

最宽泛的类型:unknown

TypeScript 中最宽泛的类型是 unknown。它代表我们不知道是什么的东西。

如果你想象一个尺度,最宽泛的类型在顶部,最窄的类型在底部,那么 unknown 就在顶部。所有其他类型,如字符串、数字、布尔值、null、undefined 及其各自的字面量都可以赋值给 unknown,如其可赋值性图表所示:

可赋值性图表

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

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

// 任何东西都可以赋值给 unknown!

fn('hello')

fn(42)

fn(true)

fn({})

fn([])

fn(() => {})

以上所有函数调用都是有效的,因为 unknown 可以被任何其他类型赋值。

当你想要表示 JavaScript 中真正未知的事物时,unknown 类型是首选。例如,当你有来自外部来源(如表单输入或对 webhook 的调用)进入应用程序的东西时,它非常有用。

unknownany 有什么区别?

你可能想知道 unknownany 之间有什么区别。它们都是宽泛的类型,但有一个关键的区别。

any 并不真正符合我们对“宽”和“窄”类型的定义。它破坏了类型系统。它根本不是一个类型——它是一种选择退出 TypeScript 类型检查的方式。

any 可以赋值给任何东西,任何东西也可以赋值给 anyany 比所有其他类型都窄,同时也比所有其他类型都宽。

另一方面,unknown 是 TypeScript 类型系统的一部分。它比所有其他类型都宽,所以它不能赋值给任何东西(除了 anyunknown 自身)。

const handleWebhookInput = (input: unknown) => {

  input.toUppercase()
// 'input' 的类型是 'unknown'。18046
}

const handleWebhookInputWithAny = (input: any) => {

  // 没有错误

  input.toUppercase()

}

这意味着 unknown 是一个安全的类型,而 any 不是。unknown 表示“我不知道这是什么”,而 any 表示“我不在乎这是什么”。

最窄的类型:never

如果 unknown 是 TypeScript 中最宽泛的类型,那么 never 就是最窄的。

never 表示永远不会发生的事情。它位于类型层级的最底层。

你很少会自己使用 never 类型注解。相反,它会出现在错误消息和悬停提示中——通常在类型收窄时。

但首先,让我们看一个 never 类型的简单示例:

never vs void

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

const getNever = () => {

  // 这个函数永远不会返回!

}

当悬停在这个函数上时,TypeScript 会推断它返回 void,表明它基本上不返回任何东西。

// 将鼠标悬停在 `getNever` 上显示:

const getNever: () => void

然而,如果我们在函数内部抛出一个错误,函数将永远不会返回:

const getNever = () => {

  throw new Error('这个函数永远不会返回')

}

通过此更改,TypeScript 将推断该函数的类型为 never

// 将鼠标悬停在 `getNever` 上显示:

const getNever: () => never

never 类型表示永远不可能发生的事情。

never 类型有一些奇怪的含义。

你不能将任何东西赋值给 never,除了 never 本身。

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

fn('hello')
// 类型 'string' 的参数不能赋给类型 'never' 的参数。2345
fn(42)
// 类型 'number' 的参数不能赋给类型 'never' 的参数。2345
fn(true)
// 类型 'boolean' 的参数不能赋给类型 'never' 的参数。2345
fn({})
// 类型 '{}' 的参数不能赋给类型 'never' 的参数。2345
fn([])
// 类型 'never[]' 的参数不能赋给类型 'never' 的参数。2345
fn(() => {})
// 类型 '() => void' 的参数不能赋给类型 'never' 的参数。2345

// 这里没有错误,因为我们将 `never` 赋值给 `never`

fn(getNever())

然而,你可以将 never 赋值给任何东西:

const str: string = getNever()

const num: number = getNever()

const bool: boolean = getNever()

const arr: string[] = getNever()

这种行为起初看起来非常奇怪——但我们稍后会看到它为什么有用。

让我们更新我们的图表以包含 never

包含 never 的可赋值性图表

这几乎为我们提供了 TypeScript 类型层级的全貌。

练习

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

const somethingDangerous = () => {

  if (Math.random() > 0.5) {

    throw new Error('出错了')

  }

  return '一切正常'

}

try {

  somethingDangerous()

} catch (error) {

  if (true) {

    console.error(error.message)
// 'error' 的类型是 'unknown'。18046
  }

}

在上面的代码片段中,我们有一个名为 somethingDangerous 的函数,它有 50% 的几率抛出错误。

注意 catch 子句中的 error 变量被类型化为 unknown

现在假设我们只想在错误包含 message 属性时使用 console.error() 记录错误。我们知道错误通常带有 message 属性,如下例所示:

const error = new Error('一些错误消息')

console.log(error.message)

你的任务是更新 if 语句,使其具有正确的条件来检查 error 是否具有 message 属性,然后再记录它。查看练习的标题以获取提示……并且记住,Error 是一个类。

练习 1:使用 instanceof 收窄错误类型

练习 2:将 unknown 收窄为一个值

这里我们有一个 parseValue 函数,它接受一个类型为 unknownvalue

const parseValue = (value: unknown) => {

  if (true) {

    return value.data.id
// 'value' 的类型是 'unknown'。18046
  }

  throw new Error('解析错误!')

}

此函数的目标是返回 value 对象的 data 属性的 id 属性。如果 value 对象没有 data 属性,则应抛出错误。

以下是该函数的一些测试,向我们展示了需要在 parseValue 函数内部完成的类型收窄量:

it('应该处理 { data: { id: string } }', () => {

  const result = parseValue({

    data: {

      id: '123',

    },

  })

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

  expect(result).toBe('123')

})

it('当传入其他任何东西时应该报错', () => {

  expect(() => parseValue('123')).toThrow('解析错误!')

  expect(() => parseValue(123)).toThrow('解析错误!')

})

你的挑战是修改 parseValue 函数,使测试通过并且错误消失。我希望你挑战自己,通过在函数内部收窄 value 的类型来做到这一点。不要更改类型。这将需要一个非常大的 if 语句!

练习 2:将 unknown 收窄为一个值

练习 3:可重用的类型守卫

让我们想象一下,我们有两个非常相似的函数,每个函数都有一个很长的条件检查来收窄一个值的类型。

这是第一个函数:

const parseValue = (value: unknown) => {

  if (

    typeof value === 'object' &&

    value !== null &&

    'data' in value &&

    typeof value.data === 'object' && // value.data 原文类型检查有误,应为 object

    value.data !== null &&

    'id' in value.data &&

    typeof value.data.id === 'string'

  ) {

    return value.data.id

  }

  throw new Error('解析错误!')

}

这是第二个函数:

const parseValueAgain = (value: unknown) => {

  if (

    typeof value === 'object' &&

    value !== null &&

    'data' in value &&

    typeof value.data === 'object' && // value.data 原文类型检查有误,应为 object

    value.data !== null &&

    'id' in value.data &&

    typeof value.data.id === 'string'

  ) {

    return value.data.id

  }

  throw new Error('解析错误!')

}

两个函数都有相同的条件检查。这是一个创建可重用类型守卫的好机会。

目前所有测试都通过了。你的任务是尝试重构这两个函数以使用可重用的类型守卫,并删除重复的代码。事实证明,TypeScript 使这比你想象的要容易得多。

练习 3:可重用的类型守卫

解决这个挑战的方法是使用 instanceof 操作符来收窄 error 的类型。

在我们检查错误消息的地方,我们将检查 error 是否是 Error 类的实例:

if (error instanceof Error) {

  console.log(error.message)

}

instanceof 操作符也涵盖了从 Error 类继承的其他类,例如 TypeError

在这种情况下,我们将错误消息记录到控制台——但这可以用于在我们的应用程序中显示不同的内容,或者将错误记录到外部服务。

尽管在这个特定示例中它适用于各种 Error,但它无法覆盖有人抛出非 Error 对象的奇怪情况。

throw '这不是一个错误!'

为了更安全地防范这些边缘情况,最好包含一个 else 块,像这样抛出 error 变量:

if (error instanceof Error) {

  console.log(error.message)

} else {

  throw error

}

使用这种技术,我们可以安全地处理错误并避免任何潜在的运行时错误。

解决方案 2:将 unknown 收窄为一个值

这是我们的起点:

const parseValue = (value: unknown) => {

  if (true) {

    return value.data.id
// 'value' 的类型是 'unknown'。18046
  }

  throw new Error('解析错误!')

}

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

首先,我们将通过用类型检查替换 true 来检查 value 的类型是否为 object

const parseValue = (value: unknown) => {

  if (typeof value === 'object') {

    return value.data.id
// 属性 'data' 在类型 'object' 上不存在。2339
// 'value' 可能为 'null'。18047
  }

  throw new Error('解析错误!')

}

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

const parseValue = (value: unknown) => {

  if (typeof value === 'object' && 'data' in value) {
// 'value' 可能为 'null'。18047
    return value.data.id
// 'value.data' 的类型是 'unknown'。18046
  }

  throw new Error('解析错误!')

}

通过此更改,TypeScript 抱怨 value 可能为 null。这是因为,当然,typeof null"object"。谢谢你,JavaScript!

为了解决这个问题,我们可以在第一个条件中添加 && value (更准确的是 && value !== null) 来确保它不是 null

const parseValue = (value: unknown) => {

  if (typeof value === 'object' && value && 'data' in value) { // 原文为 value,更严谨应为 value !== null

    return value.data.id
// 'value.data' 的类型是 'unknown'。18046
  }

  throw new Error('解析错误!')

}

现在我们的条件检查通过了,但是我们仍然在 value.data 被类型化为 unknown 时遇到错误。

我们现在需要做的是将 value.data 的类型收窄为 object 并确保它不为 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' 在类型 'object' 上不存在。2339
  }

  throw new Error('解析错误!')

}

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

const parseValue = (value: unknown): string => {

  if (

    typeof value === 'object' &&

    value !== null &&

    'data' in value &&

    typeof value.data === 'object' && // 确保是 object 类型

    value.data !== null &&

    'id' in value.data &&

    typeof value.data.id === 'string'

  ) {

    return value.data.id

  }

  throw new Error('解析错误!')

}

现在,当我们悬停在 parseValue 上时,我们可以看到它接受一个 unknown 输入并始终返回一个 string

// 将鼠标悬停在 `parseValue` 上显示:

const parseValue: (value: unknown) => string

多亏了这个巨大的条件语句,我们的测试通过了,错误消息也消失了!

这通常不是你想要编写代码的方式。它有点乱。你可以使用像 Zod 这样的库来用更优雅的 API 来完成这个任务。但这是理解 unknown 和类型收窄在 TypeScript 中如何工作的一个好方法。

解决方案 3:可重用的类型守卫

第一步是创建一个名为 hasDataId 的函数来捕获条件检查:

const hasDataId = (value: unknown) => { // 原文 value 未指定类型,应为 unknown

  return (

    typeof value === 'object' &&

    value !== null &&

    'data' in value &&

    typeof value.data === 'object' && // 确保是 object 类型

    value.data !== null &&

    'id' in value.data &&

    typeof value.data.id === 'string'

  )

}

我们在这里没有给 value 指定类型——unknown 是合理的,因为它可以是任何东西。

现在我们可以重构这两个函数来使用这个类型守卫:

const parseValue = (value: unknown) => {

  if (hasDataId(value)) {

    return value.data.id // TypeScript 现在知道 value 有 value.data.id

  }

  throw new Error('解析错误!')

}

const parseValueAgain = (value: unknown) => {

  if (hasDataId(value)) {

    return value.data.id // TypeScript 现在知道 value 有 value.data.id

  }

  throw new Error('解析错误!')

}

令人难以置信的是,这就是 TypeScript 在 if 语句内部收窄 value 类型所需要的全部。它足够聪明,能够理解在 value 上调用 hasDataId 确保了 value 具有一个带有 id 属性的 data 属性。

我们可以通过将鼠标悬停在 hasDataId 上来观察到这一点:

// 将鼠标悬停在 `hasDataId` 上显示:

const hasDataId: (value: unknown) => value is {data: {id: string}}

我们看到的这个返回类型是一个类型谓词。它是一种表达方式:“如果此函数返回 true,那么该值的类型是 { data: { id: string } }”。

我们将在本书后面的章节中学习如何编写我们自己的类型谓词——但 TypeScript 能够推断出自己的类型谓词非常有用。

可辨识联合

在本节中,我们将研究 TypeScript 开发人员用来组织其代码的一种常见模式。它被称为“可辨识联合”。

要理解什么是可辨识联合,让我们首先看看它解决的问题。

问题所在:可选属性包

让我们想象一下我们正在为一个数据获取过程建模。我们有一个 State 类型,它有一个 status 属性,该属性可以处于三种状态之一:loadingsuccesserror

type State = {

  status: 'loading' | 'success' | 'error'

}

这很有用,但我们还需要捕获一些额外的数据。获取操作返回的数据,或者如果获取失败时的错误消息。

我们可以向 State 类型添加一个 errordata 属性:

type State = {

  status: 'loading' | 'success' | 'error'

  error?: string

  data?: string

}

让我们想象我们有一个 renderUI 函数,它根据输入返回一个字符串。

const renderUI = (state: State) => {

  if (state.status === 'loading') {

    return '加载中...'

  }

  if (state.status === 'error') {

    return \`错误: ${state.error.toUpperCase()}\`
// 'state.error' 可能为 'undefined'。18048
  }

  if (state.status === 'success') {

    return \`数据: ${state.data}\`

  }

}

这一切看起来都很好,除了我们在 state.error 上遇到的错误。TypeScript 告诉我们 state.error 可能为 undefined,我们不能在 undefined 上调用 toUpperCase

这是因为我们以一种不正确的方式声明了 State 类型。我们使得 errordata 属性与它们发生的状态没有关联。换句话说,可以创建在我们的应用程序中永远不会发生的类型:

const state: State = {

  status: 'loading',

  error: '这是一个错误', // 不应该在 "loading!" 状态下发生

  data: '这是数据', // 不应该在 "loading!" 状态下发生

}

我会将这种类型描述为“可选属性包”。这是一种过于松散的类型。我们需要收紧它,以便 error 只能在 error 状态下发生,而 data 只能在 success 状态下发生。

解决方案:可辨识联合

解决方案是将我们的 State 类型转换为可辨识联合。

可辨识联合是一种具有公共属性(“辨识符”)的类型,该辨识符是联合中每个成员独有的字面量类型。

在我们的例子中,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 是一个 string

const renderUI = (state: State) => {

  if (state.status === 'loading') {

    return '加载中...'

  }

  if (state.status === 'error') {

    return \`错误: ${state.error.toUpperCase()}\`
                            // (property) error: string
  }

  if (state.status === 'success') {

    return \`数据: ${state.data}\`

  }

}

这是由于 TypeScript 的类型收窄——它知道 state.status"error",所以它知道在 if 块内部 state.error 是一个 string

为了清理我们最初的类型,我们可以为每个状态使用一个类型别名:

type LoadingState = {

  status: 'loading'

}

type ErrorState = {

  status: 'error'

  error: string

}

type SuccessState = {

  status: 'success'

  data: string

}

type State = LoadingState | ErrorState | SuccessState

所以,如果你注意到你的类型类似于“可选属性包”,那么考虑使用可辨识联合是一个好主意。

练习

练习 1:解构可辨识联合

考虑一个名为 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) {
// 属性 'sideLength' 在类型 'Shape' 上不存在。2339
// 属性 'radius' 在类型 'Shape' 上不存在。2339
  if (kind === 'circle') {

    return Math.PI * radius * radius

  } else {

    return sideLength * sideLength

  }

}

然而,TypeScript 在 'radius''sideLength' 下方显示了错误。

你的任务是更新 calculateArea 函数的实现,以便从传入的 Shape 中解构属性时不会出错。提示:我在本章中展示的示例没有使用解构,但某些解构是可能的。

练习 1:解构可辨识联合

练习 2:使用 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 的类型并相应地计算面积。

练习 2:使用 switch 语句收窄可辨识联合

练习 3:可辨识元组

这里我们有一个 fetchData 函数,它返回一个 Promise,该 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',

        // 想象这里有一些改进的错误处理

        '发生了一个错误',

      ]

    }

    const data = await response.json()

    return ['success', data]

  } catch (error) {

    return ['error', '发生了一个错误']

  }

}

然而,如下面的测试所示,APIResponse 类型目前允许我们不希望出现的其他组合。例如,它允许在返回数据时传递错误消息:

async function exampleFunc() {

  const [status, value] = await fetchData()

  if (status === 'success') {

    console.log(value)

    type test = Expect<Equal<typeof value, User[]>>
// 类型 'false' 不满足约束 'true'。2344
  } else {

    console.error(value)

    type test = Expect<Equal<typeof value, string>>
// 类型 'false' 不满足约束 'true'。2344
  }

}

问题源于 APIResponse 类型是一个“可选属性包”。

需要更新 APIResponse 类型,以便返回的元组有两种可能的组合:

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

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

你的挑战是重新定义 APIResponse 类型,使其成为一个可辨识元组,只允许上面定义的 successerror 状态的特定组合。

练习 3:可辨识元组

练习 4:使用可辨识联合处理默认值

我们又回到了 calculateArea 函数:

function calculateArea(shape: Shape) {

  if (shape.kind === 'circle') {

    return Math.PI * shape.radius * shape.radius

  } else {

    return shape.sideLength * shape.sideLength

  }

}

到目前为止,测试用例都涉及到检查 Shapekindcircle 还是 square,然后相应地计算面积。

然而,新增了一个测试用例,用于处理未向函数传入 kind 的情况:

it('当未传入 kind 时,应计算圆的面积', () => {

  const result = calculateArea({
// 类型 '{ radius: number; }' 的参数不能赋给类型 'Shape' 的参数。
//   类型 '{ radius: number; }' 中缺少属性 'kind',但类型 'Circle' 中需要该属性。2345
    radius: 5,

  })

  expect(result).toBe(78.53981633974483)

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

})

TypeScript 在测试中的 radius 下方显示了错误:

测试期望如果未传入 kind,则应将形状视为圆形。然而,当前的实现并未考虑到这一点。

你的挑战是:

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

练习 4:使用可辨识联合处理默认值

解决方案 1:解构可辨识联合

在我们查看可行的解决方案之前,让我们看一个行不通的尝试。

一个不可行的参数解构尝试

既然我们知道 kind 存在于可辨识联合的所有分支中,我们可以尝试使用剩余参数语法来引入其他属性:

function calculateArea({kind, ...shape}: Shape) {

  // 函数的其余部分

}

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

function calculateArea({kind, ...shape}: Shape) {

  if (kind === 'circle') {

    const {radius} = shape
// 属性 'radius' 在类型 '{ radius: number; } | { sideLength: number; }' 上不存在。2339

    return Math.PI * radius * radius

  } else {

    const {sideLength} = shape
// 属性 'sideLength' 在类型 '{ radius: number; } | { sideLength: number; }' 上不存在。2339

    return sideLength * sideLength

  }

}

然而,这种方法行不通,因为 kind 属性已与形状的其余部分分离。因此,TypeScript 无法跟踪 kindshape 的其他属性之间的关系。radiussideLength 下方都有错误消息。

TypeScript 给出这些错误是因为它仍然无法保证函数参数中的属性,因为它尚不知道处理的是 Circle 还是 Square

可行的解构方案

我们不应在函数参数级别进行解构,而是将函数参数恢复为 shape

function calculateArea(shape: 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 的其他属性之间的关系。

总的来说,我更喜欢在处理可辨识联合时避免解构。但如果你想这样做,请在条件分支内部进行。

解决方案 2:使用 switch 语句收窄可辨识联合

第一步是清空 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 提供了自动补全。这是确保我们处理了可辨识联合所有情况的好方法。

未考虑所有情况

作为实验,注释掉 kindsquarecase

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 的情况返回值,那么对于任何 square 形状,输出都将是 undefined

// 将鼠标悬停在 `calculateArea` 上显示

function calculateArea(shape: Shape): number | undefined

Switch 语句与可辨识联合配合得非常好!

解决方案 3:解构可辨识元组联合

完成后,你的 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'

请注意,这种智能行为特定于可辨识元组,并且不适用于可辨识对象——正如我们在上一个练习中看到的那样。

解决方案 4:使用可辨识联合处理默认值

在我们查看可行的解决方案之前,让我们先看几个不太奏效的方法。

尝试 1:创建 OptionalCircle 类型

一个可能的首要步骤是通过舍弃 kind 属性来创建 OptionalCircle 类型:

type OptionalCircle = {

  radius: number

}

然后我们会更新 Shape 类型以包含这个新类型:

type Shape = Circle | OptionalCircle | Square

这个解决方案最初看起来可行,因为它解决了半径测试用例中的错误。

然而,这种方法会在 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 类型

与其开发一个新类型,不如修改 Circle 类型,使 kind 属性变为可选:

type Circle = {

  kind?: 'circle'

  radius: number

}

type Square = {

  kind: 'square'

  sideLength: number

}

type Shape = Circle | Square

此修改使我们能够区分圆形和方形。可辨识联合保持完整,同时也适应了未指定 kind 的可选情况。

然而,现在 calculateArea 函数内部出现了一个新错误:

function calculateArea(shape: Shape) {

  if (shape.kind === 'circle') {

    return Math.PI * shape.radius * shape.radius

  } else {

    return shape.sideLength * shape.sideLength
// 属性 'sideLength' 在类型 'Shape' 上不存在。
//   属性 'sideLength' 在类型 'Circle' 上不存在。2339
// 属性 'sideLength' 在类型 'Shape' 上不存在。
//   属性 'sideLength' 在类型 'Circle' 上不存在。2339
  }

}

错误告诉我们 TypeScript 不再能够将 shape 的类型收窄为 Square,因为我们没有检查 shape.kind 是否为 undefined

可以通过为 kind 添加额外的检查来修复此错误,但我们可以直接交换条件检查的工作方式。

我们将首先检查 square,然后回退到 circle

if (shape.kind === 'square') {

  return shape.sideLength * shape.sideLength

} else {

  // 此处 shape.radius 仍然可以访问,因为 else 分支中 shape 的类型是 Circle
  return Math.PI * shape.radius * shape.radius

}

通过首先检查 square,所有不是正方形的形状情况都默认为圆形。圆形被视为可选的,这保留了我们的可辨识联合并保持了函数的灵活性。

有时,仅仅调整一下运行时逻辑就能让 TypeScript 满意!

想成为 TypeScript 高手吗?

解锁专业精华版

TypeScript 专业精华版

查看原文上一章 核心类型与注解

[

下一章 对象

](/books/total-typescript-essentials/objects)