TypeScript-秘籍-全-

TypeScript 秘籍(全)

原文:zh.annas-archive.org/md5/9e6b4d535e73dcf4fdf7025512377b5e

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

我总是对编程语言的演变和它们对软件开发的影响感到兴奋。TypeScript 作为 JavaScript 的超集,也不例外。事实上,TypeScript 迅速崛起,成为最广泛使用的编程语言之一,在 Web 开发领域独树一帜。随着这门语言受到广泛的采用和赞誉,为它在《TypeScript Cookbook》中得到全面的解析是理所当然的。

作为一名热衷于使用 TypeScript 的用户,我必须说它为 JavaScript 带来的精确性和健壮性既有力量,又令人惊讶。其中一个关键原因是它的类型安全性,这解决了 JavaScript 长期以来的一个批评。通过允许开发者为变量定义严格的类型,TypeScript 使得在编译过程中捕获错误变得更加容易,显著提高了代码质量和可维护性。

TypeScript Cookbook是一本迫切需要的指南。前言恰如其分地介绍了 TypeScript 飞速增长的流行度。然而,对 TypeScript 日益增长的兴趣也凸显出开发者在采用过程中面临的挑战。正是在这方面,这本书将产生影响。

这本书强调实用性,精心设计,旨在解决 TypeScript 用户面临的现实挑战。它集合了一百多个配方,涵盖从基础到高级的各种概念。作为开发者,我们经常发现自己在与类型检查器斗争,而这本书将成为你的利剑和盾牌。通过深入的解释,你不仅将学会如何高效地使用 TypeScript,还将理解这些概念背后的思维过程。

TypeScript Cookbook的众多值得称赞之处之一是其积极应对 TypeScript 的快速演进。由于 TypeScript 每年都会发布定期版本,保持最新状态是一项艰巨的任务。这本书着眼于 TypeScript 的长远发展,并确保你的学习保持与时俱进,尽管技术变化迅速。

除了大量的配方外,本书还鼓励你理解 JavaScript 和 TypeScript 之间复杂的关系。理解这两种语言之间的共生关系对于发掘 TypeScript 的真正潜力至关重要。无论你是在处理类型断言、泛型,还是将 TypeScript 与流行的库和框架如 React 集成,这本书都涵盖了一切。

这本书在作为指南和参考书方面也做得非常出色。作为指南,它无缝地将你从初学者引导到专家。作为参考书,它在你的 TypeScript 之旅中始终是一个可靠的伴侣。书籍的组织井然有序,确保每一章节都可以独立消化,但当放在一起时又形成了一个统一的知识库。

随着 TypeScript 的普及势头不减,TypeScript Cookbook 预计将成为每位 TypeScript 爱好者必备的资源。从实际例子到解决方案的宝库,这本书是您在 TypeScript 令人兴奋的世界中航行所需的指南针。

无论您是初尝试还是希望深入探索 TypeScript 的深度,这本书都是知识的灯塔。我衷心祝贺 Stefan Baumgartner 编写了这部杰作,并欢迎大家品味 TypeScript 成功的秘诀。

让 TypeScript 的旅程开始吧。

Addy Osmani

工程主管

Google Chrome

2023 年 7 月

前言

你能读到这句话的唯一方法是打开这本书,无论是实体书还是电子书。这告诉我你对 TypeScript 很感兴趣,这是近年来最流行的编程语言之一。根据2022 年 JavaScript 状况调查,几乎 70%的参与者在积极使用 TypeScript。2022 年 StackOverflow 调查将 TypeScript 列为五大最受欢迎语言之一,用户满意度排名第四。到 2023 年初,TypeScript 每周在 NPM 上的下载量超过了 4000 万次。

毫无疑问:TypeScript 是一种现象!

尽管 TypeScript 很受欢迎,但仍然让很多开发者感到困扰。与类型检查器抗争是一个经常听到的说法;另一个是随便加几个any来让它闭嘴。有些人感到自己写代码只是为了取悦编译器,即使他们知道他们的代码必须工作。然而,TypeScript 的唯一目的是让 JavaScript 开发者更加高效和有效率。这个工具最终是未能实现其目标,还是我们作为开发者对这个工具的期望与其设计目标不同?

答案就在中间,这就是TypeScript 食谱的用武之地。在这本书中,你将找到 100 多个配方,涵盖从复杂项目设置到高级类型技术的所有内容。你将了解类型系统的复杂性和内部运作方式,以及它不得不做出的权衡和异常,以不干扰其基础:JavaScript。你还将学习方法论、设计模式和开发技术,以创建更好、更健壮的 TypeScript 代码。最终,你将不仅明白如何做某事,还将明白为什么

我的目标是为你提供一本指南,从初学者到专家,以及在你阅读完本书后可以长期使用的快速参考。由于 TypeScript 每年有四次发布,不可能在一本书中列出所有最新的功能。这就是为什么我们专注于编程语言的持久方面,为你准备所有即将到来的变化。欢迎来到 TypeScript 食谱。

这本书适合谁?

这本书适合那些已经掌握足够 JavaScript 知识,并初步涉足 TypeScript 的开发者、工程师和架构师。你理解类型的基本概念及其应用方式,也知道静态类型的即时优势。你现在正处于一个有趣的阶段:你需要更深入地了解类型系统,并且需要积极地与 TypeScript 合作,不仅确保应用程序稳健且可扩展,还要确保与同事之间的协作。

你希望了解在 TypeScript 中某些内容的行为方式,并理解其背后的原因。这正是你在 TypeScript Cookbook 中可以得到的。你将学习项目设置、怪癖及类型系统的行为,复杂类型及其用例,以及与框架的协作和应用类型开发方法论。本书旨在将你从初学者引导至专家。如果你需要一个指南来积极学习 TypeScript 的高级特性,并且希望这本书能成为你职业生涯中的可靠参考,那么这本书将不会让你失望。

本书内容包括:

《TypeScript Cookbook》撰写的一个主要目标是解决日常问题的解决方案。TypeScript 是一门卓越的编程语言,其类型系统的特性非常强大,以至于我们达到了一个人们挑战自己使用高级 TypeScript puzzles 的阶段。尽管这些脑筋急转弯很有趣,但它们通常缺乏真实世界的背景,因此不属于本书的内容。

我希望确保所呈现的内容是作为 TypeScript 开发人员在日常工作中会遇到的问题,这些问题源自真实世界的情况,并提供了整体性的解决方案。我将教会你在多种场景下可以使用的技术和方法论,而不仅仅是单一的配方。在整本书中,你会发现对先前配方的引用,展示了特定技术如何在新的上下文中应用。

示例要么直接摘自真实项目源代码,要么被简化到只展示概念本质而不需要太多领域知识。尽管有些示例非常具体,你也会看到很多 Person 对象,这些对象的名称是“Stefan”(你将能够看到我在整本书中的年龄变化)。

本书几乎专注于 TypeScript 在 JavaScript 基础上增加的特性;因此,要完全理解示例,你需要掌握相当数量的 JavaScript。我不指望你成为 JavaScript 大师,但至少要能阅读基本的 JavaScript 代码。由于 JavaScript 和 TypeScript 之间存在密切关系,本书的一些章节讨论 JavaScript 的特性及其行为,但始终从 TypeScript 的视角出发。

Cookbook 的设计旨在为您的问题提供快速解决方案:一种配方。在本书中,每个配方都以 discussion 结尾,为您提供更广泛的上下文和解决方案的含义。根据作者的风格,O'Reilly 的 Cookbook 聚焦于解决方案或讨论。TypeScript Cookbook 显然是一本 discussion 书籍。在我近 20 年的软件编写生涯中,我从未遇到一种解决方案适用于所有问题的情况。这就是为什么我想详细展示我们是如何得出结论、它们的含义以及权衡的原因。最终,这本书应该是像这样的讨论指南。当你需要作出明智的决策时,为什么要凭空猜测呢?

本书的组织方式

TypeScript Cookbook 从头到尾带你了解这门语言。我们从项目设置开始,讨论基本类型和类型系统的内部工作原理,最终进入条件类型和辅助类型等高级领域。我们继续探讨探索非常特定功能的章节,例如类的二元性和对 React 的支持,并最终学习如何最好地处理类型开发。

虽然有一个主线和逐步发展,但每一章和每个配方都可以单独消化。每一课都设计为指出与书中前后(或下一!)配方的关联,但每一章最终都是独立的。可以从头到尾阅读,也可以根据“选择你自己的冒险”方法及其众多参考进行使用。以下是内容的简要概述。

TypeScript 希望能够与所有类型的 JavaScript 兼容,而这些类型多种多样。在 第一章,“项目设置” 中,你将学习不同语言运行时、模块系统和目标平台的配置选项。

第二章,“基本类型” 将引导你了解类型层次结构,告诉你 anyunknown 的区别,教你哪些代码属于哪个命名空间,并回答关于选择类型别名还是接口描述对象类型的古老问题。

本书中较长的一章是 第三章,“类型系统”。在这里,你将学习关于联合类型和交集类型的一切,如何定义辨识联合类型,如何使用 assert neveroptional never 技术,以及根据用例如何缩小和扩大类型。在这章之后,你将理解为什么 TypeScript 使用类型断言而不是类型转换,为什么枚举通常不被看好,以及如何在结构类型系统中找到名义位。

TypeScript 拥有一个通用类型系统,我们将在第四章,“泛型”中详细介绍它。泛型不仅使你的代码更具重用性,还是进入 TypeScript 更高级特性的入口。这一章标志着你从 TypeScript 基础进入到类型系统更复杂领域的时刻,是第一部分的合适结尾。

第五章,“条件类型”解释了为什么 TypeScript 类型系统也是其自身的元编程语言。通过基于特定条件选择类型的可能性,人们发明了一些杰出的东西,比如一个完整的 SQL 解析器或者类型系统中的字典。我们使用条件类型作为工具,使静态类型系统在动态情况下更加灵活。

在第六章,“字符串模板字面类型”中,你将看到 TypeScript 如何在类型系统中集成字符串解析器。从格式字符串中提取名称,基于字符串输入定义动态事件系统,并动态创建标识符:似乎没有什么是不可能的!

你将在第七章,“可变元组类型”中略尝函数式编程的味道。在 TypeScript 中,元组具有特殊含义,帮助描述函数参数和类似数组的对象,并创建灵活的辅助函数。

更多元编程发生在第八章,“辅助类型”中。TypeScript 有几个内置辅助类型,使你更容易从其他类型推导出类型。在这一章中,你不仅学习如何使用它们,还学习如何创建自己的辅助类型。这一章也标志着TypeScript Cookbook的下一个断点,因为此时你已经学会了语言和类型系统的所有基本要素,可以在接下来的部分中应用它们。

在了解了类型系统所有细枝末节的八章之后,现在是时候在第九章,“标准库和外部类型定义”中将你的知识与他人完成的类型定义集成起来了。在本章中,你将看到一些与预期不同的工作情况,并了解如何弯曲内置类型定义以符合你的意愿。

在第十章,“TypeScript 与 React”中,你将学习如何将最流行的 JavaScript 框架之一集成到 TypeScript 中,使语法扩展JSX成为可能,并了解这如何融入 TypeScript 的整体概念。你还将学习如何为组件和钩子编写健壮的类型,并处理附加到实际库后的类型定义文件。

下一章讨论的是类,这是面向对象编程的一个支柱,在 JavaScript 之前的 TypeScript 中就已经存在。这导致了一个特性的有趣二元性,详细讨论在第十一章,“类”中。

书末以 第十二章,“类型开发策略” 结束。在这里,我专注于赋予你自主创建高级类型的技能,帮助你做出项目推进的正确决策,并处理那些为你验证类型的库。你还将了解特殊的解决方法和隐藏功能,并讨论如何命名泛型,或者高级类型是否有些超出预期。这一章特别有趣,因为在从新手到学徒的漫长旅程后,你将达到专家的地步。

所有示例都可以在 TypeScript Playground 或 CodeSandbox 项目中找到,访问 书籍网站。Playground 特别提供了一个中间状态,让你可以自己摸索和测试行为。我总是说,你不能仅靠阅读来学会编程语言;你需要积极编码,动手实践,才能理解所有元素是如何相互作用的。把这看作是一个邀请,去享受编程类型的乐趣。

本书使用的约定

编程约定

TypeScript 支持多种编程风格和格式选项。为了避免琐碎讨论,我选择使用 Prettier 自动格式化所有示例。如果你习惯于不同的格式风格——也许你更喜欢在类型的每个属性声明后使用逗号而不是分号——你完全可以继续使用你喜欢的方式。

TypeScript Cookbook 涵盖了大量示例,并涉及许多函数。编写函数有很多方法,我选择主要使用函数声明而不是函数表达式,除非在需要解释两者差异时关键。在其他所有情况下,这主要是一种口味选择,而不是技术原因。

所有示例均已根据 TypeScript 5.0 进行了检查,这是书写本书时的最新版本。TypeScript 不断变化,规则也在变化。本书确保我们主要关注那些长期可靠的内容,并可以跨版本信赖。在我预期会有进一步发展或根本性变化的地方,我会提供相应的警告和说明。

排版约定

本书使用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序列表,以及在段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

等宽斜体

显示应由用户提供值或由上下文确定值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素表示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可在https://typescript-cookbook.com下载。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至support@oreilly.com

本书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在程序和文档中使用它。除非您复制了代码的大部分内容,否则无需征得我们的许可。例如,编写一个使用本书多个代码块的程序不需要许可。销售或分发来自 O’Reilly 书籍的示例代码需要许可。引用本书并引用示例代码回答问题无需许可。将本书中大量示例代码整合到产品文档中需要许可。

我们感谢但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“TypeScript Cookbook by Stefan Baumgartner (O’Reilly). Copyright 2023 Stefan Baumgartner, 978-1-098-13665-9.”

如果您认为您对代码示例的使用超出了合理使用范围或上述许可,请随时与我们联系:permissions@oreilly.com

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和 200 多家其他出版商的大量文本和视频。欲了解更多信息,请访问https://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • 加利福尼亚州,塞巴斯托波尔,95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

我们为这本书制作了一个网页,列出勘误、示例和任何其他信息。您可以访问https://oreil.ly/typescript-cookbook

欲了解关于我们书籍和课程的最新消息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 观看我们:https://youtube.com/oreillymedia

致谢

Alexander Rosemann、Sebastian Gierlinger、Dominik Angerer 和 Georg Kothmeier 是我在有新计划时首先寻求意见的人。我们定期的会议和互动不仅令人愉快,而且为我提供了评估所有选择所需的必要反馈。他们是第一个听到这本书的人,也是第一个给予反馈的人。

在社交媒体上与 Matt Pocock、Joe Previte、Dan Vanderkam、Nathan Shively-Sanders 和 Josh Goldberg 的互动带来了许多新的想法。他们对 TypeScript 的方法可能与我的不同,但最终他们扩展了我的视野,并确保我不会过于固执己见。

Phil Nash、Simona Cotin 和 Vanessa Böhner 不仅是最终手稿的早期审阅者,还是长期以来一直在这里审视我的想法的伙伴和朋友。Addy Osmani 在我整个职业生涯中一直是我的灵感来源,我为他同意为我的新书撰写开篇感到非常自豪。

Lena Matscheko、Alexandra Rapeanu 和 Mike Kuss 毫不犹豫地向我提出基于他们真实经验的技术挑战和问题。在我缺少良好示例的时候,他们给我提供了大量优秀的源材料来提炼。

如果不是 Peter Kröner,我可能会错过 TypeScript 的所有新发展。每当有新的 TypeScript 版本发布时,他总是第一个找到我。我们在 TypeScript 发布上的一起播客已经成为传奇,尽管内容也越来越不只是关于 TypeScript。

我的技术编辑 Mark Halpin、Fabian Friedl 和 Bernhard Mayr 给予了我最好的技术反馈。他们质疑每一个假设,检查每一个代码示例,确保我的所有推理都是有意义的,而且没有遗漏。他们对细节的热爱和他们在高水平上讨论的能力,确保了这本书不仅仅是一堆热门观点的集合,而是一个建立在坚实基础上的指南和参考书。

如果没有 Amanda Quinn,这本书根本不会存在。在 2020 年编写了《TypeScript in 50 Lessons》之后,我以为我已经说尽了我对 TypeScript 的所有看法。是 Amanda 劝我试试写一本食谱书,看看我会发现哪些想法在第一本书中无法体现。三个小时后,我有了完整的提案和超过一百个条目的目录。Amanda 是对的:我还有很多话要说,我永远感激她的支持和指导。

Amanda 在项目早期阶段提供了帮助,Shira Evans 确保项目顺利进行,避免了任何偏离轨道的情况。她的反馈是无价的,她务实而且亲自动手的方法让合作变得非常愉快。

Elizabeth Faerm 和 Theresa Jones 负责制作。他们对细节的把握非常出色,确保制作阶段既令人兴奋,又实际上非常有趣!最终的结果是一次美好的体验,让我欲罢不能。

在写作过程中,我得到了 Porcupine Tree、Beck、植松伸夫、Camel、The Beta Band 以及许多其他人的巨大帮助。

这本书最大的贡献来自于我的家人。Doris、Clemens 和 Aaron 是我梦寐以求的一切,没有他们无尽的爱和支持,我将无法追求我的抱负。谢谢你们的一切。

第一章:项目设置

你想开始使用 TypeScript,太棒了!关键问题是:你如何开始呢?你可以用多种方式将 TypeScript 集成到你的项目中,每种方式根据项目需求略有不同。就像 JavaScript 在多个运行时上运行一样,有很多方法可以配置 TypeScript,使其符合你的目标需求。

本章涵盖了向你的项目引入 TypeScript 的所有可能性,作为 JavaScript 的扩展,为你提供基本的自动完成和错误指示,直至为 Node.js 和浏览器上的全栈应用程序设置完整的配置。

由于 JavaScript 工具链是一个拥有无限可能性的领域——有人说几乎每周都会发布新的 JavaScript 构建链,几乎与新框架一样多——本章更侧重于你可以单独使用 TypeScript 编译器完成的工作,不需要任何额外工具。

TypeScript 提供了你所有转译需求所需的一切,除了创建针对 Web 分发的压缩和优化的包的能力。像 ESBuildWebpack 这样的捆绑工具会处理这项任务。此外,还有一些设置包含其他与 TypeScript 良好兼容的转译器,如 Babel.js

捆绑工具和其他转译器不在本章的范围之内。请参考它们的文档,了解如何包含 TypeScript,并使用本章中的知识来获取正确的配置设置。

TypeScript 作为一个拥有十多年历史的项目,保留了一些来自早期的遗留物,为了兼容性考虑,TypeScript 不能轻易摆脱它们。因此,本章将重点介绍现代 JavaScript 语法和 Web 标准的最新发展。

如果你仍然需要支持 Internet Explorer 8 或 Node.js 10,首先:抱歉,这些平台确实很难开发。但是,第二:通过本章和 官方 TypeScript 文档,你将能够为旧平台整合知识。

1.1 JavaScript 的类型检查

问题

你希望尽可能少的工作量获取 JavaScript 的基本类型检查。

解决方案

在你想要类型检查的每个 JavaScript 文件开头添加一个带有 @ts-check 的单行注释。对于合适的编辑器,当 TypeScript 遇到不太匹配的事物时,你会立即看到红色波浪线。

讨论

TypeScript 被设计为 JavaScript 的超集,每个有效的 JavaScript 代码也是有效的 TypeScript 代码。这意味着 TypeScript 也非常擅长发现常规 JavaScript 代码中的潜在错误。

如果我们不想要一个完整的 TypeScript 设置,但想要一些基本的提示和类型检查来简化我们的开发工作流程,我们可以使用这个。

如果您只想对 JavaScript 进行类型检查,一个很好的前提条件是一个功能强大的编辑器或 IDE。一个非常适合 TypeScript 的编辑器是 Visual Studio Code。Visual Studio Code,或简称 VSCode,在 TypeScript 发布之前就是第一个主要使用 TypeScript 的项目。

如果您想编写 JavaScript 或 TypeScript,很多人推荐使用 VSCode。但实际上,只要支持 TypeScript 的编辑器都是很棒的。现在大多数编辑器都支持 TypeScript。

使用 Visual Studio Code 进行 JavaScript 的类型检查非常重要的一点是:当代码中有什么不对劲时,我们会看到红色波浪线,如您在 图 1-1 中所见。这是最低的入门障碍。TypeScript 的类型系统在处理代码库时有不同的严格级别。

tscb 0101

图 1-1. 代码编辑器中的红色波浪线:如果代码中有什么不对劲,它将会给予第一级反馈

首先,类型系统会通过使用情况从 JavaScript 代码中 推断 类型。如果您的代码中有这样一行:

let a_number = 1000;

TypeScript 将会正确推断 a_number 的类型为 number

JavaScript 的一个难点在于其类型是动态的。通过 letvarconst 进行绑定可以根据使用情况改变类型。^(1) 请看下面的例子:

let a_number = 1000;

if (Math.random() < 0.5) {
  a_number = "Hello, World!";
}

console.log(a_number * 10);

如果条件在下一行评估为真时,我们将一个数字赋给 a_number 并将绑定更改为一个 string。如果我们在最后一行试图对 a_number 进行乘法运算,这将会是一个问题。在大约 50% 的情况下,这个例子会产生意料之外的行为。

TypeScript 可以在这里提供帮助。通过在我们的 JavaScript 文件的顶部添加一行单行注释 @ts-check,TypeScript 将激活下一个严格级别:根据 JavaScript 文件中可用的类型信息对 JavaScript 文件进行类型检查。

在我们的例子中,TypeScript 将会发现我们试图将一个字符串分配给一个 TypeScript 推断为数字的绑定。我们在编辑器中会收到一个错误:

// @ts-check
let a_number = 1000;

if (Math.random() < 0.5) {
  a_number = "Hello, World!";
// ^-- Type 'string' is not assignable to type 'number'.ts(2322)
}

console.log(a_number * 10);

现在我们可以开始修复我们的代码,TypeScript 将会指导我们。

JavaScript 的类型推断可以在很大程度上帮助。在下面的例子中,TypeScript 通过乘法和加法操作以及默认值推断类型:

function addVAT(price, vat = 0.2) {
  return price * (1 + vat);
}

函数 addVat 接受两个参数。第二个参数是可选的,因为它已经被设置为默认值 0.2。如果您试图传递一个不起作用的值,TypeScript 将会警告您:

addVAT(1000, "a string");
//           ^-- Argument of type 'string' is not assignable
//               to parameter of type 'number'.ts(2345)

另外,由于我们在函数体内使用了乘法和加法操作,TypeScript 理解我们将会从这个函数返回一个数字:

addVAT(1000).toUpperCase();
//           ^-- Property 'toUpperCase' does not
//               exist on type 'number'.ts(2339)

在某些情况下,你需要更多的类型推断。在 JavaScript 文件中,你可以通过 JSDoc 类型注释来注释函数参数和绑定。JSDoc 是一种注释约定,允许你以一种不仅对人类可读而且对机器可解释的方式描述你的变量和函数接口。TypeScript 将获取你的注释并将其用作类型系统的类型:

/** @type {number} */
let amount;

amount = '12';
//       ^-- Argument of type 'string' is not assignable
//           to parameter of type 'number'.ts(2345)

/**
 * Adds VAT to a price
 *
 * @param {number} price The price without VAT
 * @param {number} vat The VAT [0-1]
 *
 * @returns {number}
 */
function addVAT(price, vat = 0.2) {
  return price * (1 + vat);
}

JSDoc 还允许你为对象定义新的复杂类型:

/**
 * @typedef {Object} Article
 * @property {number} price
 * @property {number} vat
 * @property {string} string
 * @property {boolean=} sold
 */

/**
 * Now we can use Article as a proper type
 * @param {[Article]} articles
 */
function totalAmount(articles) {
  return articles.reduce((total, article) => {
    return total + addVAT(article);
  }, 0);
}

尽管语法可能有点笨拙,但我们将在 Recipe 1.3 中找到更好的方法来注释对象。

假设你有一个通过 JSDoc 很好文档化的 JavaScript 代码库,只需在文件顶部添加一行就可以在代码出现问题时获得很好的理解。

1.2 安装 TypeScript

问题

编辑器中的红色波浪线并不足够:你希望获得命令行反馈、状态码、配置和选项来检查 JavaScript 并编译 TypeScript。

解决方案

通过 Node 的主要包注册表安装 TypeScript:NPM

讨论

TypeScript 是用 TypeScript 编写的,编译为 JavaScript,并使用 Node.js JavaScript 运行时 作为其主要执行环境。^(2) 即使你不是在编写一个 Node.js 应用程序,你的 JavaScript 应用程序的工具将在 Node 上运行。因此,请确保从 官方网站 获取 Node.js,并熟悉其命令行工具。

对于一个新项目,请确保用一个新的 package.json 初始化你的项目文件夹。这个文件包含了 Node 及其包管理器 NPM 理解你的项目内容的所有信息。使用 NPM 命令行工具在你项目文件夹中生成一个带有默认内容的新 package.json 文件:

$ npm init -y
注意

在本书中,你会看到应在你的终端执行的命令。为方便起见,我们展示这些命令,如它们将出现在 Linux、macOS 或 Windows 子系统可用的 BASH 或类似的 shell 中。前导的 $ 符号是一种指示命令的约定,但你不应该自己输入它。注意,所有命令也适用于常规的 Windows 命令行界面以及 PowerShell。

NPM 是 Node 的包管理器。它带有一个 CLI、一个注册表以及其他工具,允许你安装依赖项。一旦你初始化了你的 package.json,从 NPM 安装 TypeScript。我们将其安装为开发依赖项,这意味着如果你打算将你的项目作为库发布到 NPM 自身,TypeScript 将不会包含在内:

$ npm install -D typescript

你可以全局安装 TypeScript,这样你就可以在任何地方使用 TypeScript 编译器,但我强烈建议每个项目单独安装 TypeScript。根据你访问项目的频率不同,你会得到与项目代码同步的不同 TypeScript 版本。全局安装(和更新)TypeScript 可能会破坏你长时间没有碰过的项目。

注意

如果你通过 NPM 安装前端依赖项,你将需要额外的工具来确保你的代码在浏览器中运行:一个捆绑工具。TypeScript 不包含与支持的模块系统一起工作的捆绑工具,因此你需要设置适当的工具。像 Webpack 这样的工具很常见,ESBuild 也是如此。所有这些工具都设计用于执行 TypeScript。或者你可以完全使用原生支持,如第 1.8 节所述。

现在 TypeScript 已经安装好了,初始化一个新的 TypeScript 项目。使用 NPX 来执行:它允许你执行一个与项目相关联的命令行实用程序。

使用:

$ npx tsc --init

你可以运行项目的本地 TypeScript 编译器,并传递 init 标志来创建一个新的 tsconfig.json

tsconfig.json 是你的 TypeScript 项目的主要配置文件。它包含了所有配置信息,以便 TypeScript 理解如何解释你的代码,如何为依赖项提供类型,并且如果需要打开或关闭某些功能。

默认情况下,TypeScript 为你设置了这些选项:

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

让我们详细看看它们。

targetes2016,这意味着如果你运行 TypeScript 编译器,它将把你的 TypeScript 文件编译成 ECMAScript 2016 兼容的语法。根据你支持的浏览器或环境,你可以将其设置为更近期的版本(ECMAScript 版本以发布年份命名),或者像 es5 这样更旧的版本,适合需要支持非常旧的 Internet Explorer 版本的人。当然,我希望你不必这么做。

modulecommonjs。这允许你使用 ECMAScript 模块语法,但 TypeScript 会将其编译为 CommonJS 格式的输出。这意味着:

import { name } from "./my-module";

console.log(name);
//...

变成:

const my_module_1 = require("./my-module");
console.log(my_module_1.name);

一旦编译完成。CommonJS 是 Node.js 的模块系统,并因为 Node 的流行而变得非常普遍。Node.js 后来也采用了 ECMAScript 模块,我们将在第 1.9 节中处理这个问题。

esModuleInterop 确保了非 ECMAScript 模块的模块在导入后与标准对齐。forceConsistentCasingInFileNames 帮助使用区分大小写文件系统的人与使用不区分大小写文件系统的人协作。而 skipLibCheck 假设你安装的类型定义文件(稍后会详述)没有错误。因此,编译器不会检查它们,编译速度会略微加快。

TypeScript 最有趣的特性之一是严格模式。如果设置为 true,TypeScript 在某些领域的行为将有所不同。这是 TypeScript 团队定义类型系统行为方式的一种方式。

如果 TypeScript 引入了破坏性变更,因为对类型系统的看法变化,它将在严格模式下被整合。这最终意味着,如果你更新 TypeScript 并且始终运行在严格模式下,你的代码可能会出现问题。

为了给你适应变化的时间,TypeScript 还允许你逐个功能地启用或禁用某些严格模式特性。

除了默认设置,我强烈推荐另外两个:

{
  "compilerOptions": {
    //...
    "rootDir": "./src",
    "outDir": "./dist"
  }
}

这告诉 TypeScript 从 src 文件夹中获取源文件,并将编译后的文件放入 dist 文件夹。这种设置允许你将构建文件与你编写的文件分开。当然,你需要创建 src 文件夹;dist 文件夹将在编译后创建。

Oh, compilation. 项目设置完成后,需在 src 文件夹中创建一个 index.ts 文件:

console.log("Hello World");

扩展名 .ts 表明这是一个 TypeScript 文件。现在运行:

$ npx tsc

在命令行中查看编译器的工作。

1.3 保持侧边类型

问题

你想写普通的 JavaScript 而不需要额外的构建步骤,但仍然希望获得一些编辑器支持和函数的正确类型信息。然而,你不想像 Recipe 1.1 中展示的那样使用 JSDoc 定义复杂的对象类型。

解决方案

将类型定义文件放在“侧边”,并在 TypeScript 编译器中以“检查 JavaScript”模式运行。

讨论

渐进采用一直是 TypeScript 的一个专注目标。通过这种称为“侧边类型”的技术,你可以为对象类型和高级特性如泛型和条件类型编写 TypeScript 语法(参见 第五章),而不是笨重的 JSDoc 注释,但你仍然为你的实际应用编写 JavaScript。

在项目的某个地方,也许在 @types 文件夹中,创建一个类型定义文件。它的扩展名是 .d.ts,与常规的 .ts 文件相反,它的目的是保存声明而不是实际代码。

这里可以编写接口、类型别名和复杂类型:

// @types/person.d.ts

// An interface for objects of this shape
export interface Person {
  name: string;
  age: number;
}

// An interface that extends the original one
// this is tough to write with JSDoc comments alone.
export interface Student extends Person {
  semester: number;
}

请注意,你要从声明文件中导出接口。这样你就可以在 JavaScript 文件中导入它们:

// index.js
/** @typedef { import ("../@types/person").Person } Person */

第一行的注释告诉 TypeScript 从 @types/person 导入 Person 类型,并使其在名称 Person 下可用。

现在你可以使用这个标识符来注释函数参数或对象,就像你对基本类型如 string 所做的那样:

// index.js, continued

/**
 * @param {Person} person
 */
function printPerson(person) {
  console.log(person.name);
}

为了确保获得编辑器反馈,你仍然需要在 JavaScript 文件的开头设置 @ts-check,如 Recipe 1.1 中描述。或者,你可以配置项目始终检查 JavaScript。

打开 tsconfig.json 并将 checkJs 标志设置为 true。这将从你的 src 文件夹中获取所有 JavaScript 文件,并在编辑器中持续反馈类型错误。你也可以在命令行中运行 npx tsc 来查看是否有错误。

如果你不希望 TypeScript 将你的 JavaScript 文件转译为较旧版本的 JavaScript,请确保将 noEmit 设置为 true

{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
  }
}

因此,TypeScript 将检查你的源文件,并为你提供所需的所有类型信息,但不会修改你的代码。

这种技术也被称为可扩展。像 Preact 这样的知名 JavaScript 库就是这样运作的,并为他们的用户以及贡献者提供了出色的工具支持。

1.4 将项目迁移到 TypeScript

问题

你希望为项目获得 TypeScript 的全部好处,但需要迁移整个代码库。

解决方案

将你的模块文件从 .js 逐个重命名为 .ts。使用多个编译器选项和功能来帮助你消除错误。

讨论

使用 TypeScript 文件而不是带有类型的 JavaScript 文件的好处在于,你的类型和实现都在一个文件中,这为编辑器提供了更好的支持和更多的 TypeScript 功能,并增加了与其他工具的兼容性。

但是,仅仅将所有文件从 .js 重命名为 .ts 很可能会导致大量的错误。因此,你应该逐个文件地操作,并逐步增加类型安全性。

在迁移时最大的问题是,你突然要处理的是一个 TypeScript 项目,而不是 JavaScript 项目。然而,你的许多模块仍然是 JavaScript,并且没有类型信息,它们将无法通过类型检查步骤。

为了让 TypeScript 和自己更轻松,关闭 JavaScript 的类型检查,但允许 TypeScript 模块加载和引用 JavaScript 文件:

{
  "compilerOptions": {
    "checkJs": false,
    "allowJs": true
  }
}

如果你现在运行 npx tsc,你会看到 TypeScript 会在源文件夹中检索所有 JavaScript 和 TypeScript 文件,并在目标文件夹中创建相应的 JavaScript 文件。TypeScript 还会将你的代码转译为与指定目标版本兼容的代码。

如果你在处理依赖项,你会发现其中一些依赖项没有类型信息。这也会导致 TypeScript 错误:

import _ from "lodash";
//            ^- Could not find a declaration
//               file for module 'lodash'.

安装第三方类型定义以消除此错误。参见 Recipe 1.5。

逐个文件迁移后,你可能会意识到你无法一次性获得一个文件的所有类型定义。存在依赖关系,你很快就会陷入调整过多文件的困境,而你实际需要解决的只是其中一个文件。

你可以选择接受错误。默认情况下,TypeScript 将编译器选项 noEmitOnError 设置为 false

{
  "compilerOptions": {
    "noEmitOnError": false
  }
}

这意味着无论你的项目有多少错误,TypeScript 都会生成结果文件,尽量不阻止你。这可能是你在完成迁移后希望打开的一个设置。

在严格模式下,TypeScript 的特性标志 noImplicitAny 被设置为 true。这个标志将确保你不会忘记为变量、常量或函数参数分配类型,即使它只是 any

function printPerson(person: any) {
  // This doesn't make sense, but is ok with any
  console.log(person.gobbleydegook);
}

// This also doesn't make sense, but any allows it
printPerson(123);

any 是 TypeScript 中的万能类型。每个值都兼容于 any,而且 any 允许你访问每个属性或调用每个方法。any 有效地关闭了类型检查,在迁移过程中给你一些喘息的空间。

或者,你可以用 unknown 注释你的参数。这也允许你将一切传递给函数,但在了解更多类型信息之前不允许对其进行任何操作。

你还可以决定通过在要排除在类型检查之外的行之前添加 @ts-ignore 注释来忽略错误。在文件开头添加 @ts-nocheck 注释可以完全关闭该模块的类型检查。

一个对迁移非常棒的注释指令是 @ts-expect-error。它像 @ts-ignore 一样,可以吞噬类型检查过程中的错误,但如果没有类型错误,则会产生红色波浪线。

在迁移过程中,这有助于你找出成功迁移到 TypeScript 的地方。当没有剩余的 @ts-expect-error 指令时,你就完成了:

function printPerson(person: Person) {
  console.log(person.name);
}

// This error will be swallowed
// @ts-expect-error
printPerson(123);

function printNumber(nr: number) {
  console.log(nr);
}

// v- Unused '@ts-expect-error' directive.ts(2578)
// @ts-expect-error
printNumber(123);

这种技术的好处在于你可以颠倒责任。通常,你需要确保将正确的值传递给函数;现在你可以确保函数能够处理正确的输入。

消除迁移过程中所有错误的所有可能性都有一个共同点:它们都是显式的。你需要显式地设置 @ts-expect-error 注释、将函数参数注释为 any,或完全忽略文件的类型检查。通过这样做,你可以始终搜索这些逃逸通道,以确保随着时间的推移,你完全消除了它们。

1.5 从 Definitely Typed 加载类型

问题

你依赖于一个尚未用 TypeScript 编写并因此缺少类型定义的依赖项。

解决方案

Definitely Typed 获取并安装由社区维护的类型定义。

讨论

Definitely Typed 是 GitHub 上最大、最活跃的仓库之一,收集由社区开发和维护的高质量 TypeScript 类型定义。

维护的类型定义数量接近 10,000,并且几乎没有 JavaScript 库不可用。

所有类型定义都经过了代码检查和部署到 Node.js 包注册表 NPM 的 @types 命名空间下。NPM 在每个包的信息页面上都有一个指示器,显示是否有 Definitely Typed 的类型定义,正如你可以在 Figure 1-2 中看到的那样。

tscb 0102

图 1-2. React 的 NPM 站点显示了一个 DT 标志,这表示来自 Definitely Typed 的可用类型定义。

点击此标志将您引导到实际的类型定义站点。如果一个包已经有第一方的类型定义可用,它会在包名旁边显示一个小的 TS 标志,就像在 Figure 1-3 中显示的那样。

tscb 0103

图 1-3. 来自 Definitely Typed 的 React 类型定义

例如,要安装流行的 JavaScript 框架 React 的类型,您需要将@types/react包安装到您的本地依赖项中:

# Installing React
$ npm install --save react

# Installing Type Definitions
$ npm install --save-dev @types/react
注意

在这个示例中,我们安装类型到开发依赖项,因为在开发应用程序时我们会使用它们,而编译后的结果对类型没有任何用处。

默认情况下,TypeScript 会捕捉到可以在项目根文件夹相对可见的@types文件夹中找到的类型定义。它还会从node_modules/@types中捕捉到所有类型定义;例如,这就是 NPM 安装@types/react的位置。

我们这样做是因为在tsconfig.json中的typeRoots编译器选项设置为@types./node_modules/@types。如果需要覆盖此设置,请确保包括原始文件夹,以便从 Definitely Typed 中获取类型定义:

{
  "compilerOptions": {
    "typeRoots": ["./typings", "./node_modules/@types"]
  }
}

注意,只需将类型定义安装到node_modules/@types中,TypeScript 就会在编译时加载它们。这意味着如果某些类型声明了全局变量,TypeScript 将会捕捉到它们。

你可能想要明确指定哪些包可以通过在编译器选项的types设置中指定它们来贡献到全局作用域:

{
  "compilerOptions": {
    "types": ["node", "jest"]
  }
}

注意,此设置仅影响对全局作用域的贡献。如果通过 import 语句加载 Node 模块,则 TypeScript 仍将从@types中获取正确的类型:

// If `@types/lodash` is installed, we get proper
// type defintions for this NPM package
import _ from "lodash"

const result = _.flattenDeep([1, [2, [3, [4]], 5]]);

我们将在 Recipe 1.7 中重新讨论这个设置。

1.6 设置一个全栈项目

问题

你想要编写一个针对 Node.js 和浏览器的全栈应用程序,并且共享依赖项。

解决方案

为每个前端和后端创建两个tsconfig文件,并将共享依赖项加载为组合。

讨论

Node.js 和浏览器都运行 JavaScript,但它们对开发者应该如何使用环境有着非常不同的理解。Node.js 用于服务器、命令行工具以及所有在没有 UI 界面的情况下运行的内容——无头。它有自己的一套 API 和标准库。这段小脚本启动了一个 HTTP 服务器:

const http = require('http'); ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/1.png)

const hostname = '127.0.0.1';
const port = process.env.PORT || 3000; ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/2.png)

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World');
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/3.png)
});

尽管它毫无疑问是 JavaScript,但有些东西是 Node.js 特有的:

1

"http"是一个内置的 Node.js 模块,用于处理与 HTTP 相关的所有事务。它通过require加载,这是 Node 的模块系统 CommonJS 的一个指示器。在 Node.js 中,加载模块还有其他方法,我们将在 Recipe 1.9 中看到,但最常见的是使用 CommonJS。

2

process 对象是一个全局对象,包含有关环境变量和当前 Node.js 进程的信息。这也是 Node.js 独有的。

3

console 及其函数几乎在每个 JavaScript 运行时中都可用,但在 Node 中它的作用与在浏览器中的不同。在 Node 中,它打印到标准输出;在浏览器中,它将在开发工具中打印一行。

当然,Node.js 还有许多独特的 API。但是同样的情况也适用于浏览器中的 JavaScript:

import { msg } from `./msg.js`; ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/1.png)

document.querySelector('button')?.addEventListener("click", () => { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/2.png)
  console.log(msg); ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/3.png)
});

1

多年来,没有一种加载模块的方式,ECMAScript 模块已经进入了 JavaScript 和浏览器。此行从另一个 JavaScript 模块中加载对象。这在浏览器中可以原生运行,并且是 Node.js 的第二个模块系统(参见 Recipe 1.9)。

2

浏览器中的 JavaScript 用于与 UI 事件交互。document 对象以及指向 文档对象模型 (DOM) 中元素的 querySelector 概念是浏览器独有的。添加事件侦听器并监听“click”事件也是如此。在 Node.js 中是没有这些的。

3

再次提到 console。它与 Node.js 中的 API 相同,但结果略有不同。

这些差异如此之大,很难创建一个处理两者的 TypeScript 项目。如果您要编写一个全栈应用程序,您需要创建两个 TypeScript 配置文件,分别处理每个部分。

首先,让我们先处理后端。假设您想在 Node.js 中编写一个 Express.js 服务器(Express 是 Node 的流行服务器框架)。首先,按照 Recipe 1.1 中的示例创建一个新的 NPM 项目。然后,将 Express 安装为依赖项:

$ npm install --save express

并从 Definitely Typed 安装 Node.js 和 Express 的类型定义:

$ npm install -D @types/express @types/node

创建一个名为 server 的新文件夹。这是您的 Node.js 代码所在之处。而不是通过 tsc 创建新的 tsconfig.json,在您项目的 server 文件夹中创建一个新的 tsconfig.json。以下是其内容:

// server/tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["ESNext"],
    "module": "commonjs",
    "rootDir": "./",
    "moduleResolution": "node",
    "types": ["node"],
    "outDir": "../dist/server",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

您可能已经对此有所了解,但有一些事情尤为突出:

  • module 属性设置为 commonjs,这是原始的 Node.js 模块系统。所有 importexport 语句将被转译为它们的 CommonJS 对应语句。

  • types 属性设置为 ["node"]。此属性包含您希望在全局范围内可用的所有库。如果全局范围中有 "node",则将获得 requireprocess 和其他 Node.js 特定的类型信息。

要编译您的服务器端代码,请运行:

$ npx tsc -p server/tsconfig.json

现在来看客户端:

// client/tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["DOM", "ESNext"],
    "module": "ESNext",
    "rootDir": "./",
    "moduleResolution": "node",
    "types": [],
    "outDir": "../dist/client",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

有些相似之处,但同样有一些事情尤为突出:

  • DOM添加到lib属性中。这将为与浏览器相关的所有内容提供类型定义。在以前需要通过 Definitely Typed 安装 Node.js 类型定义时,TypeScript 现在将最新的浏览器类型定义与编译器一起发布。

  • types数组为空。这将从全局类型定义中移除"node"。由于您只能按照package.json安装类型定义,我们之前安装的"node"类型定义将在整个代码库中可用。但是,对于client部分,您希望摆脱它们。

要编译前端代码,请运行:

$ npx tsc -p client/tsconfig.json

请注意,您配置了两个不同的tsconfig.json文件。像 Visual Studio Code 这样的编辑器仅在每个文件夹的tsconfig.json文件中获取配置信息。您也可以将它们命名为tsconfig.server.jsontsconfig.client.json,并将它们放在项目的根文件夹中(并调整所有目录属性)。tsc将使用正确的配置,并在发现错误时抛出错误,但编辑器通常会保持沉默或使用默认配置。

如果您想要共享依赖关系,情况会变得有些复杂。实现共享依赖关系的一种方法是使用项目引用和组合项目。这意味着您将共享的代码提取到其自己的文件夹中,并告诉 TypeScript 这是另一个项目的依赖项目。

在与clientserver同级的位置创建一个shared文件夹。在shared文件夹中创建一个tsconfig.json文件,并填入以下内容:

// shared/tsconfig.json
{
    "compilerOptions": {
      "composite": true,
      "target": "ESNext",
      "module": "ESNext",
      "rootDir": "../shared/",
      "moduleResolution": "Node",
      "types": [],
      "declaration": true,
      "outDir": "../dist/shared",
      "esModuleInterop": true,
      "forceConsistentCasingInFileNames": true,
      "strict": true,
      "skipLibCheck": true
    },
  }

这里再次有两件事情需要注意:

  • composite标志设置为true。这允许其他项目引用这个项目。

  • declaration标志也设置为true。这将从您的代码生成d.ts文件,以便其他项目可以使用类型信息。

要将它们包含在客户端和服务器代码中,请将此行添加到client/tsconfig.jsonserver/tsconfig.json

// server/tsconfig.json
// client/tsconfig.json
{
  "compilerOptions": {
    // Same as before
  },
  "references": [
    { "path": "../shared/tsconfig.json" }
  ]
}

现在您已经准备好了。您可以编写共享的依赖项,并将它们包含在客户端和服务器代码中。

然而,这里有一个注意事项。如果您只共享模型和类型信息,那么这将非常有效,但是一旦您共享实际功能,您会发现两个不同的模块系统(Node 中的 CommonJS 和浏览器中的 ECMAScript 模块)无法统一到一个编译文件中。您要么创建一个 ESNext 模块,无法在 CommonJS 代码中导入它,要么创建 CommonJS 代码,无法在浏览器中导入它。

有两件事情您可以做:

  • 编译为 CommonJS,并让捆绑器处理浏览器的模块解析工作。

  • 编译为 ECMAScript 模块,并编写基于 ECMAScript 模块的现代 Node.js 应用程序。有关更多信息,请参见 Recipe 1.9。

因为您是新手,我强烈建议选择第二个选项。

1.7 设置测试环境

问题

您想编写测试代码,但测试框架的全局变量会干扰您的生产代码。

解决方案

为开发和构建创建单独的tsconfig,在后者中排除所有测试文件。

讨论

在 JavaScript 和 Node.js 生态系统中,有许多单元测试框架和测试运行器。它们在细节上有所不同,有不同的观点,或者是为特定需求定制的。有些可能只是比其他框架更漂亮。

就像Ava这样的测试运行器依赖于导入模块来引入框架范围内,其他一些则提供一组全局对象。例如,Mocha

import assert from "assert";
import { add } from "..";

describe("Adding numbers", () => {
  it("should add two numbers", () => {
    assert.equal(add(2, 3), 5);
  });
});

assert来自于 Node.js 内置的断言库,但describeit等是 Mocha 提供的全局对象。它们也只存在于 Mocha CLI 运行时。

这对你的类型设置提出了一些挑战,因为这些函数在编写测试时是必需的,但在执行实际应用程序时却不可用。

解决方案是创建两个不同的配置文件:一个是常规的tsconfig.json用于开发(你的编辑器可以识别,记得配方 1.6),另一个是tsconfig.build.json,用于编译应用程序时使用。

第一个包含所有你需要的全局对象,包括 Mocha 的类型;而后者则确保在编译中不包含任何测试文件。

让我们逐步进行。我们以 Mocha 为例,但其他提供类似全局对象的测试运行器,比如Jest,也可以按照相同的方式工作。

首先,安装 Mocha 及其类型:

$ npm install --save-dev mocha @types/mocha @types/node

创建一个新的tsconfig.base.json。由于开发和构建之间唯一的区别是要包含的文件集和激活的库,因此你希望将所有其他编译器设置放在一个可以重用的文件中。一个用于 Node.js 应用程序的示例文件如下:

// tsconfig.base.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "outDir": "./dist",
    "skipLibCheck": true
  }
}

源文件应位于src;测试文件应位于相邻的test文件夹中。在此配方中创建的设置还允许你在项目的任何地方创建以.test.ts结尾的文件。

创建一个新的tsconfig.json,包含你的基本开发配置。这个配置用于编辑器反馈和在 Mocha 中运行测试。你可以从tsconfig.base.json继承基本设置,并告诉 TypeScript 哪些文件夹用于编译:

// tsconfig.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "types": ["node", "mocha"],
    "rootDirs": ["test", "src"]
  }
}

请注意,你需要为 Node 和 Mocha 添加typestypes属性定义了可用的全局对象,在开发环境中你需要这两者。

此外,你可能会发现在执行测试之前编译它们很麻烦。有一些快捷方式可以帮助你。例如,ts-node首先运行你本地安装的 Node.js,并进行内存中的 TypeScript 编译:

$ npm install --save-dev ts-node
$ npx mocha -r ts-node/register tests/*.ts

在设置开发环境后,是构建环境的时候了。创建一个tsconfig.build.json。它看起来与tsconfig.json类似,但你会立即发现其中的区别:

// tsconfig.build.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "types": ["node"],
    "rootDirs": ["src"]
  },
  "exclude": ["**/*.test.ts", "**/test/**"]
}

除了更改typesrootDirs外,您还可以定义要从类型检查和编译中排除的文件。您可以使用通配符模式来排除所有位于测试文件夹中且以.test.ts结尾的文件。根据您的喜好,您还可以将.spec.tsspec文件夹添加到此数组中。

通过引用正确的 JSON 文件来编译您的项目:

$ npx tsc -p tsconfig.build.json

您将看到在结果文件(位于dist中)中,不会看到任何测试文件。此外,虽然在编辑源文件时仍然可以访问describeit,但如果尝试编译,则会收到错误消息:

$ npx tsc -p tsconfig.build.json

src/index.ts:5:1 - error TS2593: Cannot find name 'describe'.
Do you need to install type definitions for a test runner?
Try `npm i --save-dev @types/jest` or `npm i --save-dev @types/mocha`
and then add 'jest' or 'mocha' to the types field in your tsconfig.

5 describe("this does not work", () => {})
  ~~~~~~~~

Found 1 error in src/index.ts:5

如果您不喜欢在开发模式中污染全局命名空间,可以选择与 Recipe 1.6 类似的设置,但它不允许您在源文件旁边编写测试。

最后,您始终可以选择偏向模块系统的测试运行器。

1.8 从 URL 中输入 ECMAScript 模块类型

问题

您希望在不使用捆绑工具的情况下使用浏览器的模块加载功能来处理您的应用程序,但仍希望获得所有类型信息。

解决方案

在您的tsconfig的编译器选项中设置targetmoduleesnext,并使用.js扩展名指向您的模块。此外,通过 NPM 安装依赖项的类型,并在tsconfig中使用path属性告诉 TypeScript 在哪里查找类型:

// tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "paths": {
      "https://esm.sh/lodash@4.17.21": [
        "node_modules/@types/lodash/index.d.ts"
      ]
    }
  }
}

讨论

现代浏览器原生支持模块加载。您可以直接使用原始 JavaScript 文件,而不是将应用程序捆绑成较小的一组文件。

内容传递网络(CDN)如esm.shunpkg等旨在以 URL 形式分发节点模块和 JavaScript 依赖项,以供本地 ECMAScript 模块加载使用。

使用适当的缓存和最先进的 HTTP,ECMAScript 模块成为应用程序的真正替代品。

TypeScript 不包括现代捆绑工具,因此您无论如何都需要安装额外的工具。但是,如果决定采用模块优先方式,使用 TypeScript 时需要考虑几个因素。

您要达到的目标是在 TypeScript 中编写importexport语句,但保留模块加载语法,并让浏览器处理模块解析:

// File module.ts
export const obj = {
  name: "Stefan",
};

// File index.ts
import { obj } from "./module";

console.log(obj.name);

要实现这一点,请告诉 TypeScript:

  1. 编译到理解模块的 ECMAScript 版本

  2. 使用 ECMAScript 模块语法进行模块代码生成

在您的tsconfig.json中更新两个属性:

// tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext"
  }
}

module告诉 TypeScript 如何转换导入和导出语句。默认情况下,它将模块加载转换为 CommonJS 格式,如 Recipe 1.2 中所示。将module设置为esnext将使用 ECMAScript 模块加载,从而保留语法。

target告诉 TypeScript 您要将代码转换为的 ECMAScript 版本。每年都会发布新的 ECMAScript 版本,具有新功能。将target设置为esnext将始终针对最新的 ECMAScript 版本。

根据你的兼容性目标,你可能希望将这个属性设置为与你想要支持的浏览器兼容的 ECMAScript 版本。通常是年份形式的版本(例如 es2015es2016es2017 等)。ECMAScript 模块从 es2015 版本开始支持。如果选择较旧的版本,你将无法在浏览器中原生加载 ECMAScript 模块。

改变这些编译器选项已经做了一件重要的事情:保持语法不变。但是一旦你想要运行你的代码,问题就会出现。

通常,在 TypeScript 中,导入语句指向没有扩展名的文件。你可以写 import { obj } from "./module",省略 .ts 扩展名。但是一旦编译,这个扩展名仍然缺失。但浏览器需要扩展名来实际指向相应的 JavaScript 文件。

解决方案:即使在开发时指向 .ts 文件,也要添加 .js 扩展名。TypeScript 能够智能地识别到这一点:

// index.ts

// This still loads types from 'module.ts', but keeps
// the reference intact once we compile it.
import { obj } from './module.js';

console.log(obj.name);

对于项目的模块来说,这就是你所需的一切!

当你想要使用依赖项时,情况就变得更加有趣了。如果你选择原生方式,可能会希望从 CDN 加载模块,比如 esm.sh

import _ from "https://esm.sh/lodash@4.17.21"
//             ^- Error 2307

const result = _.flattenDeep([1, [2, [3, [4]], 5]]);

console.log(result);

TypeScript 将会报错,错误信息如下:“无法找到模块 ... 或其相应的类型声明。(2307)”

TypeScript 的模块解析在文件位于你的硬盘上时有效,而不是通过 HTTP 在服务器上时有效。为了获得我们所需的信息,我们必须为 TypeScript 提供自己的解析方式。

即使我们从 URL 加载依赖项,这些依赖项的类型信息仍然驻留在 NPM 中。对于 lodash,你可以从 Definitely Typed 安装类型信息。

$ npm install -D @types/lodash

对于自带类型声明的依赖项,你可以直接安装这些依赖项:

$ npm install -D preact

一旦安装了类型声明,使用编译器选项中的 path 属性告诉 TypeScript 如何解析你的 URL:

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "paths": {
      "https://esm.sh/lodash@4.17.21": [
        "node_modules/@types/lodash/index.d.ts"
      ]
    }
  }
}

确保指向正确的文件!

如果你不想使用类型声明或者找不到类型声明,还有一种应急方法。在 TypeScript 中,我们可以使用 any 来有意地禁用类型检查。对于模块,我们可以做类似的事情——忽略 TypeScript 的错误:

// @ts-ignore
import _ from "https://esm.sh/lodash@4.17.21"

ts-ignore 从类型检查中移除 下一行 ,可以在任何你想忽略类型错误的地方使用(见 Recipe 1.4)。这实际上意味着你不会得到任何依赖项的类型信息,可能会遇到错误,但对于那些未维护的旧依赖项,这可能是最终的解决方案。

1.9 在 Node 中加载不同的模块类型

问题

你想在 Node.js 中使用 ECMAScript 模块,并为库使用 CommonJS 互操作性特性。

解决方案

将 TypeScript 的模块解析设置为 "nodeNext",并命名你的文件为 .mts.cts

讨论

随着 Node.js 的出现,CommonJS 模块系统已经成为 JavaScript 生态系统中最流行的模块系统之一。

这个想法简单而有效:在一个模块中定义导出,然后在另一个模块中引用它们:

// person.js
function printPerson(person) {
  console.log(person.name);
}

exports = {
  printPerson,
};

// index.js
const person = require("./person");
person.printPerson({ name: "Stefan", age: 40 });

这个系统对 ECMAScript 模块产生了巨大影响,也已成为 TypeScript 模块解析和转译的默认方式。如果你查看 Example 1-1 中的 ECMAScript 模块语法,你可以看到这些关键字允许不同的转译。这意味着在 commonjs 模块设置下,你的 importexport 语句将被转译为 requireexports

示例 1-1. 使用 ECMAScript 模块系统
// person.ts
type Person = {
  name: string;
  age: number;
};

export function printPerson(person) {
  console.log(person.name);
}

// index.ts
import * as person from "./person";
person.printPerson({ name: "Stefan", age: 40 });

随着 ECMAScript 模块稳定下来,Node.js 也开始采用它们。尽管两种模块系统的基础看起来非常相似,但在细节处理上存在一些差异,例如处理默认导出或异步加载 ECMAScript 模块。

由于无法使用不同的语法对待两种模块系统,Node.js 的维护者决定为它们留出空间,并为首选模块类型分配不同的文件结尾。表 1-1 显示了不同的结尾,它们在 TypeScript 中的命名,TypeScript 将它们编译成什么,以及它们可以导入什么。由于 CommonJS 的互操作性,从 ECMAScript 模块中导入 CommonJS 模块是可以的,但反之则不行。

表 1-1. 模块结尾及其导入内容

结尾 TypeScript 编译为 可导入
.js .ts CommonJS .js, .cjs
.cjs .cts CommonJS .js, .cjs
.mjs .mts ES Modules .js, .cjs, .mjs

在 NPM 上发布库的库开发者可以在他们的 package.json 文件中获取额外的信息,以指示包的主要类型(modulecommonjs),并指向一系列主要文件或回退,以便模块加载器能够选择正确的文件:

// package.json
{
  "name": "dependency",
  "type": "module",
  "exports": {
     ".": {
        // Entry-point for `import "dependency"` in ES Modules
        "import": "./esm/index.js",
        // Entry-point for `require("dependency") in CommonJS
        "require": "./commonjs/index.cjs",
     },
  },
  // CommonJS Fallback
  "main": "./commonjs/index.cjs"
}

在 TypeScript 中,主要采用 ECMAScript 模块语法,并由编译器决定最终创建哪种模块格式。现在可能有两种格式:CommonJS 和 ECMAScript 模块。

为了允许两者,你可以在 tsconfig.json 中设置模块解析为 NodeNext

{
  "compilerOptions": {
    "module": "NodeNext"
    // ...
  }
}

使用这个标志,TypeScript 将根据你的依赖关系 package.json 中描述的正确模块来选择,将识别 .mts.cts 结尾,并且将遵循 表 1-1 中的模块导入。

对于开发者来说,导入文件时存在差异。由于在导入时 CommonJS 不需要结尾,TypeScript 仍支持无结尾导入。例如,在 Example 1-1 中的示例仍然有效,如果你只使用的是 CommonJS。

使用文件结尾进行导入,就像在 Recipe 1.8 中一样,允许模块在 ECMAScript 模块和 CommonJS 模块中导入:

// index.mts
import * as person from "./person.js"; // works in both
person.printPerson({ name: "Stefan", age: 40});

如果 CommonJS 互操作性不起作用,你可以始终退回到 require 语句。将 "node" 添加为全局类型到你的编译选项中:

// tsconfig.json
{
  "compilerOptions": {
    "module": "NodeNext",
    "types": ["node"],
  }
}

然后,使用这种特定于 TypeScript 的语法进行导入:

// index.mts
import person = require("./person.cjs");

person.printPerson({ name: "Stefan", age: 40 });

在 CommonJS 模块中,这将只是另一个require调用;在 ECMAScript 模块中,这将包括 Node.js 的辅助函数:

// compiled index.mts
import { createRequire as _createRequire } from "module";
const __require = _createRequire(import.meta.url);
const person = __require("./person.cjs");
person.printPerson({ name: "Stefan", age: 40 });

请注意,这将降低与非 Node.js 环境(如浏览器)的兼容性,但最终可能会修复互操作性问题。

1.10 使用 Deno 和依赖项

问题

您希望在 Deno 中使用 TypeScript,这是一个面向非浏览器应用程序的现代 JavaScript 运行时。

解决方案

这很容易;TypeScript 已经内置。

讨论

Deno 是由开发 Node.js 的同一团队创建的现代 JavaScript 运行时。Deno 在很多方面类似于 Node.js,但也有显著的区别:

  • Deno 采用 Web 平台标准作为其主要 API,这意味着你可以更轻松地将代码从浏览器移植到服务器。

  • 它只允许在显式激活时访问文件系统或网络。

  • 它不通过集中式注册表处理依赖关系,而是——再次采用浏览器特性——通过 URL 处理。

噢,它还带有内置的开发工具和 TypeScript!

如果您想尝试 TypeScript,Deno 是最低门槛的工具。无需下载任何其他工具(tsc编译器已经内置),也不需要 TypeScript 配置。您只需编写.ts文件,Deno 会处理其余:

// main.ts
function sayHello(name: string) {
  console.log(`Hello ${name}`);
}

sayHello("Stefan");
$ deno run main.ts

Deno 的 TypeScript 可以做任何tsc能做的事情,并且随着每次 Deno 更新而更新。然而,在你想要配置它时,有一些不同之处。

首先,默认配置在其默认设置中与通过tsc --init发行的默认配置有所不同。严格模式功能标志设置不同,并包括对 React(在服务器端!)的支持。

要对配置进行更改,你应该在根文件夹中创建一个deno.json文件。除非告诉它不要,否则 Deno 会自动加载此文件。deno.json包含用于 Deno 运行时的多个配置,包括 TypeScript 编译器选项:

{
  "compilerOptions": {
    // Your TSC compiler options
  },
  "fmt": {
    // Options for the auto-formatter
  },
  "lint": {
    // Options for the linter
  }
}

您可以在Deno 网站上看到更多可能性。

默认库也有所不同。尽管 Deno 支持 Web 平台标准并具有与浏览器兼容的 API,但它需要做一些裁剪,因为没有图形用户界面。这就是为什么某些类型(例如 DOM 库)与 Deno 提供的内容冲突。

一些有趣的库有:

  • deno.ns,默认的 Deno 命名空间

  • deno.window,Deno 的全局对象

  • deno.worker,Deno 运行时中 Web Workers 的等效物

Deno 包含 DOM 和其子集,但默认情况下不会开启。如果您的应用程序同时面向浏览器和 Deno,请配置 Deno 以包含所有浏览器和 Deno 库:

// deno.json
{
  "compilerOptions": {
    "target": "esnext",
    "lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"]
  }
}

Aleph.js是一个旨在同时面向 Deno 和浏览器的框架的示例。

与 Deno 不同的是,外部依赖项的类型信息是如何分布的。在 Deno 中,外部依赖项通过 CDN 的 URL 加载。Deno 本身将其标准库托管在https://deno.land/std

但您也可以像在 Recipe 1.8 中那样使用 CDN,例如esm.shunpkg。这些 CDN 通过在 HTTP 请求中发送X-TypeScript-Types头部来分发类型,显示 Deno 将加载类型声明。这也适用于没有官方类型声明但依赖于 Definitely Typed 的依赖项。

因此,一旦安装了依赖项,Deno 将获取源文件以及所有类型信息。

如果您不从 CDN 加载依赖项,而是在本地拥有它,您可以在导入依赖项的时候指向类型声明文件:

// @deno-types="./charting.d.ts"
import * as charting from "./charting.js";

或者在库本身中包含对类型的引用:

// charting.js
/// <reference types="./charting.d.ts" />

此引用也称为三斜线指令,是 TypeScript 的一个特性,而不是 Deno 的特性。有各种三斜线指令,主要用于前 ECMAScript 模块依赖系统。文档 提供了非常好的概述。如果您坚持使用 ECMAScript 模块,您很可能不会使用三斜线指令。

1.11 使用预定义配置

问题

您想要为某个框架或平台使用 TypeScript,但不知道如何开始配置。

解决方案

使用tsconfig/bases中的预定义配置,并在此基础上进行扩展。

讨论

就像 Definitely Typed 为流行库提供社区维护的类型定义一样,tsconfig/bases提供了一组社区维护的推荐 TypeScript 配置,可以作为项目的起点。这包括像 Ember.js、Svelte 或 Next.js 这样的框架,以及像 Node.js 和 Deno 这样的 JavaScript 运行时。

配置文件被简化到最低限度,主要处理推荐的库、模块和目标设置,以及一堆对相应环境有意义的严格模式标志。

例如,这是 Node.js 18 的推荐配置,带有推荐的严格模式设置和 ECMAScript 模块:

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Node 18 + ESM + Strictest",
  "compilerOptions": {
    "lib": [
      "es2022"
    ],
    "module": "es2022",
    "target": "es2022",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "allowUnusedLabels": false,
    "allowUnreachableCode": false,
    "exactOptionalPropertyTypes": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "importsNotUsedAsValues": "error",
    "checkJs": true
  }
}

要使用此配置,请通过 NPM 安装:

$ npm install --save-dev @tsconfig/node18-strictest-esm

并将其与您自己的 TypeScript 配置连接起来:

{
  "extends": "@tsconfig/node18-strictest-esm/tsconfig.json",
  "compilerOptions": {
    // ...
  }
}

这将从预定义配置中获取所有设置。现在您可以开始设置自己的属性,例如根目录和输出目录。

^(1) 分配给const绑定的对象仍然可以更改值和属性,从而改变它们的类型。

^(2) TypeScript 还可以在其他 JavaScript 运行时环境中工作,比如 Deno 和浏览器,但它们并不是主要的目标。

第二章:基本类型

现在你已经准备好了,是时候写一些 TypeScript 代码了!开始起步应该很容易,但很快你会遇到一些不确定是否正确的情况。你应该使用接口还是类型别名?应该注释还是让类型推断发挥魔力?anyunknown 又怎么样:它们安全吗?有些人在互联网上说你永远不应该使用它们,那为什么它们会成为 TypeScript 的一部分呢?

所有这些问题将在本章中得到解答。我们将查看构成 TypeScript 的基本类型,并了解经验丰富的 TypeScript 开发者如何使用它们。你可以将其作为即将到来的章节的基础,这样你就能感受到 TypeScript 编译器如何生成类型,以及它如何解释你的注释。

这是关于你的代码、编辑器和编译器之间的互动。它涉及到上下类型层次结构的交互,正如我们将在食谱 2.3 中看到的那样。无论你是经验丰富的 TypeScript 开发者还是刚入门的人,你都会在本章中找到有用的信息。

2.1 有效注释

问题

注释类型是繁琐且无聊的。

解决方案

只有当你希望检查类型时才添加注释。

讨论

类型注释是一种明确告知期望的类型的方式。你知道,在其他编程语言中,StringBuilder stringBuilder = new StringBuilder() 的冗长确保你确实在处理一个 StringBuilder。相反的是类型推断,其中 TypeScript 尝试为你推断类型:

// Type inference
let aNumber = 2;
// aNumber: number

// Type annotation
let anotherNumber: number = 3;
// anotherNumber: number

类型注释也是 TypeScript 和 JavaScript 之间最显而易见的语法差异。

当你开始学习 TypeScript 时,你可能希望注释所有内容以表达你期望的类型。这可能会感觉是显而易见的选择,但你也可以节省地使用注释,让 TypeScript 为你推断类型。

类型注释是你表达需要检查契约的地方。如果你在变量声明中添加类型注释,你告诉编译器在赋值过程中检查类型是否匹配:

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

const me: Person = createPerson();

如果 createPerson 返回的东西与 Person 不兼容,TypeScript 将抛出错误。如果你确实希望确保处理正确类型,请这样做。

从这一刻起,mePerson 类型,并且 TypeScript 将其视为 Person。如果 me 还有更多属性——例如 profession——TypeScript 将不允许你访问它们。这在 Person 中未定义。

如果你在函数签名的返回值中添加类型注释,你告诉编译器在返回该值的时候检查类型是否匹配:

function createPerson(): Person {
  return { name: "Stefan", age: 39 };
}

如果返回的内容与 Person 不匹配,TypeScript 将抛出错误。如果你想完全确保返回正确的类型,可以这样做。特别是在处理从各种来源构建大对象的函数时,这非常方便。

如果你给函数签名的参数添加类型注解,你告诉编译器在传递参数时检查类型是否匹配:

function printPerson(person: Person) {
  console.log(person.name, person.age);
}

printPerson(me);

在我看来,这是最重要且不可避免的类型注解。其他一切都可以推断:

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

// Inferred!
// return type is { name: string, age: number }
function createPerson() {
  return { name: "Stefan", age: 39 };
}

// Inferred!
// me: { name: string, age: number}
const me = createPerson();

// Annotated! You have to check if types are compatible
function printPerson(person: Person) {
  console.log(person.name, person.age);
}

// All works
printPerson(me);

当你期望一个注解而使用推断的对象类型时,你可以使用 TypeScript 的 结构类型系统。在结构类型系统中,编译器只会考虑类型的成员(属性),而不是实际的名称。

如果要检查的类型的所有成员在值的类型中都可用,则这些类型是兼容的。我们也说类型的 形状结构 必须匹配:

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

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

function printPerson(person: Person) {
  console.log(person.name, person.age);
}

const user: User = {
  name: "Stefan",
  age: 40,
  id: 815,
};

printPerson(user); // works!

User 拥有比 Person 更多的属性,但是所有 Person 中的属性也都在 User 中,并且类型相同。这就是为什么可以将 User 对象传递给 printPerson,即使这些类型没有显式的连接。

但是,如果你传递一个文字量,TypeScript 将抱怨存在不应该存在的多余属性:

printPerson({
  name: "Stefan",
  age: 40,
  id: 1000,
  // ^- Argument of type '{ name: string; age: number; id: number; }'
  //    is not assignable to parameter of type 'Person'.
  //    Object literal may only specify known properties,
  //    and 'id' does not exist in type 'Person'.(2345)
});

这确保了你不会期望这种类型存在某些属性,然后惊讶地发现更改它们没有效果。

使用结构类型系统,你可以创建具有推断类型的承载变量的有趣模式,并且可以在软件的不同部分重复使用相同的变量,而彼此之间没有类似的连接:

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

type Studying = {
  semester: number;
};

type Student = {
  id: string;
  age: number;
  semester: number;
};

function createPerson() {
  return { name: "Stefan", age: 39, semester: 25, id: "XPA" };
}

function printPerson(person: Person) {
  console.log(person.name, person.age);
}

function studyForAnotherSemester(student: Studying) {
  student.semester++;
}

function isLongTimeStudent(student: Student) {
  return student.age - student.semester / 2 > 30 && student.semester > 20;
}

const me = createPerson();

// All work!
printPerson(me);
studyForAnotherSemester(me);
isLongTimeStudent(me);

StudentPersonStudying 有一些重叠,但彼此无关。createPerson 返回与这三种类型兼容的内容。如果你注释过多,你需要创建比必要更多的类型和检查,而且没有任何好处。

因此,无论在哪里,你都应该对函数参数进行注解以便进行类型检查。

2.2 使用 anyunknown

问题

TypeScript 中有两个顶级类型,anyunknown。你应该使用哪一个?

解决方案

如果你希望有效地取消类型检查,请使用 any;当你需要谨慎处理时,请使用 unknown

讨论

anyunknown 都是顶级类型,这意味着每个值都兼容于 anyunknown

const name: any = "Stefan";
const person: any = { name: "Stefan", age: 40 };
const notAvailable: any = undefined;

由于 any 是一种每个值都兼容的类型,你可以无限制地访问任何属性:

const name: any = "Stefan";
// This is ok for TypeScript, but will crash in JavaScript
console.log(name.profession.experience[0].level);

any 也与每个子类型兼容,除了 never。这意味着你可以通过分配新类型来缩小可能值的集合:

const me: any = "Stefan";
// Good!
const name: string = me;
// Bad, but ok for the type system.
const age: number = me;

由于 any 非常宽容,它可能是潜在错误和陷阱的不断源头,因为你实际上是取消了类型检查。

虽然大家似乎都同意你不应该在代码库中使用 any,但在某些情况下,any确实非常有用:

迁移

当你从 JavaScript 迁移到 TypeScript 时,你可能已经有一个大型的代码库,其中包含大量关于数据结构和对象如何工作的隐含信息。一次性将所有内容明确化可能有些麻烦。any可以帮助你逐步迁移到更安全的代码库。

未类型化的第三方依赖项

你可能有一个 JavaScript 依赖项,仍然拒绝使用 TypeScript(或类似的东西)。甚至更糟糕的是:没有最新的类型定义。Definitely Typed 是一个很好的资源,但它也是由志愿者维护的。它是 JavaScript 中存在的东西的形式化,但并非直接从 JavaScript 派生而来。可能存在错误(甚至在像 React 这样的流行类型定义中),或者它们可能只是不是最新的!

这就是any可以帮助你的地方。当你了解库的工作原理,如果文档足够好以帮助你入门,并且你使用它不多时,any可以是一个选择,而不是与类型作斗争的选项。

JavaScript 原型

TypeScript 的工作方式与 JavaScript 有所不同,并需要做出许多权衡以确保你不会遇到边缘情况。这也意味着,如果你写的某些东西在 JavaScript 中可以工作,但在 TypeScript 中会出错:

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

function printPerson(person: Person) {
  for (let key in person) {
    console.log(`${key}: ${person[key]}`);
// Element implicitly has an 'any' --^
// type because expression of type 'string'
// can't be used to index type 'Person'.
// No index signature with a parameter of type 'string'
// was found on type 'Person'.(7053)
  }
}

查找在 Recipe 9.1 中为什么这是一个错误。在这种情况下,any可以帮助你暂时关闭类型检查,因为你知道你在做什么。而且由于你可以从任何类型转换到any,但也可以返回到任何其他类型,所以在你的代码中有少量明确的不安全块,你可以控制发生的事情:

function printPerson(person: any) {
  for (let key in person) {
    console.log(`${key}: ${person[key]}`);
  }
}

一旦你知道代码的这部分工作正常,你可以开始添加正确的类型,绕过 TypeScript 的限制,并进行类型断言:

function printPerson(person: Person) {
  for (let key in person) {
    console.log(`${key}: ${person[key as keyof Person]}`);
  }
}

每当使用any时,请确保在tsconfig.json中激活noImplicitAny标志;在strict模式下,默认已激活。TypeScript 需要你在通过推断或注释没有类型时显式注释any,这有助于在后续找到潜在的问题情况。

any的一种替代方案是unknown。它允许使用相同的值,但你可以做的事情完全不同。any允许你做任何事情,而unknown则不允许你做任何事情。你只能传递值;当你想要调用函数或使类型更具体时,首先需要进行类型检查:

const me: unknown = "Stefan";
const name: string = me;
//    ^- Type 'unknown' is not assignable to type 'string'.(2322)
const age: number = me;
//    ^- Type 'unknown' is not assignable to type 'number'.(2322)

类型检查和控制流分析帮助你更多地使用unknown

function doSomething(value: unknown) {
  if (typeof value === "string") {
    // value: string
    console.log("It's a string", value.toUpperCase());
  } else if (typeof value === "number") {
    // value: number
    console.log("it's a number", value * 2);
  }
}

如果你的应用程序涉及大量不同类型,unknown非常适合确保你可以在整个代码中传递值,但不会因为any的宽松性而遇到安全问题。

2.3 选择正确的对象类型

问题

你希望允许 JavaScript 对象的值,但有三种不同的对象类型:objectObject{}。你应该使用哪一个?

解决方案

对于对象、函数和数组等复合类型,请使用 object。对于所有具有值的情况,请使用 {}

讨论

TypeScript 将其类型分为两个分支。第一个分支,原始类型,包括 numberbooleanstringsymbolbigint 和一些子类型。第二个分支,复合类型,包括所有对象的子类型,最终由其他复合类型或原始类型组成。图 2-1 提供了一个概述。

tscb 0201

图 2-1. TypeScript 中的类型层次结构

在某些情况下,你希望针对复合类型的值,无论是因为你想修改某些属性还是因为你只是想确保不传递任何原始值。例如 Object.create 创建一个新对象,并将其原型作为第一个参数。这只能是一个复合类型;否则,你的运行时 JavaScript 代码将崩溃:

Object.create(2);
// Uncaught TypeError: Object prototype may only be an Object or null: 2
//    at Function.create (<anonymous>)

在 TypeScript 中,似乎有三种类型可以做同样的事情:空对象类型 {},大写 Object 接口和小写 object 类型。你会选择哪一个用于复合类型?

{}Object 允许大致相同的值,即除了 nullundefined 外的所有内容(假设激活了 strict 模式或 strictNullChecks):

let obj: {}; // Similar to Object
obj = 32;
obj = "Hello";
obj = true;
obj = () => { console.log("Hello") };
obj = undefined; // Error
obj = null; // Error
obj = { name: "Stefan", age: 40 };
obj = [];
obj = /.*/;

Object 接口与所有具有 Object 原型的值兼容,这些值来自每种原始和复合类型。

然而,在 TypeScript 中,Object 是一个定义好的接口,并且对某些函数有一些要求。例如,toString 方法是 toString() => string,并且是任何非空值的一部分,它是 Object 原型的一部分。如果你分配一个具有不同 tostring 方法的值,TypeScript 将会报错:

let okObj: {} = {
  toString() {
    return false;
  }
}; // OK

let obj: Object = {
  toString() {
    return false;
  }
// ^-  Type 'boolean' is not assignable to type 'string'.ts(2322)
}

由于此行为,Object 可能会导致一些混淆,因此在大多数情况下,你可以使用 {}

TypeScript 还有一个小写 object 类型。这更接近你要找的类型,因为它允许任何复合类型,但不允许原始类型:

let obj: object;
obj = 32; // Error
obj = "Hello"; // Error
obj = true; // Error
obj = () => { console.log("Hello") };
obj = undefined;  // Error
obj = null; // Error
obj = { name: "Stefan", age: 40 };
obj = [];
obj = /.*/;

如果你想要一个排除函数、正则表达式、数组等的类型,请参阅第五章,我们在那里自己创建一个。

2.4 使用元组类型

问题

你正在使用 JavaScript 数组来组织你的数据。顺序很重要,每个位置的类型也很重要。但是 TypeScript 的类型推断使得处理起来非常繁琐。

解决方案

用元组类型进行注释。

讨论

像对象一样,JavaScript 数组是组织复杂对象数据的一种流行方式。与我们在其他示例中所做的典型 Person 对象不同,你可以逐个元素地存储条目:

const person = ["Stefan", 40]; // name and age

使用数组而不是对象的好处在于,数组元素没有属性名称。当你使用解构分配每个元素到变量时,可以很容易地分配自定义名称:

// objects.js
// Using objects
const person = {
  name: "Stefan",
  age: 40,
};

const { name, age } = person;

console.log(name); // Stefan
console.log(age); // 40

const { anotherName = name, anotherAge = age } = person;

console.log(anotherName); // Stefan
console.log(anotherAge); // 40

// arrays.js
// Using arrays
const person = ["Stefan", 40]; // name and age

const [name, age] = person;

console.log(name); // Stefan
console.log(age); // 40

const [anotherName, anotherAge] = person;

console.log(anotherName); // Stefan
console.log(anotherAge); // 40

对于需要不断分配新名称的 API,使用数组非常方便,正如在第十章中所解释的那样。

然而,在使用 TypeScript 并依赖类型推断时,这种模式可能会导致一些问题。默认情况下,TypeScript 从赋值推断数组类型。数组是具有相同元素在每个位置的开放集合:

const person = ["Stefan", 40];
// person: (string | number)[]

因此,TypeScript 认为 person 是一个数组,其中每个元素可以是字符串或数字,并允许在原始两个元素之后有大量元素。这意味着在解构时,每个元素也是stringnumber类型:

const [name, age] = person;
// name: string | number
// age: string | number

这使得 JavaScript 中的一个舒适模式在 TypeScript 中变得非常繁琐。您需要进行控制流检查以缩小到实际类型,而实际上从赋值中应该明确这是不必要的。

每当您认为在 JavaScript 中需要额外工作仅仅是为了满足 TypeScript 时,通常都有更好的方法。在这种情况下,您可以使用元组类型来更具体地指定数组应如何解释。

元组类型是数组类型的一个类似物,但其语义不同。虽然数组可能大小无限,并且每个元素都是相同类型的(无论多宽泛),元组类型具有固定的大小,并且每个元素具有不同的类型。

要获取元组类型,您只需显式注释:

const person: [string, number] = ["Stefan", 40];

const [name, age] = person;
// name: string
// age: number

太棒了!元组类型具有固定的长度;这意味着长度也编码在类型中。因此,不可能出现越界的赋值;TypeScript 将抛出错误:

person[1] = 41; // OK!
person[2] = false; // Error
//^- Type 'false' is not assignable to type 'undefined'.(2322)

TypeScript 还允许您向元组类型添加标签。这只是编辑器和编译器反馈的元信息,但它允许您更清楚地了解每个元素的期望值:

type Person = [name: string, age: number];

这将帮助您和您的同事理解期望值,就像对象类型一样。

元组类型还可以用于注释函数参数。这个函数:

function hello(name: string, msg: string): void {
  // ...
}

也可以使用元组类型编写:

function hello(...args: [name: string, msg: string]): {
  // ...
}

而且您可以非常灵活地定义它:

function h(a: string, b: string, c: string): void {
  //...
}
// equal to
function h(a: string, b: string, ...r: [string]): void {
  //...
}
// equal to
function h(a: string, ...r: [string, string]): void {
  //...
}
// equal to
function h(...r: [string, string, string]): void {
  //...
}

这些也被称为剩余元素,在 JavaScript 中我们有这样的功能,允许您定义几乎无限的参数列表;当它是最后一个元素时,剩余元素会吸收所有多余的参数。当您需要在代码中收集参数时,可以在将它们应用到函数之前使用一个元组:

const person: [string, number] = ["Stefan", 40];

function hello(...args: [name: string, msg: string]): {
 // ...
}

hello(...person);

元组类型在许多场景中非常有用。有关元组类型的更多信息,请参见第七章和第十章。

2.5 理解接口与类型别名之间的区别

问题

TypeScript 以两种方式声明对象类型:接口和类型别名。您应该使用哪一种?

解决方案

在项目边界内部使用类型别名,对于供他人使用的合同,请使用接口。

讨论

多年来,关于定义对象类型的两种方法的许多博客文章都涉及到这两种方法。但它们随时间变得过时。截至本文撰写时,类型别名和接口之间几乎没有区别。所有以前的差异已逐渐对齐。

从语法上看,接口和类型别名之间的区别是微妙的:

type PersonAsType = {
  name: string;
  age: number;
  address: string[];
  greet(): string;
};

interface PersonAsInterface {
  name: string;
  age: number;
  address: string[];
  greet(): string;
}

你可以在相同的场景中,为相同的事物使用接口和类型别名:

  • 在类的实现声明中

  • 作为对象字面量的类型注解

  • 对于递归类型结构

然而,有一个重要的区别可能会引起通常不想处理的副作用:接口允许声明合并,但类型别名不允许。声明合并允许在接口已声明之后添加属性:

interface Person {
  name: string;
}

interface Person {
  age: number;
}

// Person is now { name: string; age: number; }

TypeScript 经常在lib.d.ts文件中使用这种技术,这样只需添加基于 ECMAScript 版本的新 JavaScript API 的增量。例如,如果你想扩展Window,这是一个很好的特性,但在其他情况下可能会适得其反:

// Some data we collect in a web form
interface FormData {
  name: string;
  age: number;
  address: string[];
}

// A function that sends this data to a backend
function send(data: FormData) {
  console.log(data.entries()) // this compiles!
  // but crashes horrendously in runtime
}

那么,entries()方法是从哪里来的呢?这是一个 DOM API!FormData是浏览器 API 提供的接口之一,而且还有很多类似的接口。它们是全局可用的,如果你扩展这些接口,是不会收到任何通知的。

当然,你可以争论正确的命名,但这个问题会影响你所提供的所有全局接口,甚至是一些依赖项中的接口,你甚至可能不知道它们向全局空间添加了一个接口。

将这个接口更改为类型别名会立即让你意识到这个问题:

type FormData = {
//   ^-- Duplicate identifier 'FormData'.(2300)
  name: string;
  age: number;
  address: string[];
};

如果你正在创建一个被项目中其他部分消费的库,甚至是完全由其他团队编写的其他项目,声明合并是一个非常棒的特性。它允许你定义一个描述你的应用程序的接口,但允许用户根据实际情况进行调整。想象一个插件系统,加载新模块增强功能:声明合并是一个你不想错过的特性。

然而,在你模块的边界内,使用类型别名可以防止你意外重用或扩展已声明的类型。当你不希望其他人使用它们时,请使用类型别名。

性能

在接口的评估中,使用类型别名而不是接口引发了一些讨论,因为接口在其评估中被认为更为高效,甚至在官方TypeScript wiki上也有性能建议。这些建议应该持保留态度。

在创建时,简单的类型别名可能比接口执行更快,因为接口永远不会关闭,并且可能与其他声明合并。但是,接口在其他地方可能执行得更快,因为它们在预先知道要成为对象类型。来自 TypeScript 团队的 Ryan Canavaugh 预计在声明了非常多的接口或类型别名时,性能差异将是可测量的:大约在这条推文中说明了这一点。

如果你的 TypeScript 代码库性能不佳,这并不是因为你声明了太多类型别名而不是接口,或者反之。

2.6 定义函数重载

问题

你的函数 API 非常灵活,允许不同类型的参数,其中上下文非常重要。这在单个函数签名中很难类型化。

解决方案

使用函数重载。

讨论

JavaScript 在处理函数参数时非常灵活。你可以传递基本上任何长度的任何参数。只要函数体正确处理输入,你就没问题。这使得 API 非常符合人体工程学,但同时也非常难以类型化。

想象一个概念上的任务运行器。通过 task 函数,你可以按名称定义新任务,然后传递回调函数或传递要执行的其他任务列表。或者两者兼有——在回调运行之前,需要执行的任务列表:

task("default", ["scripts", "styles"]);

task("scripts", ["lint"], () => {
    // ...
});

task("styles", () => {
    // ...
});

如果你觉得,“这看起来很像六年前的 Gulp”,你是对的。它的灵活 API 允许你几乎无所不能,这也是 Gulp 如此流行的原因之一。

类型函数的编写可能会是一场噩梦。可选参数,相同位置的不同类型——即使使用联合类型也很难处理:^(1)

type CallbackFn = () => void;

function task(
  name: string, param2: string[] | CallbackFn, param3?: CallbackFn
): void {
//...
}

这会捕捉到前面示例的所有变化,但它也是错误的,因为它允许不合理的组合:

task(
  "what",
  () => {
    console.log("Two callbacks?");
  },
  () => {
    console.log("That's not supported, but the types say yes!");
  }
);

幸运的是,TypeScript 有一种方法可以解决这类问题:函数重载。它的名字暗示了与其他编程语言相似的概念:相同的定义但具有不同的行为。与其他编程语言相比,TypeScript 最大的不同在于函数重载仅在类型系统层级上工作,并不影响实际实现。

思路是你将每种可能的情况定义为其自己的函数签名。最后一个函数签名是实际的实现:

// Types for the type system
function task(name: string, dependencies: string[]): void;
function task(name: string, callback: CallbackFn): void
function task(name: string, dependencies: string[], callback: CallbackFn): void
// The actual implementation
function task(
  name: string, param2: string[] | CallbackFn, param3?: CallbackFn
): void {
//...
}

这里有几件重要的事情需要注意。

首先,TypeScript 只会选取实际实现之前的声明作为可能的类型。如果实际实现的签名也相关,则会重复声明。

此外,实际的实现函数签名不能是任何东西。TypeScript 检查重载是否可以使用实现签名实现。

如果有不同的返回类型,你有责任确保输入和输出匹配:

function fn(input: number): number
function fn(input: string): string
function fn(input: number | string): number | string {
  if(typeof input === "number") {
    return "this also works";
  } else {
    return 1337;
  }
}

const typeSaysNumberButItsAString = fn(12);
const typeSaysStringButItsANumber = fn("Hello world");

实现签名通常与非常广泛的类型一起工作,这意味着您必须进行许多在 JavaScript 中无论如何都需要做的检查。这是件好事,因为它促使您格外小心。

如果您需要将重载函数作为它们自己的类型使用,以便在注释中使用并分配多个实现,您总是可以创建一个类型别名:

type TaskFn = {
  (name: string, dependencies: string[]): void;
  (name: string, callback: CallbackFn): void;
  (name: string, dependencies: string[], callback: CallbackFn): void;
}

正如您所见,您只需要类型系统的重载,而不是实际的实现定义。

2.7 定义此参数类型

问题

您正在编写对this 做出假设的回调函数,但在编写函数独立时不知道如何定义this

解决方案

在函数签名的开头定义一个this 参数类型。

讨论

对于渴望成为 JavaScript 开发者的人来说,一个困惑的来源是this 对象指针的不断变化性质:

有时在编写 JavaScript 时,我想大声说:“这太荒谬了!”但我从来不知道* this * 指的是什么。

未知的 JavaScript 开发者

前述声明尤其适用于您的背景是基于类的面向对象编程语言的情况,其中this 总是指向类的实例。JavaScript 中的this 完全不同,但并不一定更难理解。更重要的是,TypeScript 可以在使用中极大地帮助理解this

this 存在于函数的作用域中,并且指向绑定到该函数的对象或值。在常规对象中,this 是非常简单明了的:

const author = {
  name: "Stefan",
  // function shorthand
  hi() {
    console.log(this.name);
  },
};

author.hi(); // prints 'Stefan'

但是在 JavaScript 中,函数是值,它们可以绑定到不同的上下文,从而有效地改变this 的值:

const author = {
  name: "Stefan",
};

function hi() {
  console.log(this.name);
}

const pet = {
  name: "Finni",
  kind: "Cat",
};

hi.apply(pet); // prints "Finni"
hi.call(author); // prints "Stefan"

const boundHi = hi.bind(author);

boundHi(); // prints "Stefan"

如果使用箭头函数而不是常规函数,则this 的语义再次改变也没有帮助:

class Person {
  constructor(name) {
    this.name = name;
  }

  hi() {
    console.log(this.name);
  }

  hi_timeout() {
    setTimeout(function() {
      console.log(this.name);
    }, 0);
  }

  hi_timeout_arrow() {
    setTimeout(() => {
      console.log(this.name);
    }, 0);
  }
}

const person = new Person("Stefan")
person.hi(); // prints "Stefan"
person.hi_timeout(); // prints "undefined"
person.hi_timeout_arrow(); // prints "Stefan"

使用 TypeScript,我们可以通过this 参数类型获得关于this 是什么以及更重要的是应该是什么的更多信息。

看看下面的例子。我们通过 DOM API 访问按钮元素并将事件侦听器绑定到它。在回调函数中,this 的类型是HT⁠ML⁠Bu⁠tt⁠on​El⁠em⁠ent⁠,这意味着您可以访问classList 等属性:

const button = document.querySelector("button");
button?.addEventListener("click", function() {
  this.classList.toggle("clicked");
});

addEventListener 函数提供关于this 的信息。如果您在重构步骤中提取函数,则保留功能,但 TypeScript 将出错,因为它失去了this 的上下文:

const button = document.querySelector("button");
button.addEventListener("click", handleToggle);

function handleToggle() {
  this.classList.toggle("clicked");
// ^- 'this' implicitly has type 'any'
//     because it does not have a type annotation
}

诀窍在于告诉 TypeScript this 应该是特定类型。您可以通过在函数签名的第一个位置添加名为this 的参数来做到这一点:

const button = document.querySelector("button");
button?.addEventListener("click", handleToggle);

function handleToggle(this: HTMLButtonElement) {
  this.classList.toggle("clicked");
}

一旦编译,此参数将被移除。TypeScript 现在具有确保this 必须是HTMLButtonElement 类型的所有信息,这也意味着一旦您在不同上下文中使用handleToggle,您会收到错误:

handleToggle();
// ^- The 'this' context of type 'void' is not
//    assignable to method's 'this' of type 'HTMLButtonElement'.

如果您定义thisHTMLElement,它比HTMLButtonElement 的超类型更有用,您可以使handleToggle 更加实用:

const button = document.querySelector("button");
button?.addEventListener("click", handleToggle);

const input = document.querySelector("input");
input?.addEventListener("click", handleToggle);

function handleToggle(this: HTMLElement) {
  this.classList.toggle("clicked");
}

在处理 this 参数类型时,您可能希望使用两个辅助类型,可以从函数类型中提取或删除 this 参数。

function handleToggle(this: HTMLElement) {
  this.classList.toggle("clicked");
}

type ToggleFn = typeof handleToggle;
// (this: HTMLElement) => void

type WithoutThis = OmitThisParameter<ToggleFn>
// () = > void

type ToggleFnThis = ThisParameterType<ToggleFn>
// HTMLElement

类和对象中还有更多关于 this 的辅助类型。在《食谱》4.8 和 11.8 中查看更多内容。

2.8 使用符号工作

问题

您会看到类型 symbol 在某些错误消息中弹出,但您不知道符号的含义或如何使用它们。

解决方案

为您希望是唯一且不可迭代的对象属性创建符号。它们非常适合存储和访问敏感信息。

讨论

symbol 是 JavaScript 和 TypeScript 中的原始数据类型,除其他用途外,还可用于对象属性。与 numberstring 相比,符号具有一些独特的特性。

可以使用 Symbol() 工厂函数创建符号:

const TITLE = Symbol('title')

Symbol 没有构造函数。参数是可选的描述。通过调用工厂函数,TITLE 被分配了这个新创建符号的唯一值。此符号现在是唯一的,与所有其他具有相同描述的符号不同,并且不会与任何其他具有相同描述的符号冲突:

const ACADEMIC_TITLE = Symbol('title')
const ARTICLE_TITLE = Symbol('title')

if(ACADEMIC_TITLE === ARTICLE_TITLE) {
  // This is never true
}

描述在开发时帮助您获取符号信息:

console.log(ACADEMIC_TITLE.description) // title
console.log(ACADEMIC_TITLE.toString()) // Symbol(title)

如果您想拥有可比较且独特的值进行运行时切换或模式比较,则符号非常适合:

// A really bad logging framework
const LEVEL_INFO = Symbol('INFO')
const LEVEL_DEBUG = Symbol('DEBUG')
const LEVEL_WARN = Symbol('WARN')
const LEVEL_ERROR = Symbol('ERROR')

function log(msg, level) {
  switch(level) {
    case LEVEL_WARN:
      console.warn(msg); break
    case LEVEL_ERROR:
      console.error(msg); break;
    case LEVEL_DEBUG:
      console.log(msg);
      debugger; break;
    case LEVEL_INFO:
      console.log(msg);
  }
}

符号也可以作为属性键,但不可迭代,这对于序列化非常有用:

const print = Symbol('print')

const user = {
  name: 'Stefan',
  age: 40,
  [print]: function() {
    console.log(`${this.name} is ${this.age} years old`)
  }
}

JSON.stringify(user) // { name: 'Stefan', age: 40 }
user[print]() // Stefan is 40 years old

全局符号注册表允许您跨整个应用程序访问令牌:

Symbol.for('print') // creates a global symbol

const user = {
  name: 'Stefan',
  age: 37,
  // uses the global symbol
  [Symbol.for('print')]: function() {
    console.log(`${this.name} is ${this.age} years old`)
  }
}

第一次调用 Symbol.for 创建一个符号,第二次调用使用相同的符号。如果您将符号值存储在变量中并想知道键,您可以使用 Symbol.keyFor()

const usedSymbolKeys = []

function extendObject(obj, symbol, value) {
  //Oh, what symbol is this?
  const key = Symbol.keyFor(symbol)
  //Alright, let's better store this
  if(!usedSymbolKeys.includes(key)) {
    usedSymbolKeys.push(key)
  }
  obj[symbol] = value
}

// now it's time to retreive them all
function printAllValues(obj) {
  usedSymbolKeys.forEach(key => {
    console.log(obj[Symbol.for(key)])
  })
}

很巧妙!

TypeScript 对符号提供了全面的支持,它们是类型系统中的主要成员。symbol 本身是所有可能符号的数据类型注解。请参见上述代码块中的 ex⁠ten⁠d​Ob⁠je⁠ct 函数。为了允许所有符号扩展我们的对象,我们可以使用 symbol 类型:

const sym = Symbol('foo')

function extendObject(obj: any, sym: symbol, value: any) {
  obj[sym] = value
}

extendObject({}, sym, 42) // Works with all symbols

还有子类型 unique symbolunique symbol 与声明紧密相关,仅允许在 const 声明中使用,并引用这个确切的符号,而不是其他任何符号。

您可以将 TypeScript 中的名义类型视为 JavaScript 中非常名义化的值。

要了解 unique symbol 的类型,您需要使用 typeof 运算符:

const PROD: unique symbol = Symbol('Production mode')
const DEV: unique symbol = Symbol('Development mode')

function showWarning(msg: string, mode: typeof DEV | typeof PROD) {
 // ...
}

在撰写本文时,唯一可能的名义类型是 TypeScript 的结构类型系统。

符号位于 TypeScript 和 JavaScript 中名义类型与不透明类型的交集。它们是我们在运行时最接近名义类型检查的东西。

2.9 理解值和类型命名空间

问题

令人困惑的是,您可以将某些名称用作类型注解,而不能用作其他名称。

解决方案

了解类型和值命名空间,以及哪些名称对应于什么。

讨论

TypeScript 是 JavaScript 的超集,这意味着它在已有的、已定义的语言基础上添加了更多内容。随着时间的推移,你会学会辨别哪些部分是 JavaScript,哪些部分是 TypeScript。

真的很有帮助将 TypeScript 视为常规 JavaScript 上的这一额外类型层,一层薄薄的元信息,在 JavaScript 代码在可用的运行时之一运行之前会被剥离。一些人甚至谈论 TypeScript 代码一旦编译后“擦除到 JavaScript”。

TypeScript 作为 JavaScript 顶层的这一层还意味着不同的语法贡献给不同的层。虽然functionconst在 JavaScript 部分创建了一个名称,但type声明或interface贡献了 TypeScript 层的名称:

// Collection is in TypeScript land! --> type
type Collection = Person[]

// printCollection is in JavaScript land! --> value
function printCollection(coll: Collection) {
  console.log(...coll.entries)
}

我们还说声明会向类型命名空间值命名空间贡献名称。由于类型层位于值层之上,可以在类型层消费值,但反之则不行。我们还有专门的关键字用于此目的:

// a value
const person = {
  name: "Stefan",
};

// a type
type Person = typeof person;

typeof从下面的值层创建一个在类型层可用的名称。

当存在既创建类型又创建值的声明类型时,会变得很恼人。例如,类可以在 TypeScript 层作为类型使用,也可以在 JavaScript 中作为值使用:

// declaration
class Person {
  name: string;

  constructor(n: string) {
    this.name = n;
  }
}

// used as a value
const person = new Person("Stefan");

// used as a type
type Collection = Person[];

function printPersons(coll: Collection) {
  //...
}

命名约定可能会让你困惑。通常,我们用大写字母开头定义类、类型、接口、枚举等。即使它们可能贡献值,它们肯定也贡献了类型。嗯,直到你为 React 应用程序编写大写函数,因为惯例要求如此。

如果你习惯于将名称用作类型和值,突然遇到旧有的“TS2749: YourType 指的是一个值,但却被用作类型”错误会让你摸不着头脑:

type PersonProps = {
  name: string;
};

function Person({ name }: PersonProps) {
  // ...
}

type PrintComponentProps = {
  collection: Person[];
  //          ^- 'Person' refers to a value,
  //              but is being used as a type
}

这就是 TypeScript 可以变得非常令人困惑的地方。什么是类型,什么是值,为什么我们需要将它们分开,为什么这不像其他编程语言那样工作?突然之间,你遇到了typeof调用或者甚至是InstanceType辅助类型,因为你意识到类实际上贡献了两种类型(见第十一章)。

类在类型命名空间中贡献名称,并且由于 TypeScript 是一个结构类型系统,它们允许具有与某个类实例相同形状的值。因此这是允许的:

class Person {
  name: string;

  constructor(n: string) {
    this.name = n;
  }
}

function printPerson(person: Person) {
  console.log(person.name);
}

printPerson(new Person("Stefan")); // ok
printPerson({ name: "Stefan" }); // also ok

然而,完全在值命名空间中工作并且仅在类型命名空间中产生影响的instanceof检查将失败,因为具有相同形状的对象可能具有相同的属性,但不是类的实际实例

function checkPerson(person: Person) {
  return person instanceof Person;
}

checkPerson(new Person("Stefan")); // true
checkPerson({ name: "Stefan" }); // false

因此,了解是什么贡献类型,什么贡献值是很有用的。来自 TypeScript 文档修改的表 2-1,概括得很好。

表 2-1. 类型和值命名空间

声明类型 类型
X X
枚举 X X
接口 X
类型别名 X
函数 X
变量 X

如果你坚持使用函数、接口(或类型别名,请参见 Recipe 2.5)和变量开始学习,你会对在何处可以使用它们有所感觉。如果你使用类,考虑其影响可能需要更长时间。

^(1) 联合类型是将两种不同类型组合成一种的一种方法(详见第三章)。

第三章:类型系统

在前一章中,你了解了允许你使 JavaScript 代码更具表现力的基本构建块。但如果你在 JavaScript 中有经验,你会明白 TypeScript 的基本类型和注解只覆盖了其固有灵活性的一小部分。

TypeScript 的目标是让 JavaScript 的意图更加清晰,并且在不牺牲灵活性的情况下做到这一点,特别是它允许开发者设计出被数百万人使用和喜爱的出色 API。把 TypeScript 更多地看作是形式化 JavaScript 的一种方式,而不是限制它。进入 TypeScript 的类型系统。

在本章中,你将建立一个关于类型如何思考的思维模型。你将学习如何根据需要定义值集合的范围,如何在控制流程中改变它们的作用范围,以及如何利用结构类型系统,以及何时打破规则。

本章界定了 TypeScript 基础和高级类型技术之间的分界线。但无论你是经验丰富的 TypeScript 开发者还是刚刚入门,这种思维模型都将成为未来所有内容的基础。

3.1 使用联合和交集类型建模数据

问题

你有一个复杂的数据模型,你想在 TypeScript 中描述它。

解决方案

使用联合类型和交集类型对你的数据进行建模。使用字面类型来定义具体的变体。

讨论

假设你正在为一个玩具店创建数据模型。这个玩具店中的每个物品都有一些基本属性:名称、数量和建议的最小年龄。其他属性只有在每种特定类型的玩具中才相关,这要求你创建几个衍生类:

type BoardGame = {
  name: string;
  price: number;
  quantity: number;
  minimumAge: number;
  players: number;
};

type Puzzle = {
  name: string;
  price: number;
  quantity: number;
  minimumAge: number;
  pieces: number;
};

type Doll = {
  name: string;
  price: number;
  quantity: number;
  minimumAge: number;
  material: string;
};

对于你创建的函数,你需要一个代表所有玩具的类型,一个包含所有玩具共同基本属性的超类型:

type ToyBase = {
  name: string;
  price: number;
  quantity: number;
  minimumAge: number;
};

function printToy(toy: ToyBase) {
  /* ... */
}

const doll: Doll = {
  name: "Mickey Mouse",
  price: 9.99,
  quantity: 10000,
  minimumAge: 2,
  material: "plush",

};

printToy(doll); // works

这个方法可行,因为你可以用这个函数打印所有的玩偶、棋盘游戏或者拼图,但有一个重要的注意事项:在printToy函数中,你会丢失原始玩具的信息。你只能打印通用属性,而不能打印特定属性。

对于表示所有可能玩具的类型,你可以创建一个联合类型

// Union Toy
type Toy = Doll | BoardGame | Puzzle;

function printToy(toy: Toy) {
  /* ... */
}

把类型看作是一组兼容值的好方法。对于每个值,无论是否有注解,TypeScript 都会检查这个值是否与某个类型兼容。对于对象来说,这还包括具有比其类型定义中定义的更多属性的值。通过推断,具有更多属性的值在结构类型系统中被分配为子类型。而子类型的值也属于超类型集合。

联合类型是集合的联合。兼容值的数量变得更广泛,类型之间也存在一些重叠。例如,一个既有material又有players的对象可以同时兼容DollBoardGame。这是一个需要注意的细节,在 Recipe 3.2 中可以看到处理这个细节的方法。

Figure 3-1 以 Venn 图的形式说明了联合类型的概念。在这里,集合理论的类比同样适用。

tscb 0301

Figure 3-1. 联合类型的可视化;每种类型代表了一组兼容的值,而联合类型则表示这些集合的并集

你可以随处创建联合类型,甚至可以用原始类型:

function takesNumberOrString(value: number | string) {
  /* ... */
}

takesNumberOrString(2); // ok
takesNumberOrString("Hello"); // ok

这使得你可以根据需要扩展值的集合。

正如你在玩具店示例中看到的那样,存在一些冗余:ToyBase 属性被重复了。如果我们能够将 ToyBase 用作每个联合部分的基础,那将会更好。而我们可以,使用交叉类型:

type ToyBase = {
  name: string;
  price: number;
  quantity: number;
  minimumAge: number;
};

// Intersection of ToyBase and { players: number }
type BoardGame = ToyBase & {
  players: number;
};

// Intersection of ToyBase and { pieces: number }
type Puzzle = ToyBase & {
  pieces: number;
};

// Intersection of ToyBase and { material: string }
type Doll = ToyBase & {
  material: string;
};

就像联合类型一样,交叉类型 类似于集合理论中的对应物。它告诉 TypeScript 兼容的值需要同时是类型 A 类型 B。现在的类型接受了一个更窄的值集合,其中包括两种类型及其子类型的所有属性。Figure 3-2 展示了交叉类型的可视化。

交叉类型也适用于原始类型,但它们没有什么好处。string & number 的交叉结果为 never,因为没有值同时满足 stringnumber 属性。

tscb 0302

Figure 3-2. 两种类型的交叉类型的可视化;可能值的集合变得更窄
注意

相比于类型别名和交叉类型,你也可以使用接口来定义你的模型。在 Recipe 2.5 中,我们讨论了它们之间的区别,你需要注意其中的一些差异。因此,type BoardGame = ToyBase & { /* ... */ } 可以很容易地被描述为 interface BoardGame extends ToyBase { /* ... */ }。然而,你不能定义一个联合类型的接口。不过,你可以定义接口的联合。

这些已经是 TypeScript 中对数据建模的很好的方法了,但我们可以做得更多。在 TypeScript 中,字面量值可以表示为字面量类型。例如,我们可以定义一个只是数字 1 的类型,唯一兼容的值是 1

type One = 1;
const one: One = 1; // nothing else can be assigned.

这被称为字面量类型,虽然它单独看起来似乎没有太大用处,但当你将多个字面量类型组合成联合类型时,它非常有用。例如,对于 Doll 类型,我们可以明确设置 material 的允许值:

type Doll = ToyBase & {
  material: "plush" | "plastic";
};

function checkDoll(doll: Doll) {
  if (doll.material === "plush") {
    // do something with plush
  } else {
    // doll.material is "plastic", there are no other options
  }
}

这使得除了 "plush""plastic" 之外的任何值都无法赋给它,从而使我们的代码更加健壮。

借助联合类型、交叉类型和字面量类型,即使是复杂的模型也能更轻松地定义。

3.2 明确定义具有辨别联合类型的模型

问题

你模拟的联合类型的部分具有巨大的属性重叠,因此在控制流中区分它们变得非常麻烦。

解决方案

对每个联合部分添加一个 kind 属性,带有一个字符串字面量类型,并检查其内容。

讨论

让我们来看一个类似于我们在 食谱 3.1 中创建的数据模型。这一次,我们想为图形软件定义各种形状:

type Circle = {
  radius: number;
};

type Square = {
  x: number;
};

type Triangle = {
  x: number;
  y: number;
};

type Shape = Circle | Triangle | Square;

类型之间有一些相似之处,但在 area 函数中仍然有足够的信息来区分它们:

function area(shape: Shape) {
  if ("radius" in shape) {
    // shape is Circle
    return Math.PI * shape.radius * shape.radius;
  } else if ("y" in shape) {
    // shape is Triangle
    return (shape.x * shape.y) / 2;
  } else {
    // shape is Square
    return shape.x * shape.x;
  }
}

这样做是有效的,但存在一些注意事项。虽然 Circle 是唯一带有 radius 属性的类型,TriangleSquare 共享 x 属性。由于 Square 仅包含 x 属性,这使得 Triangle 成为 Square 的子类型。

鉴于我们首先定义了控制流以检查区分子类型属性 y,这不是一个问题,但仅仅检查 x 并创建一个在控制流中同时计算 TriangleSquare 区域的分支太容易了,这是错误的。

扩展 Shape 也很困难。如果我们查看矩形所需的属性,我们会发现它包含与 Triangle 相同的属性:

type Rectangle = {
  x: number;
  y: number;
};

type Shape = Circle | Triangle | Square | Rectangle;

没有明确的方法来区分联合类型的每个部分。为了确保联合类型的每个部分可区分,我们需要在我们的模型中增加一个识别属性,清楚地表明我们正在处理什么。

通过添加 kind 属性可以发生这种情况。该属性采用字符串文字类型来标识模型的部分。

如 食谱 3.1 中所示,TypeScript 允许你将原始类型如 stringnumberbigintboolean 缩小到具体的值。这意味着每个值也是一种类型,一个由完全兼容的单个值组成的集合。

因此,为了清晰地定义我们的模型,我们向每个模型部分添加一个 kind 属性,并将其设置为标识此部分的确切文字类型:

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

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

type Triangle = {
  x: number;
  y: number;
  kind: "triangle";
};

type Shape = Circle | Triangle | Square;

请注意,我们不将 kind 设置为 string,而是设置为精确的文字类型 "circle"(或分别为 "square""triangle")。这是一种类型,而不是值,但唯一兼容的值是文字字符串。

添加具有字符串文字类型的 kind 属性可以确保联合类型的各部分之间没有重叠,因为文字类型彼此不兼容。这种技术称为辨识联合类型,有效地分离联合类型 Shape 的每个集合。

这对于 area 函数非常棒,因为我们可以有效地区分,例如在 switch 语句中:

function area(shape: Shape) {
  switch (shape.kind) {
    case "circle": // shape is Circle
      return Math.PI * shape.radius * shape.radius;
    case "triangle": // shape is Triangle
      return (shape.x * shape.y) / 2;
    case "square": // shape is Square
      return shape.x * shape.x;
    default:
      throw Error("not possible");
  }
}

这不仅使我们清楚地知道我们在处理什么,而且对即将到来的变更非常未来化,正如我们将在 食谱 3.3 中看到的那样。

3.3 使用断言技术进行完备性检查

问题

您的辨识联合类型随着时间的推移而变化,向联合中添加新的部分。在您的代码中跟踪所有需要适应这些更改的位置变得困难。

解决方案

创建穷尽性检查,其中你断言所有剩余情况都不可能发生,使用一个assertNever函数。

讨论

让我们来看一下 Recipe 3.2 中的完整示例:

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

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

type Triangle = {
  x: number;
  y: number;
  kind: "triangle";
};

type Shape = Circle | Triangle | Square;

function area(shape: Shape) {
  switch (shape.kind) {
    case "circle": // shape is Circle
      return Math.PI * shape.radius * shape.radius;
    case "triangle": // shape is Triangle
      return (shape.x * shape.y) / 2;
    case "square": // shape is Square
      return shape.x * shape.x;
    default:
      throw Error("not possible");
  }
}

使用辨别联合,我们可以区分联合类型的每个部分。area函数使用 switch-case 语句分别处理每种情况。由于kind属性的字符串文字类型,类型之间不会重叠。

一旦所有选项都被耗尽,在默认情况下,我们抛出一个错误,表明我们达到了一个无效的情况,这是永远不应该发生的。如果我们的类型在整个代码库中都正确,这个错误应该永远不会被抛出。

即使类型系统告诉我们默认情况是不可能发生的场景。如果我们在默认情况下添加shape并将鼠标悬停在其上,TypeScript 告诉我们shape的类型是never

function area(shape: Shape) {
  switch (shape.kind) {
    case "circle": // shape is Circle
      return Math.PI * shape.radius * shape.radius;
    case "triangle": // shape is Triangle
      return (shape.x * shape.y) / 2;
    case "square": // shape is Square
      return shape.x * shape.x;
    default:
      console.error("Shape not defined:", shape); // shape is never
      throw Error("not possible");
  }
}

never是一种有趣的类型。它是 TypeScript 的底部类型,意味着它处于类型层次结构的最末端。而anyunknown包括每一个可能的值,没有值与never兼容。它是空集,这解释了它的名称。如果你的值之一恰好是never类型,你处于一个永远不应该发生的情况中。

在默认情况下,shape的类型会立即改变,如果我们例如扩展Shape类型为Rectangle

type Rectangle = {
  x: number;
  y: number;
  kind: "rectangle";
};

type Shape = Circle | Triangle | Square | Rectangle;

function area(shape: Shape) {
  switch (shape.kind) {
    case "circle": // shape is Circle
      return Math.PI * shape.radius * shape.radius;
    case "triangle": // shape is Triangle
      return (shape.x * shape.y) / 2;
    case "square": // shape is Square
      return shape.x * shape.x;
    default:
      console.error("Shape not defined:", shape); // shape is Rectangle
      throw Error("not possible");
  }
}

这是控制流分析的最佳实践:TypeScript 在每个时间点都准确地知道你的值的类型。在default分支中,shape的类型是Rectangle,但我们期望处理矩形。如果 TypeScript 能告诉我们,我们漏掉了处理可能类型的地方,那就太棒了。现在,每次计算矩形形状时,我们都会遇到这个问题。默认情况的目的是处理(从类型系统的角度来看)不可能的情况;我们希望保持这种状态。

在某种情况下这已经是糟糕的情况,并且如果你在代码库中多次使用穷尽性检查模式,情况会变得更糟。你无法确定自己是否确实没有遗漏一个可能导致软件最终崩溃的地方。

确保你处理了所有可能情况的一种技术是创建一个帮助函数,断言所有选项都已穷尽。它应该确保唯一可能的值是没有值:

function assertNever(value: never) {
  console.error("Unknown value", value);
  throw Error("Not possible");
}

通常,你会将never看作是你处于一个不可能的情况的指示器。在这里,我们将其用作函数签名的显式类型注释。你可能会问:我们应该传递哪些值?答案是:没有!在最好的情况下,这个函数永远不会被调用。

然而,如果我们将示例中的原始默认情况替换为as⁠se⁠rt​Ne⁠ve⁠r,我们可以利用类型系统确保所有可能的值都是兼容的,即使没有值存在:

function area(shape: Shape) {
  switch (shape.kind) {
    case "circle": // shape is Circle
      return Math.PI * shape.radius * shape.radius;
    case "triangle": // shape is Triangle
      return (shape.x * shape.y) / 2;
    case "square": // shape is Square
      return shape.x * shape.x;
    default: // shape is Rectangle
      assertNever(shape);
//    ^-- Error: Argument of type 'Rectangle' is not
//        assignable to parameter of type 'never'
  }
}

很棒!现在当我们忘记耗尽所有选项时,我们会看到红色波浪线。 TypeScript 在没有错误的情况下不会编译此代码,并且很容易在代码库中找到所有需要添加Rectangle情况的位置:

function area(shape: Shape) {
  switch (shape.kind) {
    case "circle": // shape is Circle
      return Math.PI * shape.radius * shape.radius;
    case "triangle": // shape is Triangle
      return (shape.x * shape.y) / 2;
    case "square": // shape is Square
      return shape.x * shape.x;
    case "rectangle":
      return shape.x * shape.y;
    default: // shape is never
      assertNever(shape); // shape can be passed to assertNever!
  }
}

尽管never没有兼容的值,并且用于指示 - 对于类型系统 - 一个不可能的情况,我们可以使用类型作为类型注释,以确保我们不忘记可能的情况。将类型视为根据控制流而变得更广或更窄的兼容值集合,使我们能够利用诸如assertNever之类的技术,这是一个非常有用的小函数,可以增强我们代码库的质量。

3.4 使用const上下文固定类型

问题

您不能将对象字面量分配给精心设计的辨别联合类型。

解决方案

使用类型断言和const 上下文固定你的字面量类型。

讨论

在 TypeScript 中,您可以将每个值用作其自身的类型。这些被称为文字类型,允许您将更大的集合子集化为仅仅几个有效值。

TypeScript 中的文字类型不仅是指向特定值的好技巧,而且是类型系统工作的重要部分。当您通过letconst将原始类型的值分配给不同的绑定时,这一点变得显而易见。

如果我们两次分配相同的值,一次通过let,一次通过const,TypeScript 将推断出两种不同的类型。使用let绑定时,TypeScript 将推断出更广泛的原始类型:

let name = "Stefan"; // name is string

使用const绑定时,TypeScript 将推断出精确的文字类型:

const name = "Stefan"; // name is "Stefan"

对象类型的行为略有不同。 let绑定仍然推断出更广泛的集合:

// person is { name: string }
let person = { name: "Stefan" };

const绑定也是如此:

// person is { name: string }
const person = { name: "Stefan" };

这背后的推理是在 JavaScript 中,尽管绑定本身是常量,这意味着我无法重新分配person,但对象属性的值可以更改:

// person is { name: string }
const person = { name: "Stefan" };

person.name = "Not Stefan"; // works!

从正确性的角度来看,这种行为反映了 JavaScript 的行为,但在我们对数据模型非常精确时可能会引发问题。

在前面的配方中,我们使用联合和交集类型对数据进行建模。我们使用辨别联合类型来区分那些非常相似的类型。

问题在于当我们将文字用于数据时,TypeScript 通常会推断出更广泛的集合,这使得值与定义的类型不兼容。这会产生一个非常冗长的错误消息:

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

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

type Triangle = {
  x: number;
  y: number;
  kind: "triangle";
};

type Shape = Circle | Triangle | Square;

function area(shape: Shape) {
  /* ... */
}

const circle = {
  radius: 2,
  kind: "circle",
};

area(circle);
//   ^-- Argument of type '{ radius: number; kind: string; '
//       is not assignable to parameter of type 'Shape'.
//       Type '{ radius: number; kind: string; }' is not
//       assignable to type 'Circle'.
//       Types of property 'kind' are incompatible.
//       Type 'string' is not assignable to type '"circle"'.

解决这个问题有几种方法。首先,我们可以使用显式注解来确保类型。如 Recipe 2.1 中所述,每个注解都是一个类型检查,这意味着右侧的值将被检查其兼容性。由于没有推断,Typescript 将查看确切的值以决定对象字面量是否兼容:

// Exact type
const circle: Circle = {
  radius: 2,
  kind: "circle",
};

area(circle); // Works!

// Broader set
const circle: Shape = {
  radius: 2,
  kind: "circle",
};

area(circle); // Also works!

我们还可以在赋值结束时进行类型断言,而不是类型注释:

// Type assertion
const circle = {
  radius: 2,
  kind: "circle",
} as Circle;

area(circle); // Works!

然而,有时注释可能会限制我们。特别是当我们必须处理包含更多信息并在不同地方以不同语义使用的文字时,这一点尤为真实。

从我们注释或断言为 Circle 开始,绑定将始终是一个圆,无论 circle 实际携带哪些值。

但是我们可以通过断言变得更加精细。我们可以断言单个属性为特定类型,而不是断言整个对象为某种类型:

const circle = {
  radius: 2,
  kind: "circle" as "circle",
};

area(circle); // Works!

另一种断言确切值的方法是使用常量上下文as const 类型断言;TypeScript 将值锁定为文字类型:

const circle = {
  radius: 2,
  kind: "circle" as const,
};

area(circle); // Works!

如果我们将常量上下文应用于整个对象,我们还确保这些值是只读的,不会被更改:

const circle = {
  radius: 2,
  kind: "circle",
} as const;

area2(circle); // Works!

circle.kind = "rectangle";
//     ^-- Cannot assign to 'kind' because
//         it is a read-only property.

常量上下文类型断言是一个非常方便的工具,如果我们想要将值固定为其确切的文字类型并保持不变。如果在代码库中有许多对象文字,它们不应更改但需要在各种场合使用,常量上下文可以帮助!

3.5 使用类型断言缩小类型

问题

基于特定条件,你可以断言一个值的类型比最初分配的更窄,但 TypeScript 无法为你缩小它。

解决方案

将类型断言添加到辅助函数的签名中,以指示布尔条件对类型系统的影响。

讨论

借助文字类型和联合类型,TypeScript 允许你定义非常具体的值集合。例如,我们可以轻松定义一个具有六个面的骰子:

type Dice = 1 | 2 | 3 | 4 | 5 | 6;

虽然这种标记法很有表现力,并且类型系统可以告诉你确切哪些值是有效的,但需要一些工作才能达到这种类型。

假设我们有某种游戏,允许用户输入任何数字。如果是有效的点数,我们执行某些操作。

我们编写一个条件检查,看看输入的数字是否属于一组值:

function rollDice(input: number) {
  if ([1, 2, 3, 4, 5, 6].includes(input)) {
    // `input` is still `number`, even though we know it
    // should be Dice
  }
}

问题在于,尽管我们进行了检查以确保值集合是已知的,TypeScript 仍将 input 处理为 number。类型系统无法将你的检查与类型系统的变化联系起来。

但是你可以帮助类型系统。首先,将你的检查提取到自己的辅助函数中:

function isDice(value: number): boolean {
  return [1, 2, 3, 4, 5, 6].includes(value);
}

请注意,此检查返回一个 boolean。这个条件要么为真,要么为假。对于返回布尔值的函数,我们可以将函数签名的返回类型更改为类型断言。

我们告诉 TypeScript,如果这个函数返回 true,我们更了解传递给函数的值。在我们的例子中,value 的类型是 Dice

function isDice(value: number): value is Dice {
  return [1, 2, 3, 4, 5, 6].includes(value);
}

有了这个,TypeScript 可以获得你的值的实际类型的提示,让你能够对值进行更精细的操作:

function rollDice(input: number) {
  if (isDice(input)) {
    // Great! `input` is now `Dice`
  } else {
    // input is still `number`
  }
}

TypeScript 是严格的,不允许使用任何带有类型谓词的断言。它需要是比原始类型更窄的类型。例如,获取string输入并断言number的子集将导致错误:

type Dice = 1 | 2 | 3 | 4 | 5 | 6;

function isDice(value: string): value is Dice {
// Error: A type predicate's type must be assignable to
// its parameter's type. Type 'number' is not assignable to type 'string'.
  return ["1", "2", "3", "4", "5", "6"].includes(value);
}

这种失效安全机制在类型级别上为您提供了一些保证,但有一个警告:它不会检查您的条件是否合理。isDice中的原始检查确保传递的值包含在有效数字数组中。

此数组中的值由您选择。如果包含错误的数字,TypeScript 仍然会认为value是有效的Dice,尽管您的检查不匹配:

// Correct on a type-level
// incorrect set of values on a value-level
function isDice(value: number): value is Dice {
  return [1, 2, 3, 4, 5, 7].includes(value);
}

这很容易引起混淆。在示例 3-1 中的条件对于整数是正确的,但如果传递浮点数则是错误的。例如,3.1415将是有效的Dice点数!

示例 3-1. 浮点数的isDice逻辑不正确
// Correct on a type-level, incorrect logic
function isDice(value: number): value is Dice {
  return value >= 1 && value <= 6;
}

实际上,任何条件都适用于 TypeScript。返回true,TypeScript 将认为valueDice

function isDice(value: number): value is Dice {
  return true;
}

TypeScript 让您控制类型断言。您有责任确保这些断言是有效和合理的。如果您大量依赖类型断言和类型谓词,确保相应进行测试。

3.6 理解 void

问题

在其他编程语言中,你知道void是一个概念,但在 TypeScript 中它的行为可能有所不同。

解决方案

拥抱void作为回调的可替代类型。

讨论

您可能从 Java 或 C#等编程语言中了解void,其中它表示没有返回值。void也存在于 TypeScript 中,乍一看它做的事情也是一样的:如果您的函数或方法没有返回值,返回类型是void

然而,仔细观察时,void的行为有些微妙,它在类型系统中的位置也是如此。在 TypeScript 中,voidundefined的子类型。JavaScript 中的函数总是返回一些东西。函数显式返回一个值,或者隐式返回undefined

function iHaveNoReturnValue(i) {
  console.log(i);
}

let check = iHaveNoReturnValue(2);
// check is undefined

如果我们为iHaveNoReturnValue创建一个类型,它将显示一个带有void作为返回类型的函数类型:

function iHaveNoReturnValue(i) {
  console.log(i);
}

type Fn = typeof iHaveNoReturnValue;
// type Fn = (i: any) => void

void作为类型也可以用于参数和所有其他声明。唯一可以传递的值是undefined

function iTakeNoParameters(x: void): void { }

iTakeNoParameters(); // works
iTakeNoParameters(undefined); // works
iTakeNoParameters(void 2); // works

voidundefined几乎相同。但有一个显著的区别:void作为返回类型可以被不同类型替换,以允许高级回调模式。例如,让我们创建一个fetch函数。它的任务是获取一组数字并将结果传递给作为参数提供的回调函数:

function fetchResults(
  callback: (statusCode: number, results: number[]) => void
) {
  // get results from somewhere ...
  callback(200, results);
}

回调函数在其签名中有两个参数——状态码和结果——返回类型是void。我们可以使用与callback精确类型匹配的回调函数调用fetchResults

function normalHandler(statusCode: number, results: number[]): void {
  // do something with both parameters
}

fetchResults(normalHandler);

但是,如果函数类型指定返回类型为void,也接受具有不同、更具体返回类型的函数:

function handler(statusCode: number): boolean {
  // evaluate the status code ...
  return true;
}

fetchResults(handler); // compiles, no problem!

函数签名不完全匹配,但代码仍然编译。首先,提供带有较短参数列表的函数签名是可以的。JavaScript 可以调用带有多余参数的函数,如果在函数中没有指定这些参数,则这些参数将被简单地忽略。不需要携带比实际需要的参数更多的参数。

其次,返回类型是 boolean,但 TypeScript 仍将此功能传递。在声明 void 返回类型时这是有用的。原始调用者 fetchResults 在调用回调时不期望返回值。因此,对于类型系统来说,callback 的返回值仍然是 undefined,即使它可能是其他值。

只要类型系统不允许你使用返回值,你的代码应该是安全的:

function fetchResults(
  callback: (statusCode: number, results: number[]) => void
) {
  // get results from somewhere ...
  const didItWork = callback(200, results);
  // didItWork is `undefined` in the type system,
  // even though it would be a boolean with `handler`.
}

这就是为什么我们可以传递带有任何返回类型的回调函数。即使回调返回了某些东西,这个值也不会被使用,而是进入虚空。

功能的权力在于调用功能,它知道如何从回调功能中期望什么。如果调用功能根本不需要从回调中返回值,则可以使用任何东西!

TypeScript 称此功能为可替代性:在任何有意义的地方,能够替换一件事物为另一件事物。这一点起初可能看起来很奇怪。但特别是当你使用你没有编写的库时,你会发现这个功能非常有价值。

3.7 在 catch 子句中处理错误类型

问题

你不能在 try-catch 块中注释明确的错误类型。

解决方案

anyunknown 进行注释,并使用类型谓词(参见 Recipe 3.5)来缩小到特定的错误类型。

讨论

当你来自像 Java、C++ 或 C# 这样的语言时,习惯于通过抛出异常来处理错误,然后在一系列的 catch 子句中捕获它们。有人认为有更好的处理错误的方式,但这种方式已经存在很久,并且由于历史和影响,已经流行到了 JavaScript。^(1)

“抛出”错误并“捕获”它们是处理 JavaScript 和 TypeScript 中错误的有效方法,但在指定你的 catch 子句时有一个重大区别。当您尝试捕获特定的错误类型时,TypeScript 将会出错。

示例 3-2 使用流行的数据获取库 Axios 来展示问题。

示例 3-2. 捕获明确的错误类型不起作用
try {
  // something with the popular fetching library Axios, for example
} catch(e: AxiosError) {
//         ^^^^^^^^^^ Error 1196: Catch clause variable
//                    type annotation must be 'any' or
//                    'unknown' if specified.
}

出现这种情况有几个原因:

任何类型都可以抛出

在 JavaScript 中,您被允许抛出每个表达式。当然,你可以抛出“异常”(或错误,因为在 JavaScript 中我们称它们为错误),但也可以抛出任何其他值:

throw "What a weird error"; // OK
throw 404; // OK
throw new Error("What a weird error"); // OK

由于可以抛出任何有效值,可以捕获的可能值已经比您通常的 Error 子类型广泛。

JavaScript 中只有一个 catch 子句。

JavaScript 在每个 try 语句中只有一个 catch 子句。过去曾有过多个 catch 子句的提案,甚至有条件表达式,但由于早期 2000 年代 JavaScript 的缺乏兴趣,这些从未实现。

相反,你应该使用这一个 catch 子句并进行 instanceoftypeof 检查,如MDN提议的。

这个示例也是狭窄类型在 TypeScript 的 catch 子句唯一正确的方式:

try {
  myroutine(); // There's a couple of errors thrown here
} catch (e) {
  if (e instanceof TypeError) {
    // A TypeError
  } else if (e instanceof RangeError) {
    // Handle the RangeError
  } else if (e instanceof EvalError) {
    // you guessed it: EvalError
  } else if (typeof e === "string") {
    // The error is a string
  } else if (axios.isAxiosError(e)) {
    // axios does an error check for us!
  } else {
    // everything else
    logMyErrors(e);
  }
}

由于所有可能的值都可以被抛出,并且我们只有一个 try 语句中的一个 catch 子句来处理它们,e 的类型范围异常地广泛。

任何异常都可能发生

既然你知道可能发生的每个错误,用一个包含所有可能的“可抛出物”的合并类型是否同样有效?理论上是的。但实际上,没有办法确定异常会有哪些类型。

在用户定义的所有异常和错误旁边,系统可能在遇到内存出错、类型不匹配或一个函数未定义时抛出错误。一个简单的函数调用可能会超出调用栈并导致臭名昭著的堆栈溢出。

大量可能的值集合,单一的 catch 子句以及发生错误的不确定性,只允许 e 的两种类型:anyunknown

所有理由都适用于如果你拒绝一个 Promise。 TypeScript 只允许你指定一个成功的 Promise 的类型。拒绝可能是由你或系统错误引起的:

const somePromise = () =>
  new Promise((fulfil, reject) => {
    if (someConditionIsValid()) {
      fulfil(42);
    } else {
      reject("Oh no!");
    }
  });

somePromise()
  .then((val) => console.log(val)) // val is number
  .catch((e) => console.log(e)); // can be anything, really;

如果你在 async/await 流程中调用同一个 Promise,情况就变得更清晰了:

try {
  const z = await somePromise(); // z is number
} catch(e) {
  // same thing, e can be anything!
}

如果你想定义自己的错误并相应地捕获,你可以编写错误类并进行 instanceof 检查,或创建检查特定属性并通过类型谓词告知正确类型的辅助函数。Axios 再次是一个很好的例子:

function isAxiosError(payload: any): payload is AxiosError {
  return payload !== null
    && typeof payload === 'object'
    && payload.isAxiosError;
}

如果你来自其他具有相似特性的编程语言,JavaScript 和 TypeScript 中的错误处理可能会是一个“假朋友”。要注意区别,并信任 TypeScript 团队和类型检查器,以确保有效处理你的错误。

3.8 使用可选的 never 创建互斥或模型

问题

你的模型要求你有一个联合的互斥部分,但你的 API 不能依赖于 kind 属性来区分。

解决方案

使用 optional never 技术来排除某些属性。

讨论

你想编写一个处理应用程序中选择操作结果的函数。这个选择操作既可以给出可能选项列表,也可以给出所选选项列表。这个函数可以处理仅产生单个值的选择操作的调用,也可以处理产生多个值的选择操作的调用。

既然你需要适应现有的 API,你的函数应该能够处理单一和多个情况,并在函数内部做出决定。

注意

当然,有更好的方法来建模 API,我们可以无休止地谈论这个问题。但有时您必须处理一开始就不那么好的现有 API。TypeScript 提供了技术和方法来在这种情况下正确地对您的数据进行类型化。

您的模型反映了该 API,因此可以传递单个value或多个values

type SelectBase = {
  options: string[];
};

type SingleSelect = SelectBase & {
  value: string;
};

type MultipleSelect = SelectBase & {
  values: string[];
};

type SelectProperties = SingleSelect | MultipleSelect;

function selectCallback(params: SelectProperties) {
  if ("value" in params) {
    // handle single cases
  } else if ("values" in params) {
    // handle multiple cases
  }
}

selectCallback({
  options: ["dracula", "monokai", "vscode"],
  value: "dracula",
});

selectCallback({
  options: ["dracula", "monokai", "vscode"],
  values: ["dracula", "vscode"],
});

这正如预期的那样工作,但请记住 TypeScript 的结构类型系统特性。将SingleSelect定义为类型允许所有子类型的值,这意味着具有value属性和values属性的对象也兼容于SingleSelectMultipleSelect同样如此。没有什么能阻止您使用带有两者的对象的selectCallback函数:

selectCallback({
  options: ["dracula", "monokai", "vscode"],
  values: ["dracula", "vscode"],
  value: "dracula",
}); // still works! Which one to choose?

您在这里传递的值是有效的,但在您的应用程序中没有意义。您无法确定这是多选操作还是单选操作。

在这种情况下,我们再次需要分离这两组值,以使我们的模型更清晰。我们可以通过使用可选的never技术^(2)来实现这一点。它涉及将每个联合分支专属的属性作为never类型的可选属性添加到其他分支:

type SelectBase = {
  options: string[];
};

type SingleSelect = SelectBase & {
  value: string;
  values?: never;
};

type MultipleSelect = SelectBase & {
  value?: never;
  values: string[];
};

您告诉 TypeScript 此属性在此分支中是可选的,并且当它被设置时,没有兼容的值。因此,包含两个属性的所有对象对于SelectProperties都是无效的:

selectCallback({
  options: ["dracula", "monokai", "vscode"],
  values: ["dracula", "vscode"],
  value: "dracula",
});
// ^ Argument of type '{ options: string[]; values: string[]; value: string; }'
//   is not assignable to parameter of type 'SelectProperties'.

联合类型再次分开,不包括kind属性。这对于模型来说非常有效,其中判别属性只有几个。如果您的模型具有太多不同的属性,并且您可以负担添加kind属性,请使用如配方 3.2 所示的判别联合类型

3.9 有效使用类型断言

问题

您的代码产生了正确的结果,但类型太宽泛了。您更了解!

解决方案

使用类型断言使用as关键字缩小到一个更小的集合,表示一种不安全的操作。

讨论

想象掷骰子并生成一个介于一到六之间的数字。JavaScript 函数只需一行,使用 Math 库。您希望使用一个缩小的类型,一个包含结果的六个字面数字类型的联合。但是,您的操作产生了一个number,而number对于您的结果来说是一个太宽泛的类型:

type Dice = 1 | 2 | 3 | 4 | 5 | 6;

function rollDice(): Dice {
  let num = Math.floor(Math.random() * 6) + 1;
  return num;
//^ Type 'number' is not assignable to type 'Dice'.(2322)
}

由于number允许的值比Dice更多,TypeScript 不会允许您仅通过注释函数签名来缩小类型。这仅在类型更宽的情况下,即超类型时才有效。

// All dice are numbers
function asNumber(dice: Dice): number {
  return dice;
}

相反,就像从配方 3.5 的类型谓词一样,我们可以告诉 TypeScript 我们知道更多,通过断言类型比预期更窄:

type Dice = 1 | 2 | 3 | 4 | 5 | 6;

function rollDice(): Dice {
  let num = Math.floor(Math.random() * 6) + 1;
  return num as Dice;
}

就像类型断言一样,类型断言仅适用于假定类型的超类型和子类型。我们可以将值设置为更宽的超类型或更窄的子类型。TypeScript 不允许我们切换集合:

function asString(num: number): string {
  return num as string;
//       ^- Conversion of type 'number' to type 'string' may
//          be a mistake because neither type sufficiently
//          overlaps with the other.
//          If this was intentional, convert the expression to 'unknown' first.
}

使用as Dice语法非常方便。它指示我们作为开发者负责的类型更改。这意味着如果出现问题,我们可以轻松扫描我们的代码以查找as关键字并找到可能的罪魁祸首。

注意

在日常语言中,人们倾向于将类型断言称为类型转换。这可能来自于它与 C、Java 等编程语言中实际、显式类型转换的相似性。然而,类型断言与类型转换有很大不同。类型转换不仅改变了兼容值的集合,还改变了内存布局甚至值本身。将浮点数转换为整数会截断尾数。另一方面,在 TypeScript 中,类型断言仅改变了兼容值的集合。数值保持不变。它被称为类型断言,因为你断言类型是更窄或更宽的,向类型系统提供更多提示。所以如果讨论类型转换,请称其为断言而非转换。

当您组装对象的属性时,断言通常也会经常使用。您知道形状将是例如Person,但首先需要设置属性:

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

function createDemoPerson(name: string) {
  const person = {} as Person;
  person.name = name;
  person.age = Math.floor(Math.random() * 95);
  return person;
}

类型断言告诉 TypeScript 空对象最终应该是Person。随后,TypeScript 允许您设置属性。这也是一个不安全的操作,因为您可能会忘记设置某个属性,而 TypeScript 不会报错。更糟的是,Person可能会更改并且获取更多属性,而您却没有任何指示表明您缺少属性:

type Person = {
  name: string;
  age: number;
  profession: string;
};

function createDemoPerson(name: string) {
  const person = {} as Person;
  person.name = name;
  person.age = Math.floor(Math.random() * 95);
  // Where's Profession?
  return person;
}

在这种情况下,最好选择安全的对象创建。没有什么能阻止您注释并确保使用赋值设置所有必需的属性:

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

function createDemoPerson(name: string) {
  const person: Person = {
    name,
    age: Math.floor(Math.random() * 95),
  };
  return person;
}

尽管类型注释比类型断言更安全,在像rollDice这样的情况下,没有更好的选择。在其他 TypeScript 场景中,您可能可以选择,但可能希望首选类型断言,即使您可以使用注释。

当我们使用fetch API 时,例如从后端获取 JSON 数据,我们可以调用fetch并将结果分配给一个已注释的类型:

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

const ppl: Person[] = await fetch("/api/people").then((res) => res.json());

res.json()的结果是any,并且any可以通过类型注释更改为任何其他类型。不能保证结果实际上是Person[]。我们可以以不同方式编写相同的行,通过断言结果是Person[],将any缩小到更具体的内容:

const ppl = await fetch("/api/people").then((res) => res.json()) as Person[];

对于类型系统来说,这是同一件事情,但我们可以轻松地扫描可能存在问题的情况。如果"/api/people"中的模型发生变化怎么办?如果我们只看注释,很难发现错误。此处的断言是不安全操作的指示器。

真正有帮助的是考虑创建一组在应用程序边界内工作的模型。一旦依赖外部的东西,例如 API,或者正确计算一个数字,类型断言可以表明您已经跨越了边界。

就像使用类型谓词一样(参见 Recipe 3.5),类型断言将正确类型的责任放在您手中。明智地使用它们。

3.10 使用索引签名

问题

您希望使用您知道值类型的对象进行工作,但是您不知道所有属性名称的起始值。

解决方案

使用索引签名定义一组开放的键,但具有定义的值类型。

讨论

在 Web API 中有一种风格,其中您以 JavaScript 对象的形式获取集合,其中属性名大致相当于唯一标识符,而值具有相同的结构。如果您主要关注 ,那么这种风格非常适合,因为简单的 Object.keys 调用会给您所有相关的 ID,允许您快速过滤和索引您正在寻找的值。

让我们考虑一下在所有您的网站上进行性能评审,您收集相关的性能指标并按域名对它们进行分组:

const timings = {
  "fettblog.eu": {
    ttfb: 300,
    fcp: 1000,
    si: 1200,
    lcp: 1500,
    tti: 1100,
    tbt: 10,
  },
  "typescript-book.com": {
    ttfb: 400,
    fcp: 1100,
    si: 1100,
    lcp: 2200,
    tti: 1100,
    tbt: 0,
  },
};

如果我们想要找到给定度量标准的最低时序域,我们可以创建一个函数,在此函数中我们循环遍历所有键,对每个度量标准条目进行索引并进行比较:

function findLowestTiming(collection, metric) {
  let result = {
    domain: "",
    value: Number.MAX_VALUE,
  };
  for (const domain in collection) {
    const timing = collection[domain];
    if (timing[metric] < result.value) {
      result.domain = domain;
      result.value = timing[metric];
    }
  }
  return result.domain;
}

由于我们是优秀的程序员,我们希望相应地为我们的函数编写类型,以确保我们不传递任何不符合我们度量集合理念的数据。在右侧为度量标准类型值进行类型编写非常简单:

type Metrics = {
  // Time to first byte
  ttfb: number;
  // First contentful paint
  fcp: number;
  // Speed Index
  si: number;
  // Largest contentful paint
  lcp: number;
  // Time to interactive
  tti: number;
  // Total blocking time
  tbt: number;
};

定义一个形状,其键集尚未定义,这有些棘手,但 TypeScript 提供了一个工具:索引签名。我们可以告诉 TypeScript 我们不知道有哪些属性名称,但我们知道它们将是 string 类型,并且它们将指向 Metrics

type MetricCollection = {
  [domain: string]: Timings;
};

这是我们需要输入 findLowestTiming 的全部内容。我们用 Me⁠tric​Co⁠ll⁠ec⁠ti⁠oncollection 进行了注释,并确保我们只传递 Metrics 的键作为第二个参数:

function findLowestTiming(
  collection: MetricCollection,
  key: keyof Metrics
): string {
  let result = {
    domain: "",
    value: Number.MAX_VALUE,
  };
  for (const domain in collection) {
    const timing = collection[domain];
    if (timing[key] < result.value) {
      result.domain = domain;
      result.value = timing[key];
    }
  }
  return result.domain;
}

这很棒,但也有一些注意事项。 TypeScript 允许您读取任何字符串的属性,但不会检查该属性是否确实可用,因此请注意:

const emptySet: MetricCollection = {};
let timing = emptySet["typescript-cookbook.com"].fcp * 2; // No type errors!

将您的索引签名类型更改为 Metricsundefined 是更实际的表示。它表示您可以使用所有可能的字符串进行索引,但可能没有值;这会增加一些保障措施,但最终是正确的选择:

type MetricCollection = {
  [domain: string]: Metrics | undefined;
};

function findLowestTiming(
  collection: MetricCollection,
  key: keyof Metrics
): string {
  let result = {
    domain: "",
    value: Number.MAX_VALUE,
  };
  for (const domain in collection) {
    const timing = collection[domain]; // Metrics | undefined
    // extra check for undefined values
    if (timing && timing[key] < result.value) {
      result.domain = domain;
      result.value = timing[key];
    }
  }
  return result.domain;
}

const emptySet: MetricCollection = {};
// access with optional chaining and nullish coalescing
let timing = (emptySet["typescript-cookbook.com"]?.fcp ?? 0) * 2;

值既可以是 Metrics,也可以是 undefined,这并不完全像是一个缺少的属性,但足够接近且足够适用于此用例。您可以在 Recipe 3.11 中了解有关缺少属性和未定义值之间细微差别的信息。要将属性键设置为可选,您告诉 TypeScript domain 不是 string 的整个集合,而是一组名为 映射类型string 子集:

type MetricCollection = {
  [domain in string]?: Metrics;
};

您可以为所有有效的属性键定义索引签名:stringnumbersymbol,并且通过映射类型还可以定义一个类型,以索引骰子的有效面:

type Throws = {
  [x in 1 | 2 | 3 | 4 | 5 | 6]: number;
};

您还可以向类型添加属性。例如,这个ElementCollection允许您通过数字索引项目,但还具有getfilter函数的额外属性,以及一个length属性:

type ElementCollection = {
  [y: number]: HTMLElement | undefined;
  get(index: number): HTMLElement | undefined;
  length: number;
  filter(callback: (element: HTMLElement) => boolean): ElementCollection;
};

如果将索引签名与其他属性组合,需要确保您的索引签名的广泛集合包含来自特定属性的类型。在前面的例子中,数字索引签名与其他属性的字符串键之间没有重叠,但如果定义映射到string并希望具有count类型为numberstring索引签名,则 TypeScript 会报错:

type StringDictionary = {
  [index: string]: string;
  count: number;
  // Error: Property 'count' of type 'number' is not assignable
  // to 'string' index type 'string'.(2411)
};

并且这是有道理的:如果所有字符串键指向一个字符串,为什么count会指向其他内容?存在歧义,TypeScript 不会允许这种情况。您需要扩展索引签名的类型,以确保较小的集合是较大集合的一部分:

type StringOrNumberDictionary = {
  [index: string]: string | number;
  count: number; // works
};

现在count同时定义了索引签名的类型和属性值的类型。

索引签名和映射类型是强大的工具,允许您使用 Web API 以及允许对元素进行灵活访问的数据结构。我们从 JavaScript 中熟悉和喜爱的东西现在在 TypeScript 中安全地进行了类型化。

3.11 区分缺少属性和未定义的值

问题

缺少属性和未定义的值不同!在此差异很重要的情况下会遇到。

解决方案

tsconfig中启用exactOptionalPropertyTypes以启用对可选属性更严格的处理。

讨论

我们的软件具有用户设置,我们可以在其中定义用户的语言和他们首选的颜色覆盖。这是一个额外的主题,这意味着基本颜色已在"default"样式中设置。这意味着theme的用户设置是可选的:要么它可用,要么不可用。我们使用 TypeScript 的可选属性来实现这一点:

type Settings = {
  language: "en" | "de" | "fr";
  theme?: "dracula" | "monokai" | "github";
};

strictNullChecks激活时,在您的代码中的某个位置访问theme会扩大可能值的数量。您不仅有三个主题覆盖,还有可能是undefined

function applySettings(settings: Settings) {
  // theme is "dracula" | "monokai" | "github" | undefined
  const theme = settings.theme;
}

这是很好的行为,因为您确实希望确保设置了此属性;否则,可能会导致运行时错误。TypeScript 将undefined添加到可选属性可能值列表中是很好的,但它并不完全反映 JavaScript 的行为。可选属性意味着该键在对象中缺失,这是微妙但重要的。例如,缺少键会在属性检查中返回false

function getTheme(settings: Settings) {
  if ('theme' in settings) { // only true if the property is set!
    return settings.theme;
  }
  return 'default';
}

const settings: Settings = {
  language: "de",
};

const settingsUndefinedTheme: Settings = {
  language: "de",
  theme: undefined,
};

console.log(getTheme(settings)) // "default"
console.log(getTheme(settingsUndefinedTheme)) // undefined

在这里,即使两个设置对象看起来相似,我们也会得到完全不同的结果。更糟糕的是,undefined主题是一个我们不考虑有效的值。尽管 TypeScript 没有欺骗我们,因为它完全意识到in检查仅告诉我们属性是否可用。getTheme的可能返回值包括undefined

type Fn = typeof getTheme;
// type Fn = (settings: Settings)
//   => "dracula" | "monokai" | "github" | "default" | undefined

并且可以认为有更好的检查方式来查看这里是否有正确的值。使用空值合并前面的代码变为:

function getTheme(settings: Settings) {
  return settings.theme ?? "default";
}

type Fn = typeof getTheme;
// type Fn = (settings: Settings)
//   => "dracula" | "monokai" | "github" | "default"

尽管in检查是开发人员使用的有效方法,而 TypeScript 解释可选属性的方式可能会导致歧义。从可选属性读取undefined是正确的,但将可选属性设置为undefined则不正确。通过启用exactOptionalPropertyTypes,TypeScript 改变了这种行为:

// exactOptionalPropertyTypes is true
const settingsUndefinedTheme: Settings = {
  language: "de",
  theme: undefined,
};

// Error: Type '{ language: "de"; theme: undefined; }' is
// not assignable to type 'Settings' with 'exactOptionalPropertyTypes: true'.
// Consider adding 'undefined' to the types of the target's properties.
// Types of property 'theme' are incompatible.
// Type 'undefined' is not assignable to type
// '"dracula" | "monokai" | "github"'.(2375)

exactOptionalPropertyTypes进一步使 TypeScript 的行为更加接近 JavaScript。然而,这个标志并不在strict模式中,默认情况下不会启用,如果遇到这类问题,你需要手动设置它。

3.12 使用枚举

问题

TypeScript 枚举是一个很好的抽象,但它们似乎与类型系统的其他部分行为截然不同。

解决方案

要谨慎使用它们,最好选择const枚举,了解它们的注意事项,或者可能选择联合类型替代。

讨论

TypeScript 中的枚举允许开发人员定义一组命名常量,这样可以更轻松地记录意图或创建一组不同的情况。

它们使用enum关键字定义:

enum Direction {
  Up,
  Down,
  Left,
  Right,
};

像类一样,它们贡献于值和类型命名空间,这意味着你可以在类型注释或 JavaScript 代码中使用Direction作为值时使用:

// used as type
function move(direction: Direction) {
  // ...
}

// used as value
move(Direction.Up);

它们是 JavaScript 的语法扩展,这意味着它们不仅在类型系统级别上工作,而且会发出 JavaScript 代码:

var Direction;
(function (Direction) {
    Direction[Direction["Up"] = 0] = "Up";
    Direction[Direction["Down"] = 1] = "Down";
    Direction[Direction["Left"] = 2] = "Left";
    Direction[Direction["Right"] = 3] = "Right";
})(Direction || (Direction = {}));

当你将枚举定义为const enum时,TypeScript 尝试用实际值替换使用,消除了生成的代码:

const enum Direction {
  Up,
  Down,
  Left,
  Right,
};

// When having a const enum, TypeScript
// transpiles move(Direction.Up) to this:
move(0 /* Direction.Up */);

TypeScript 支持字符串枚举和数值枚举,这两种变体的行为差异非常大。

TypeScript 枚举默认为数值枚举,这意味着该枚举的每个变体都有一个数值值,默认从 0 开始。枚举变体的起始点和实际值可以是默认或用户定义的:

// Default
enum Direction {
  Up, // 0
  Down, // 1
  Left, // 2
  Right, // 3
};

enum Direction {
  Up = 1,    // 1
  Down,      // 2
  Left,      // 3
  Right = 5, // 5
};

从某种意义上说,数值枚举定义了与数字联合类型相同的集合:

type Direction = 0 | 1 | 2 | 3;

但它们之间存在显著差异。数值联合类型仅允许严格定义的一组值,而数值枚举允许为每个值分配任意值:

function move(direction: Direction) { /* ... */ }

move(30);// This is  ok!

原因在于使用数值枚举实现标志的用例:

// Possible traits of a person, can be multiple
enum Traits {
  None,              // 0000
  Friendly = 1,      // 0001 or 1 << 0
  Mean     = 1 << 1, // 0010
  Funny    = 1 << 2, // 0100
  Boring   = 1 << 3, // 1000
}

// (0010 | 0100) === 0110
let aPersonsTraits = Traits.Mean | Traits.Funny;

if ((aPersonsTraits & Traits.Mean) === Traits.Mean) {
  // Person is mean, amongst other things
}

枚举为这种场景提供了语法糖。为了使编译器更容易看到哪些值是允许的,TypeScript 将数值枚举的兼容值扩展到整个number集合。

枚举变体也可以用字符串而不是数字初始化,从而有效地创建字符串枚举。如果选择编写字符串枚举,必须定义每个变体,因为字符串无法递增:

enum Status {
  Admin = "Admin",
  User = "User",
  Moderator = "Moderator",
};

字符串枚举比数字枚举更为严格。它们只允许传递枚举的实际变体,而不是整个字符串集。然而,它们不允许传递字符串等价物:

function closeThread(threadId: number, status: Status): {
  // ...
}

closeThread(10, "Admin");
//              ^-- Argument of type '"Admin"' is not assignable to
//                  parameter of type 'Status'

closeThread(10, Status.Admin); // This works

与 TypeScript 中的其他每种类型不同,字符串枚举是名义类型。这也意味着具有相同值集的两个枚举不兼容:

enum Roles {
  Admin = "Admin",
  User = "User",
  Moderator = "Moderator",
};

closeThread(10, Roles.Admin);
//              ^-- Argument of type 'Roles.Admin' is not
//                  assignable to parameter of type 'Status'

当值来自于另一个不了解你的枚举但确实具有正确字符串值的来源时,这可能会引起混乱和挫败感。

明智地使用枚举并了解它们的注意事项。枚举非常适合用于功能标志和一组命名的常量,你有意让人们使用数据结构而不仅仅是值。

注意

自 TypeScript 5.0 起,对数字枚举的解释变得更加严格;现在它们表现得像字符串枚举一样,作为名义类型,不包含整个数字集作为值。然而,你仍然可能会发现依赖于早期版本 5.0 之前独特特性的代码库,因此要注意!

在可能的情况下尽量优先使用const枚举,因为非const枚举可能会增加你的代码库大小,这可能是多余的。我见过的项目中有超过两千个标志位的非const枚举,导致了巨大的工具开销、编译时间开销,以及随后的运行时开销。

或者干脆不使用它们。简单的联合类型表现类似,并且更符合其余类型系统的行为:

type Status = "Admin" | "User" | "Moderator";

function closeThread(threadId: number, status: Status) {
  // ...
}

closeThread(10, "Admin"); // All good

你可以从枚举中获得所有的好处,如适当的工具支持和类型安全,而无需额外的回合,并且避免输出不希望的代码。这也让你更清楚地知道需要传递什么以及从哪里获取值。

如果你想以枚举的方式编写代码,使用一个对象和一个命名标识符,具有const对象和Values辅助类型可能会给你想要的行为,并且非常接近 JavaScript。相同的技术也适用于字符串联合:

const Direction = {
  Up: 0,
  Down: 1,
  Left: 2,
  Right: 3,
} as const;

// Get to the const values of Direction
type Direction = (typeof Direction)[keyof typeof Direction];

// (typeof Direction)[keyof typeof Direction] yields 0 | 1 | 2 | 3
function move(direction: Direction) {
  // ...
}

move(30); // This breaks!

move(0); //This works!

move(Direction.Left); // This also works!

这行特别有趣:

// = 0 | 1 | 2 | 3
type Direction = (typeof Direction)[keyof typeof Direction];

有一些不太常见的事情发生:

  • 我们声明了与值同名的类型。这是因为 TypeScript 拥有独立的值和类型命名空间。

  • 使用typeof运算符,我们从Direction中获取类型。由于Directionconst 上下文中,我们得到了字面类型。

  • 我们使用自身的键来索引Direction类型,将对象右侧的所有值留给我们:0123。简而言之:这是一个数字的联合类型。

使用联合类型不会有任何意外:

  • 知道输出的代码最终会是什么。

  • 你不会因为有人决定从字符串枚举转换为数字枚举而导致行为变化。

  • 在需要的地方获得类型安全。

  • 你给同事和用户提供了与枚举提供的相同便利性。

但公平地说,简单的字符串联合类型确实满足你所需:类型安全、自动完成和可预测的行为。

3.13 在结构类型系统中定义名义类型

问题

您的应用程序有几种类型,它们是相同的原始类型的别名,但语义完全不同。结构类型处理它们的方式相同,但这不应该是这样的!

解决方案

使用包装类或创建原始类型与文字对象类型的交集,并使用此方法来区分两个整数。

讨论

TypeScript 的类型系统是结构化的。这意味着如果两种类型具有相似的形状,则这些类型的值是兼容的:

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

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

function acceptsPerson(person: Person) {
  // ...
}

const student: Student = {
  name: "Hannah",
  age: 27,
};

acceptsPerson(student); // all ok

JavaScript 大量依赖于对象字面量,而 TypeScript 试图推断这些字面量的类型或形状。在这种情况下,结构化类型系统是有意义的,因为值可以来自任何地方,并且需要与接口和类型定义兼容。

但是,有些情况下,您需要在类型上更加明确。对于对象类型,我们学习了像在 Recipe 3.2 中使用kind属性的区分联合技术,或者在 Recipe 3.8 中使用“可选的never”来进行异或string枚举类型也是名义上的,正如我们在 Recipe 3.12 中所看到的。

那些测量值对于对象类型和枚举类型来说已经足够好了,但如果你有两种独立的类型,它们使用相同的原始类型值集合,那么这些测量值就解决不了问题。如果你的八位数账号和你的余额都指向number类型,并且你搞混了它们呢?在你的资产负债表上看到一个八位数的数字是一个惊喜,但很可能并不正确。

或许您需要验证用户输入的字符串,并希望确保您的程序中只携带验证过的用户输入,而不是回到原始的、可能不安全的字符串。

TypeScript 允许您在类型系统内模仿名义类型以获得更多安全性。关键也在于通过具有不同属性的不同值集合来分隔可能的值集,以确保相同的值不会落入同一集合中。

实现这一目标的一种方法是使用包装类。我们不直接使用这些值,而是将每个值都包装在一个类中。通过一个private kind属性,我们确保它们不会重叠:

class Balance {
  private kind = "balance";
  value: number;

  constructor(value: number) {
    this.value = value;
  }
}

class AccountNumber {
  private kind = "account";
  value: number;

  constructor(value: number) {
    this.value = value;
  }
}

这里有趣的是,由于我们使用了private属性,TypeScript 会区分这两个类。现在,这两个kind属性都是string类型。尽管它们具有不同的值,但它们可以在内部被更改。但是类的工作方式不同。如果存在privateprotected成员,TypeScript 会认为两种类型是兼容的,如果它们来自同一声明。否则,它们不被认为是兼容的。

这使我们能够用更一般的方法来完善这种模式。与其定义一个kind成员并将其设置为一个值,不如在每个类声明中定义一个_nominal成员,其类型为void。这样做足以将两个类区分开来,但又防止我们在任何情况下都使用_nominalvoid只允许我们将_nominal设置为undefined,而undefined是一个假值,因此极其无用:

class Balance {
  private _nominal: void = undefined;
  value: number;

  constructor(value: number) {
    this.value = value;
  }
}

class AccountNumber {
  private _nominal: void = undefined;
  value: number;

  constructor(value: number) {
    this.value = value;
  }
}

const account = new AccountNumber(12345678);
const balance = new Balance(10000);

function acceptBalance(balance: Balance) {
  // ...
}

acceptBalance(balance); // ok
acceptBalance(account);
// ^ Argument of type 'AccountNumber' is not
//   assignable to parameter of type 'Balance'.
//   Types have separate declarations of a
//    private property '_nominal'.(2345)

现在我们可以区分两种具有相同值集的类型。这种方法的唯一缺点是我们封装了原始类型,这意味着每次我们想要处理原始值时,都需要将其解封。

模仿名义类型的另一种方法是将基本类型与带有kind属性的品牌化对象类型进行交集。这样,我们保留了原始类型的所有操作,但我们需要使用类型断言告诉 TypeScript 我们想要以不同的方式使用这些类型。

正如我们在第 3.9 节中学到的,如果它是原始类型的子类型或超类型,我们可以安全地断言另一种类型:

type Credits = number & { _kind: "credits" };

type AccountNumber = number & { _kind: "accountNumber" };

const account = 12345678 as AccountNumber;
let balance = 10000 as Credits;
const amount = 3000 as Credits;

function increase(balance: Credits, amount: Credits): Credits {
  return (balance + amount) as Credits;
}

balance = increase(balance, amount);
balance = increase(balance, account);
// ^ Argument of type 'AccountNumber' is not
//   assignable to parameter of type 'Credits'.
//   Type 'AccountNumber' is not assignable to type '{ _kind: "credits"; }'.
//   Types of property '_kind' are incompatible.
//   Type '"accountNumber"' is not assignable to type '"credits"'.(2345)

还要注意,添加balanceamount仍然按原意工作,但再次生成一个数字。这就是为什么我们需要添加另一个断言:

const result = balance + amount; // result is number
const credits = (balance + amount) as Credits; // credits is Credits

这两种方法各有其优缺点,你更喜欢哪一种主要取决于你的情况。这两种方法都是社区根据对类型系统行为理解而开发的变通方法和技术。

GitHub 上的TypeScript 问题跟踪器上有关于开放名义类型的类型系统的讨论,这种可能性正在不断调查中。一个想法是使用 Symbols 的unique关键字进行区分:

// Hypothetical code, this does not work!
type Balance = unique number;
type AccountNumber = unique number;

在撰写时,这个想法——以及许多其他想法——仍然是未来的可能性。

3.14 启用字符串子集的宽松自动完成

问题

你的 API 允许传递任何字符串,但你仍然希望为自动完成显示几个字符串值。

解决方案

在字符串字面量的联合类型中添加string & {}

讨论

假设你定义了一个用于访问内容管理系统的 API。有预定义的内容类型,如postpageasset,但开发人员可以定义自己的内容类型。

你创建了一个retrieve函数,它只有一个参数——内容类型,允许加载条目:

type Entry = {
    // tbd.
};

function retrieve(contentType: string): Entry[] {
    // tbd.
}

这个方法运行得足够好,但你想为用户提供关于内容类型默认选项的提示。一个可能的方法是创建一个助手类型,将所有预定义的内容类型列为字符串字面量,并与string联合:

type ContentType = "post" | "page" | "asset" | string;

function retrieve(content: ContentType): Entry[] {
  // tbd
}

这很好地描述了你的情况,但带来了一个缺点:postpageassetstring的子类型,因此将它们与string联合起来实际上将详细信息包含在更广泛的集合中。

这意味着你无法通过编辑器获取语句完成提示,就像你在图 3-3 中看到的那样。

tscb 0303

图 3-3. TypeScript 将 ContentType 扩展到整个 string 集合,从而吞噬了自动完成信息

要保留自动完成信息并保留字面类型,我们需要将 string 与空对象类型 {} 进行交集:

type ContentType = "post" | "page" | "asset" | string & {};

此更改的影响更为微妙。它不会改变 ContentType 的兼容值数量,但会将 TypeScript 设置为一种模式,防止子类型减少并保留字面类型。

您可以在 图 3-4 中看到其效果,其中 ContentType 并未减少为 string,因此在文本编辑器中所有字面值都可用于语句完成。

tscb 0304

图 3-4. 将 string 与空对象交集保留语句完成提示

仍然,每个字符串都是有效的 ContentType;它只是改变了 API 的开发者体验,并在需要时提供提示。

这一技术被像 CSSTypeReact 的明确类型定义 等流行库使用。

^(1) 例如,Rust 编程语言因其错误处理而受到赞扬。

^(2) Dan Vanderkam 首次在他精彩的 Effective TypeScript 博客 中将这一技术称为“可选但从未使用”。

第四章:泛型

直到现在,我们的主要目标是利用类型系统对动态类型语言 JavaScript 的固有灵活性进行形式化。我们为了传达意图、获取工具并在错误发生之前捕获它们,为动态类型的语言添加了静态类型。

尽管在 JavaScript 的某些部分确实不关心静态类型。例如,isKeyAvailableInObject 函数应该只检查对象中是否存在键;它不需要了解具体类型。为了正确形式化这样一个函数,我们可以使用 TypeScript 的结构类型系统,并为信息的代价描述一个非常宽泛的类型或为灵活性的代价描述一个非常严格的类型。

但我们不想付出任何代价。我们既需要灵活性又需要信息。在 TypeScript 中,泛型正是我们所需的银弹。我们可以描述复杂的关系,并为尚未定义的数据结构形式化结构。

泛型及其映射类型、类型映射、类型修改器和辅助类型,打开了元类型化的大门,我们可以基于旧类型创建新类型,并在新生成的类型挑战原始代码可能存在的错误时保持类型之间的关系。

这是进阶 TypeScript 概念的入口。但不用担心,除非我们定义它们

4.1 泛化函数签名

问题

您有两个功能,它们在不同且大部分不兼容的类型上工作相同。

解决方案

使用泛型概括它们的行为。

讨论

您正在编写一个应用程序,该应用程序在对象中存储多个语言文件(例如字幕)。键是语言代码,值是 URL。您通过选择它们来加载语言文件,选择由某些 API 或用户界面作为 string 提供的语言代码。为了确保语言代码是正确且有效的,您添加了一个 isLanguageAvailable 函数,执行 in 检查并使用类型断言设置正确的类型:

type Languages = {
  de: URL;
  en: URL;
  pt: URL;
  es: URL;
  fr: URL;
  ja: URL;
};

function isLanguageAvailable(
  collection: Languages,
  lang: string
): lang is keyof Languages {
  return lang in collection;
}

function loadLanguage(collection: Languages, lang: string) {
  if (isLanguageAvailable(collection, lang)) {
    // lang is keyof Languages
    collection[lang]; // access ok!
  }
}

相同的应用程序,不同的场景,完全不同的文件。您将媒体数据加载到 HTML 元素中:音频、视频或与 canvas 元素中某些动画的组合。所有元素已存在于应用程序中,但根据来自 API 的输入,您需要选择正确的元素。再次,选择作为 string 提供,并编写 isElementAllowed 函数以确保输入实际上是 AllowedElements 集合的有效键:

type AllowedElements = {
  video: HTMLVideoElement;
  audio: HTMLAudioElement;
  canvas: HTMLCanvasElement;
};

function isElementAllowed(
  collection: AllowedElements,
  elem: string
): elem is keyof AllowedElements {
  return elem in collection;
}

function selectElement(collection: AllowedElements, elem: string) {
  if (isElementAllowed(collection, elem)) {
    // elem is keyof AllowedElements
    collection[elem]; // access ok
  }
}

您不需要仔细观察就能看出这两种情况非常相似。特别是类型守卫函数引起我们的注意。如果我们去除所有类型信息并对齐名称,它们是相同的:

function isAvailable(obj, key) {
  return key in obj;
}

它们之所以存在是因为我们获得的类型信息。不是因为输入参数,而是因为类型断言。在这两种情况下,我们可以通过断言特定的 keyof 类型更多地了解输入参数。

问题在于集合的两种输入类型完全不同且没有重叠。除了空对象外,我们无法获得太多有价值的信息,如果我们创建一个 keyof 类型。keyof {} 实际上是 never

但这里有一些类型信息,我们可以泛化。我们知道第一个输入参数是一个对象。第二个是一个属性键。如果此检查评估为 true,我们知道第一个参数是第二个参数的键。

要泛化此函数,我们可以在 isAvailable 中添加一个 泛型类型参数,称为 Obj,放在尖括号中。这是一个用于实际类型替换 isAvailable 时的占位符。我们可以像使用 AllowedElementsLanguages 一样使用这个 泛型类型参数,并且可以添加类型断言。由于 Obj 可以替代 每一个 类型,key 需要包括所有可能的属性键 — stringsymbolnumber

function isAvailable<Obj>(
  obj: Obj,
  key: string | number | symbol
): key is keyof Obj {
  return key in obj;
}

function loadLanguage(collection: Languages, lang: string) {
  if (isAvailable(collection, lang)) {
    // lang is keyof Languages
    collection[lang]; // access ok!
  }
}

function selectElement(collection: AllowedElements, elem: string) {
  if (isAvailable(collection, elem)) {
    // elem is keyof AllowedElements
    collection[elem]; // access ok
  }
}

现在,我们有了一个可以在两种情况下工作的函数,不管我们用什么类型替换 Obj,它都可以工作!就像 JavaScript 一样!我们仍然得到相同的功能,并且得到正确的类型信息。索引访问变得安全,而不会牺牲灵活性。

最棒的部分?我们可以像使用未类型化的 JavaScript 等效项一样使用 isAvailable。这是因为 TypeScript 通过使用推断泛型类型参数的类型。这带来了一些很棒的副作用。你可以在 Recipe 4.3 中详细了解更多。

4.2 创建相关函数参数

问题

你编写函数,其中第二个参数依赖于第一个参数。

解决方案

使用泛型类型为每个参数进行注释,并通过泛型约束创建它们之间的关系。

讨论

类似于 Recipe 4.1,我们的应用程序在 Languages 类型的对象中存储字幕列表。Languages 具有一组描述语言代码的键和一个 URL 作为值:

type Languages = {
  de: URL;
  en: URL;
  pt: URL;
  es: URL;
  fr: URL;
  ja: URL;
};

const languages: Languages = { /* ... */ };

在我们的应用程序中有几个类似的列表,我们可以将它们抽象成一个名为 URLList 的类型,其索引签名允许任何 string 键:

type URLList = {
  [x: string]: URL;
};

URLListLanguages 的超类型:Languages 类型的每个值都是 URLList,但并非每个 URLList 都是 Languages。尽管如此,我们可以使用 URLList 编写一个名为 fetchFile 的函数,在此列表中加载特定条目:

function fetchFile(urls: URLList, key: string) {
  return fetch(urls[key]).then((res) => res.json());
}

const de = fetchFile(languages, "de");
const it = fetchFile(languages, "it");

keystring 类型的问题在于允许输入过多的条目。例如,未定义任何意大利字幕,但 fetchFile 仍然允许我们加载 "it" 作为语言代码。从特定的 URLList 加载项目时,我们也想知道可以访问哪些键。

我们可以通过用更通用的类型替换泛型,并设置泛型约束来解决这个问题,以确保我们传递的是URLList的子类型。这样,函数签名的行为与以前非常相似,但我们可以更好地使用替代类型。我们定义一个泛型类型参数 List,它是 URLList 的子类型,并将 key 设置为 keyof List

function fetchFile<List extends URLList>(urls: List, key: keyof List) {
  return fetch(urls[key]).then((res) => res.json());
}

const de = fetchFile(languages, "de");
const it = fetchFile(languages, "it");
//                               ^
// Argument of type '"it"' is not assignable to
// parameter of type 'keyof Languages'.(2345)

当我们调用 fetchFile 时,List 将被替换为实际类型,并且我们知道 "it" 不是 Languages 的键。TypeScript 会在我们打错字或选择不属于我们数据类型的元素时向我们显示。

如果我们加载多个键,这也适用。相同的约束,相同的效果:

function fetchFiles<List extends URLList>(urls: List, keys: (keyof List)[]) {
  const els = keys.map((el) =>
    fetch(urls[el])
      .then((res) => res.json())
      .then((data) => [el, data])
  );
  return els;
}

const de_and_fr = fetchFiles(languages, ["de", "fr"]); // Promise<any[]≥[]
const de_and_it = fetchFiles(languages, ["de", "it"]);
//                                             ^
//  Type '"it"' is not assignable to type 'keyof Languages'.(2322)

我们将结果存储在一个元组中,语言键作为第一个元素,数据作为第二个元素。然而,当我们获取结果时,它是解析为 any[]Promise 数组。这是可以理解的,因为 fetch 不告诉我们有关加载的数据的任何信息,而 dataany 类型,因此具有最广泛的类型,它只是吞下了 el,即 keyof List

但是我们在这个阶段了解得更多。例如,我们知道[el, data]不是一个数组,而是一个元组。这有微妙但重要的区别,如食谱 2.4 所示。如果我们用元组类型注释结果,我们从返回值中获得更多信息:

function fetchFiles<List extends URLList>(urls: List, keys: (keyof List)[]) {
  const els = keys.map((el) =>
    fetch(urls[el])
      .then((res) => res.json())
      .then((data) => {
        const entry: [keyof List, any] = [el, data];
        return entry;
      })
  );
  return els;
}

const de_and_fr = fetchFiles(languages, ["de", "fr"]);

fetchFiles 现在返回一个 [keyof List, any]Promise 数组。因此,我们一旦用 Languages 替换 List,我们就知道唯一可能的键是语言代码。

然而,仍然有一个注意事项。如前面的代码示例所示,de_and_fr 中唯一可用的语言是德语和法语,但编译器并未警告我们随后检查英语。编译器应该能够做到这一点,因为这个条件总是返回 false:

for (const result of de_and_fr) {
  if (result[0] === "en") {
    // English?
  }
}

问题在于,我们再次处理的类型范围太广泛了。是的,keyof Liststring窄得多,但我们也可以用一个更小的集合替换所有键。

我们需要重复相同的过程:

  1. 创建一个新的泛型类型参数。

  2. 将更广泛的类型设置为新创建的泛型类型参数的约束。

  3. 在函数签名中使用参数来替代实际类型。

就这样,我们也可以用一个子类型 "de" | "fr" 替换 keyof List

function fetchFiles<List extends URLList, Keys extends keyof List>(
  urls: List,
  keys: Keys[]
) {
  const els = keys.map((el) =>
    fetch(urls[el])
      .then((res) => res.json())
      .then((data) => {
        const entry: [Keys, any] = [el, data];
        return entry;
      })
  );
  return els;
}

const de_and_fr = fetchFiles(languages, ["de", "fr"]);

令人满意的是,我们可以在泛型类型参数之间建立关系。第二个类型参数可以由第一个泛型类型参数中的某些内容约束。这使我们可以非常具体地缩小范围,直到我们用真实值替换。效果?我们可以在代码中的任何地方了解到我们类型的可能值。所以,如果我们已经知道我们从未请求过加载英语,我们就不会检查英语语言:

for (const entry of de_and_fr) {
  const result = await entry;
  if (result[0] === "en") {
    //  This condition will always return 'false' since the types
    //. '"de" | "fr"' and '"en"' have no overlap.(2367)
  }
}

我们没有摆脱的一个检查是看看在位置 0 的是哪种语言。

我们没有考虑的一件事是泛型实例化。我们通过使用方式让类型参数被真实值替换,就像类型推断一样。但我们也可以通过注释显式地替换它们:

const de_and_ja = fetchFiles<Languages, "ja" | "de">(languages, ["de"]);

在这里,类型告诉我们可能还有日文字幕,尽管我们从使用中可以看到我们只加载了德文字幕。让这成为一个提醒,并在配方 4.4 中获取更多见解。

4.3 摆脱 any 和 unknown

问题

泛型类型参数、anyunknown 看起来都描述了非常广泛的值集。在什么情况下应该使用哪个?

解决方案

当你最终要得到实际类型时,请使用泛型类型参数;参考配方 2.2 中关于anyunknown的决策。

讨论

当我们使用泛型时,它们可能看起来像anyunknown的替代品。取一个identity函数作为例子——它的唯一工作就是返回作为输入参数传递的值:

function identity(value: any): any {
  return value;
}

let a = identity("Hello!");
let b = identity(false);
let c = identity(2);

它接受每种类型的值,其返回类型也可以是任何类型。如果我们想安全访问属性,我们可以使用unknown编写相同的函数:

function identity(value: unknown): unknown {
  return value;
}

let a = identity("Hello!");
let b = identity(false);
let c = identity(2);

我们甚至可以混合和匹配 anyunknown,但结果始终如一:类型信息会丢失。返回值的类型是我们定义的。

现在让我们使用泛型而不是anyunknown来编写相同的函数。它的类型注释表明泛型类型也是返回类型:

function identity<T>(t: T): T {
  return t;
}

我们可以使用此函数传递任何值,并查看 TypeScript 推断的类型:

let a = identity("Hello!"); // a is string
let b = identity(2000);     // b is number
let c = identity({ a: 2 }); // c is { a: number }

使用const而不是let分配到绑定时,结果略有不同:

const a = identity("Hello!"); // a is "Hello!"
const b = identity(2000);     // b is 2000
const c = identity({ a: 2 }); // c is { a: number }

对于原始类型,TypeScript 会用实际类型替换泛型类型参数。我们可以在更高级的场景中大量使用这一特性。

使用 TypeScript 的泛型,也可以注释泛型类型参数:

const a = identity<string>("Hello!"); // a is string
const b = identity<number>(2000);     // b is number
const c = identity<{ a: 2 }>({ a: 2 }); // c is { a: 2 }

如果这种行为让你想起了配方 3.4 中描述的注解和推断,那么你绝对是正确的。它非常类似,但在函数中使用了泛型类型参数。

当使用无约束的泛型时,我们可以编写能处理任何类型值的函数。在内部,它们的行为像unknown,这意味着我们可以做类型保护来缩小类型。最大的区别在于一旦我们使用函数,我们就用实际类型替换我们的泛型,完全不会丢失任何关于类型的信息。

这使我们在类型上比允许一切更加清晰。这个pairs函数接受两个参数并创建一个元组:

function pairs(a: unknown, b: unknown): [unknown, unknown] {
  return [a, b];
}

const a = pairs(1, "1"); // [unknown, unknown]

使用泛型类型参数,我们得到了一个漂亮的元组类型:

function pairs<T, U>(a: T, b: U): [T, U] {
  return [a, b];
}

const b = pairs(1, "1"); // [number, string]

使用相同的泛型类型参数,我们可以确保只在每个元素都是相同类型的元组中获取元组:

function pairs<T>(a: T, b: T): [T, T] {
  return [a, b];
}

const c = pairs(1, "1");
//                  ^
// Argument of type 'string' is not assignable to parameter of type 'number'

那么,你应该在所有地方都使用泛型吗?未必。本章包括许多解决方案,这些解决方案依赖于在正确的时间获得正确的类型信息。当你对更广泛的值集合感到满意并且可以依赖于子类型兼容时,你根本不需要使用泛型。如果你的代码中有anyunknown,请思考是否在某个时刻需要实际类型。添加一个泛型类型参数可能会有所帮助。

4.4 理解泛型实例化

问题

你理解泛型如何被实际类型替换,但有时像“Foo可分配给类型Bar的约束,但可以用约束Baz的不同子类型来实例化”这样的错误会让你困惑。

解决方案

记住,泛型类型的值可以明确和隐式地替换为各种子类型。编写友好于子类型的代码。

讨论

为你的应用程序创建一个过滤逻辑。你有不同的过滤规则,可以使用 "and" | "or" 运算符组合起来。你还可以将常规过滤规则与组合过滤器的结果链接起来。你根据这种行为创建你的类型:

type FilterRule = {
  field: string;
  operator: string;
  value: any;
};

type CombinatorialFilter = {
  combinator: "and" | "or";
  rules: FilterRule[];
};

type ChainedFilter = {
  rules: (CombinatorialFilter | FilterRule)[];
};

type Filter = CombinatorialFilter | ChainedFilter;

现在你想要编写一个reset函数,根据已提供的过滤器,重置所有规则。你使用类型守卫来区分CombinatorialFilterChainedFilter

function reset(filter: Filter): Filter {
  if ("combinator" in filter) {
    // filter is CombinatorialFilter
    return { combinator: "and", rules: [] };
  }
  // filter is ChainedFilter
  return { rules: [] };
}

const filter: CombinatorialFilter = { rules: [], combinator: "or" };
const resetFilter = reset(filter); // resetFilter is Filter

行为是你追求的,但reset的返回类型太宽泛了。当我们传递一个CombinatorialFilter时,我们应该确保重置的过滤器也是一个Com⁠bin⁠a⁠to⁠rial​Fil⁠ter。这里是联合类型,就像我们的函数签名所指示的那样。但你希望确保如果传递某种类型的过滤器,也能获得相同的返回类型。因此,你用一个受约束的泛型类型参数替换了广泛的联合类型。返回类型按预期工作,但函数的实现会抛出错误:

function reset<F extends Filter>(filter: F): F {
  if ("combinator" in filter) {
    return { combinator: "and", rules: [] };
//  ^ '{ combinator: "and"; rules: never[]; }' is assignable to
//     the constraint of type 'F', but 'F' could be instantiated
//     with a different subtype of constraint 'Filter'.
  }
  return { rules: [] };
//^ '{ rules: never[]; }' is assignable to the constraint of type 'F',
//   but 'F' could be instantiated with a different subtype of
//   constraint 'Filter'.
}

const resetFilter = reset(filter); // resetFilter is CombinatorialFilter

虽然你想要区分联合的两部分,但 TypeScript 的思考更加广泛。它知道你可能会传递一个与Filter结构兼容但具有更多属性的对象,因此它是一个子类型。

这意味着你可以用子类型实例化F来调用reset,你的程序会愉快地覆盖所有多余的属性。这是错误的,TypeScript 告诉你:

const onDemandFilter = reset({
  combinator: "and",
  rules: [],
  evaluated: true,
  result: false,
});
/* filter is {
 combinator: "and";
 rules: never[];
 evaluated: boolean;
 result: boolean;
}; */

通过编写友好于子类型的代码来克服这个问题。克隆输入对象(仍然是类型F),根据需要更改属性,并返回仍然是类型F的东西:

function reset<F extends Filter>(filter: F): F {
  const result = { ...filter }; // result is F
  result.rules = [];
  if ("combinator" in result) {
    result.combinator = "and";
  }
  return result;
}

const resetFilter = reset(filter); // resetFilter is CombinatorialFilter

泛型类型可以是联合类型中的一种,但也可以是更多,更多。TypeScript 的结构类型系统允许你处理各种子类型,你的代码需要反映这一点。

这里是另一种场景,但结果类似。您想创建一个树形数据结构,并编写一个递归类型来存储所有树项。此类型可以进行子类型化,因此您编写了一个带有通用类型参数的createRootItem函数,因为您希望使用正确的子类型进行实例化:

type TreeItem = {
  id: string;
  children: TreeItem[];
  collapsed?: boolean;
};

function createRootItem<T extends TreeItem>(): T {
  return {
    id: "root",
    children: [],
  };
// '{ id: string; children: never[]; }' is assignable to the constraint
//   of type 'T', but 'T' could be instantiated with a different subtype
//   of constraint 'TreeItem'.(2322)
}

const root = createRootItem(); // root is TreeItem

与之前类似,我们遇到了类似的错误,因为我们不可能说返回值与所有子类型兼容。为了解决这个问题,我们去掉了通用类型!我们知道返回类型的外观——它是一个TreeItem

function createRootItem(): TreeItem {
  return {
    id: "root",
    children: [],
  };
}

最简单的解决方案通常是最好的。但现在你想通过能够将类型或子类型的TreeItem附加到新创建的根来扩展你的软件。我们尚未添加任何通用类型,并且有些不满意:

function attachToRoot(children: TreeItem[]): TreeItem {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([]); // TreeItem

root的类型是TreeItem,但我们丢失了关于子类型化子项的任何信息。即使我们为子项添加了一个通用类型参数,并将其限制为TreeItem,我们也不能在此过程中保留此信息:

function attachToRoot<T extends TreeItem>(children: T[]): TreeItem {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([
  {
    id: "child",
    children: [],
    collapsed: false,
    marked: true,
  },
]); // root is TreeItem

当我们开始将通用类型作为返回类型时,我们遇到了与之前相同的问题。为了解决这个问题,我们需要将根项目类型与子项目类型分离开来,通过将TreeItem定义为通用类型,其中我们可以将Children设置为TreeItem的子类型。

由于我们希望避免任何循环引用,我们需要将Children设置为默认的BaseTreeItem,这样我们就可以将TreeItem用作Children的约束和attachToRoot的输入:

type BaseTreeItem = {
  id: string;
  children: BaseTreeItem[];
};

type TreeItem<Children extends TreeItem = BaseTreeItem> = {
  id: string;
  children: Children[];
  collapsed?: boolean;
};

function attachToRoot<T extends TreeItem>(children: T[]): TreeItem<T> {
  return {
    id: "root",
    children,
  };
}

const root = attachToRoot([
  {
    id: "child",
    children: [],
    collapsed: false,
    marked: true,
  },
]);
/*
root is TreeItem<{
 id: string;
 children: never[];
 collapsed: false;
 marked: boolean;
}>
*/

再次编写子类型友好并将输入参数视为它们自己,而不是做出假设。

4.5 生成新的对象类型

问题

在您的应用程序中有一个与您的模型相关的类型。每次模型更改时,您都需要相应地更改您的类型。

解决方案

使用通用映射类型来基于原始类型创建新的对象类型。

讨论

让我们回到玩具店,来自 Recipe 3.1。多亏了联合类型、交集类型和判别联合类型,我们能够很好地对我们的数据进行建模:

type ToyBase = {
  name: string;
  description: string;
  minimumAge: number;
};

type BoardGame = ToyBase & {
  kind: "boardgame";
  players: number;
};

type Puzzle = ToyBase & {
  kind: "puzzle";
  pieces: number;
};

type Doll = ToyBase & {
  kind: "doll";
  material: "plush" | "plastic";
};

type Toy = Doll | Puzzle | BoardGame;

在我们的代码中的某个地方,我们需要将模型中的所有玩具分组到可以由称为GroupedToys的类型描述的数据结构中。GroupedToys具有每个类别(或"kind")的属性和一个Toy数组作为值。一个groupToys函数接受一个未排序的玩具列表,并按种类分组:

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
};

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
  }
  return groups;
}

此代码中已经有一些美好之处。首先,在声明groups时我们使用了显式的类型注解,这确保我们没有忘记任何类别。此外,由于GroupedToys的键与Toy"kind"类型的联合相同,我们可以轻松地通过toy.kind索引访问groups

几个月和开发周期过去了,我们需要再次触及我们的模型。现在玩具店正在销售原始或者说是互锁玩具积木的替代供应商。我们将新类型Bricks连接到我们的Toy模型:

type Bricks = ToyBase & {
  kind: "bricks",
  pieces: number;
  brand: string;
}

type Toy = Doll | Puzzle | BoardGame | Bricks;

因为 groupToys 需要处理 Bricks,我们会收到一个很好的错误,因为 GroupedToys"bricks" 种类一无所知:

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
//  ^- Element implicitly has an 'any' type because expression
//     of type '"boardgame" | "puzzle" | "doll" | "bricks"' can't
//     be used to index type 'GroupedToys'.
//     Property 'bricks' does not exist on type 'GroupedToys'.(7053)
  }
  return groups;
}

这是 TypeScript 中期望的行为:知道何时类型不再匹配。这应该引起我们的注意。让我们更新 GroupedToysgroupToys

type GroupedToys = {
  boardgame: Toy[];
  puzzle: Toy[];
  doll: Toy[];
  bricks: Toy[];
};

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {
    boardgame: [],
    puzzle: [],
    doll: [],
    bricks: [],
  };
  for (let toy of toys) {
    groups[toy.kind].push(toy);
  }
  return groups;
}

有一件令人烦恼的事情:分组玩具的任务总是相同的。无论我们的模型如何变化,我们始终会按种类选择并推入数组。我们需要随着每次变化来维护 groups,但如果我们改变对群组的思考方式,我们可以优化变化。首先,我们将类型 GroupedToys 改为具有可选属性。其次,如果尚未进行任何初始化,我们用空数组初始化每个组:

type GroupedToys = {
  boardgame?: Toy[];
  puzzle?: Toy[];
  doll?: Toy[];
  bricks?: Toy[];
};

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    // Initialize when not available
    groups[toy.kind] = groups[toy.kind] ?? [];
    groups[toy.kind]?.push(toy);
  }
  return groups;
}

我们不再需要维护 groupToys。唯一需要维护的是类型 GroupedToys。如果我们仔细观察 GroupedToys,我们会发现它与 Toy 有一个隐含的关系。每个属性键都是 Toy["kind"] 的一部分。让我们将这种关系变得显式。通过映射类型,我们基于 Toy["kind"] 中的每种类型创建一个新的对象类型。

Toy["kind"] 是一个字符串文字的联合:"boardgame" | "puzzle" | "doll" | "bricks"。由于我们有一个非常有限的字符串集合,这个联合的每个元素将被用作自己的属性键。让我们稍作思考:我们可以使用一个类型作为新生成的类型的属性键。每个属性都有一个可选的类型修饰符,并指向一个 Toy[]

type GroupedToys = {
  [k in Toy["kind"]]?: Toy[];
};

太棒了!每次我们改变 Toy,我们立即改变 Toy[]。我们的代码完全不需要改动;我们仍然可以像以前一样按种类分组。

这是我们有潜力泛化的模式。让我们创建一个 Group 类型,它接受一个集合并按特定选择器进行分组。我们想要创建一个具有两个类型参数的通用类型:

  • Collection 可以是任何内容。

  • SelectorCollection 的一个键,因此它可以创建相应的属性。

我们的第一次尝试是采用 GroupedToys 中的内容,并用类型参数替换具体类型。这会创建我们需要的内容,但也会导致错误:

// How to use it
type GroupedToys = Group<Toy, "kind">;

type Group<Collection, Selector extends keyof Collection> = {
  [x in Collection[Selector]]?: Collection[];
//     ^ Type 'Collection[Selector]' is not assignable
//       to type 'string | number | symbol'.
//       Type 'Collection[keyof Collection]' is not
//       assignable to type 'string | number | symbol'.
//       Type 'Collection[string] | Collection[number]
//        | Collection[symbol]' is not assignable to
//       type 'string | number | symbol'.
//       Type 'Collection[string]' is not assignable to
//       type 'string | number | symbol'.(2322)
};

TypeScript 警告我们 Collection[string] | Collection[number] | Collection[symbol] 可能会导致任何结果,而不仅仅是可以用作键的内容。这是真的,我们需要为此做好准备。我们有两个选择。

首先,在 Collection 上使用类型约束,指向 Record<string, any>Record 是一个实用类型,它生成一个新对象,其中第一个参数提供所有键,第二个参数提供类型:

// This type is built-in!
type Record<K extends string | number | symbol, T> = { [P in K]: T; };

这将 Collection 提升为通配符对象,有效地禁用了来自 Groups 的类型检查。这没关系,因为如果某个东西不能作为属性键使用,TypeScript 会将其丢弃。因此最终的 Group 有两个约束类型参数:

type Group<
  Collection extends Record<string, any>,
  Selector extends keyof Collection
> = {
  [x in Collection[Selector]]: Collection[];
};

第二个选项是对每个键进行检查,看它是否是有效的字符串键。我们可以使用条件类型来查看Collection[Selector]是否确实是键的有效类型。否则,我们会通过选择never来移除此类型。条件类型本身就是一种特殊情况,在第 5.4 节中我们对此进行了详细讨论:

type Group<Collection, Selector extends keyof Collection> = {
  [k in Collection[Selector] extends string
    ? Collection[Selector]
    : never]?: Collection[];
};

请注意,我们已经移除了可选类型修饰符。我们这样做是因为使键可选不是分组的任务。我们有另一种类型来处理这个问题:Partial<T>,这是另一种映射类型,使对象类型中的每个属性变为可选:

// This type is built-in!
type Partial<T> = { [P in keyof T]?: T[P] };

无论你创建了哪个 Group 辅助函数,现在你都可以通过告诉 TypeScript 你想要一个 Partial(将所有属性变为可选属性)的 Group of Toys,来创建一个 GroupedToys 对象,通过 "kind"

type GroupedToys = Partial<Group<Toy, "kind">>;

现在读起来很不错。

4.6 使用断言签名修改对象

问题

在代码中的某个函数执行后,你知道值的类型已经改变了。

解决方案

使用断言签名可以独立于ifswitch语句改变类型。

讨论

JavaScript 是一种非常灵活的语言。其动态类型特性允许您在运行时更改对象,动态添加新属性。开发者们利用了这一点。有些情况下,例如,您遍历一个元素集合并需要断言某些属性。然后,您存储一个 checked 属性,并将其设置为 true,这样您就知道您已经通过了某个标记点:

function check(person: any) {
  person.checked = true;
}

const person = {
  name: "Stefan",
  age: 27,
};

check(person); // person now has the checked property

person.checked; // this is true!

你希望在类型系统中反映这种行为;否则,你将不得不不断地进行额外检查,以确定对象中是否存在某些属性,尽管你可以确信它们存在。

断言某些属性存在的一种方法是使用类型断言。我们说在某个时间点,此属性具有不同的类型:

(person as typeof person & { checked: boolean }).checked = true;

不过,你需要一遍又一遍地进行这种类型断言,因为它们不会改变person的原始类型。另一种断言某些属性可用的方法是创建类型谓词,就像第 3.5 节中展示的那样:

function check<T>(obj: T): obj is T & { checked: true } {
  (obj as T & { checked: boolean }).checked = true;
  return true;
}

const person = {
  name: "Stefan",
  age: 27,
};

if (check(person)) {
  person.checked; // checked is true!
}

尽管这种情况有些不同,这使得check函数感觉笨拙:你需要在断言函数中执行额外的条件并返回true。这感觉不对劲。

幸运的是,TypeScript 还有另一种我们可以在这种情况下利用的技术:断言签名。断言签名可以在控制流中改变值的类型,而无需条件语句。它们已经为 Node.js 的 assert 函数建模,该函数接受一个条件,如果条件不为真则抛出错误。这意味着在调用 assert 后,你可能比之前拥有更多的信息。例如,如果你调用 assert 并检查值是否为 string 类型,你知道在这个 assert 函数之后该值应该是 string

function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new Error(msg);
  }
}

function yell(str: any) {
  assert(typeof str === "string");
  // str is string
  return str.toUpperCase();
}

请注意,如果条件为 false,该函数会提前终止。它会抛出一个错误,即 never 情况。如果此函数通过,您确实可以断言该条件。

虽然断言签名已经为 Node.js 的 assert 函数建模,但您可以断言任何您喜欢的类型。例如,您可以有一个函数,接受任何类型的值进行加法,但要求这些值必须是 number 才能继续:

function assertNumber(val: any): asserts val is number {
  if (typeof val !== "number") {
    throw Error("value is not a number");
  }
}

function add(x: unknown, y: unknown): number {
  assertNumber(x); // x is number
  assertNumber(y); // y is number
  return x + y;
}

所有关于断言签名的示例都是在断言和条件不成立时抛出错误后立即终止的。但是我们可以使用相同的技术告诉 TypeScript 更多的属性可用。我们编写一个函数,与之前的断言函数中的 check 非常相似,但这次我们不需要返回 true。我们设置属性,并且由于 JavaScript 中的对象是按值传递的,所以我们可以断言,在调用此函数后,无论我们传入什么,它都具有一个 checked 属性,该属性为 true

function check<T>(obj: T): asserts obj is T & { checked: true } {
  (obj as T & { checked: boolean }).checked = true;
}

const person = {
  name: "Stefan",
  age: 27,
};

check(person);

有了这个技巧,我们可以即时修改值的类型。这是一个鲜为人知但非常有用的技术。

4.7 类型映射

问题

您编写一个工厂函数,根据字符串标识符创建特定子类型的对象,并且有很多可能的子类型。

解决方案

将所有子类型存储在类型映射中,通过索引访问扩展,并使用像 Partial<T> 这样的映射类型。

讨论

如果您希望根据一些基本信息创建复杂对象的变体,工厂函数是一个很好的选择。您可能从浏览器 JavaScript 中了解到一个场景是创建元素。document.createElement 函数接受元素的标签名,您会得到一个对象,可以修改所有必要的属性。

您希望为这种创建增加一些趣味,可以调用一个名为 cr⁠ea⁠te​El⁠eme⁠nt 的漂亮工厂函数。它不仅接受元素的标签名,还会列出属性的清单,因此您无需逐个设置每个属性:

// Using create Element

// a is HTMLAnchorElement
const a = createElement("a", { href: "https://fettblog.eu" });
// b is HTMLVideoElement
const b = createElement("video", { src: "/movie.mp4", autoplay: true });
// c is HTMLElement
const c = createElement("my-element");

您希望为此创建良好的类型,因此需要注意两件事:

  • 确保您仅创建有效的 HTML 元素。

  • 提供一个接受 HTML 元素属性子集的类型。

首先我们来处理有效的 HTML 元素。大约有 140 种可能的 HTML 元素,这实在是很多。每个元素都有一个标签名,可以表示为字符串,并且在 DOM 中有一个对应的原型对象。使用你的 tsconfig.json 中的 dom 库,TypeScript 中有关这些原型对象的信息以类型的形式存在。你可以找出所有这 140 个元素名。

提供元素标签名与原型对象之间的映射的一个好方法是使用 类型映射。类型映射是一种技术,您可以采用类型别名或接口,并让键指向相应的类型变体。然后,您可以使用字符串字面类型的索引访问获取正确的类型变体:

type AllElements = {
  a: HTMLAnchorElement;
  div: HTMLDivElement;
  video: HTMLVideoElement;
  //... and ~140 more!
};

// HTMLAnchorElement
type A = AllElements["a"];

看起来像是使用索引访问来访问 JavaScript 对象的属性,但请记住我们仍然在类型级别上工作。这意味着索引访问可以很广泛:

type AllElements = {
  a: HTMLAnchorElement;
  div: HTMLDivElement;
  video: HTMLVideoElement;
  //... and ~140 more!
};

// HTMLAnchorElement | HTMLDivELement
type AandDiv = AllElements["a" | "div"];

让我们使用这个映射来为createElement函数命名类型。我们使用一个泛型类型参数,该参数受限于AllElements的所有键,这使我们只能传递有效的 HTML 元素:

function createElement<T extends keyof AllElements>(tag: T): AllElements[T] {
  return document.createElement(tag as string) as AllElements[T];
}

// a is HTMLAnchorElement
const a = createElement("a");

在这里使用泛型来将字符串文字固定到字面类型,我们可以用它来索引类型映射中正确的 HTML 元素变体。还要注意,使用do⁠cum⁠ent.​cre⁠ate⁠Ele⁠me⁠nt需要两个类型断言。一个使集合变宽(从Tstring),另一个使集合变窄(从HTMLElementAllElements[T])。这两个断言表明我们必须处理一个在我们控制之外的 API,正如在食谱 3.9 中所确定的那样。我们将在稍后处理这些断言。

现在我们想要提供选项来传递所述 HTML 元素的额外属性,例如将href设置为HTMLAnchorElement等。所有属性已经在各自的HTMLElement变体中,但它们是必需的,而不是可选的。我们可以使用内置类型Partial<T>使所有属性变为可选。它是一个映射类型,它获取某种类型的所有属性并添加一个类型修饰符:

type Partial<T> = { [P in keyof T]?: T[P] };

我们扩展我们的函数,添加一个可选参数props,它是AllElements中索引元素的Partial。这样,我们知道如果传递一个"a",我们只能设置在HTMLAnchorElement中可用的属性:

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T] {
  const elem = document.createElement(tag as string) as AllElements[T];
  return Object.assign(elem, props);
}

const a = createElement("a", { href: "https://fettblog.eu" });
const x = createElement("a", { src: "https://fettblog.eu" });
//                           ^--
// Argument of type '{ src: string; }' is not assignable to parameter
// of type 'Partial<HTMLAnchorElement>'.
// Object literal may only specify known properties, and 'src' does not
// exist in type 'Partial<HTMLAnchorElement>'.(2345)

太棒了!现在轮到你找出所有 140 个 HTML 元素了。或者不找。有人已经完成了这项工作,并将HTMLElementTagNameMap放入lib.dom.ts中。所以让我们使用这个:

function createElement<T extends keyof HTMLElementTagNameMap>(
  tag: T,
  props?: Partial<HTMLElementTagNameMap[T]>
): HTMLElementTagNameMap[T] {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

这也是document.createElement使用的接口,因此你的工厂函数和内置函数之间没有摩擦。不需要额外的断言。

只有一个注意事项。你只能使用HT⁠ML​Ele⁠men⁠tTa⁠gNa⁠me⁠Map提供的 140 个元素。如果你想创建 SVG 元素,或者可以完全自定义元素名称的 Web 组件,你的工厂函数突然太受限制了。

为了允许更多——就像document.createElement那样——我们需要再次将所有可能的字符串添加到混合中。HTMLElementTagNameMap是一个接口。所以我们可以使用声明合并来通过索引签名扩展接口,其中我们将所有剩余的字符串映射到HTMLUnknownElement

interface HTMLElementTagNameMap {
  [x: string]: HTMLUnknownElement;
};

function createElement<T extends keyof HTMLElementTagNameMap>(
  tag: T,
  props?: Partial<HTMLElementTagNameMap[T]>
): HTMLElementTagNameMap[T] {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

// a is HTMLAnchorElement
const a = createElement("a", { href: "https://fettblog.eu" });
// b is HTMLUnknownElement
const b = createElement("my-element");

现在我们拥有我们想要的一切:

  • 一个出色的工厂函数来创建类型化的 HTML 元素

  • 只需一个配置对象即可设置元素属性的可能性

  • 灵活性可以创建比定义的更多的元素

最后一个很棒,但是如果你只想允许 Web 组件呢?Web 组件有一个约定;它们需要在它们的标签名称中有一个破折号。我们可以使用字符串模板文字类型上的映射类型来建模这一点。你将在第六章中详细了解字符串模板文字类型。

现在,你需要知道的唯一一件事情是,我们创建了一组字符串,其中模式是任意字符串后跟一个破折号,然后是任意字符串。这足以确保我们只传递正确的元素名称。

映射类型仅适用于类型别名,而不是接口声明,因此我们需要重新定义一个AllElements类型:

type AllElements = HTMLElementTagNameMap &
  {
    [x in `${string}-${string}`]: HTMLElement;
  };

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T] {
  const elem = document.createElement(tag as string) as AllElements[T];
  return Object.assign(elem, props);
}

const a = createElement("a", { href: "https://fettblog.eu" }); // OK
const b = createElement("my-element"); // OK

const c = createElement("thisWillError");
//                      ^
// Argument of type '"thisWillError"' is not
// assignable to parameter of type '`${string}-${string}`
// | keyof HTMLElementTagNameMap'.(2345)

太棒了。使用AllElements类型,我们还可以获得类型断言,尽管我们不太喜欢这样。在这种情况下,我们可以使用函数重载,定义两个声明:一个用于我们的用户,另一个用于实现函数。您可以在《食谱》2.6 和 12.7 中了解更多关于这种函数重载技术的信息:

function createElement<T extends keyof AllElements>(
  tag: T,
  props?: Partial<AllElements[T]>
): AllElements[T];
function createElement(tag: string, props?: Partial<HTMLElement>): HTMLElement {
  const elem = document.createElement(tag);
  return Object.assign(elem, props);
}

我们已经准备好了。我们使用映射类型索引签名定义了一个类型映射,并使用泛型类型参数非常明确地表达了我们的意图。这是在我们的 TypeScript 工具箱中多种工具的巧妙结合。

4.8 使用 ThisType 来定义对象中的 this

问题

您的应用程序需要具有方法的复杂配置对象,其中this在使用时具有不同的上下文。

解决方案

使用内置的泛型ThisType<T>来定义正确的this

讨论

VueJS这样的框架非常依赖于工厂函数,其中您传递一个全面的配置对象来定义初始数据、计算属性和每个实例的方法。您希望为应用程序的组件创建类似的行为。想法是提供一个具有三个属性的配置对象:

一个data函数

返回值是实例的初始数据。在此函数中,您不应该访问配置对象的任何其他属性。

一个computed属性

这是用于基于初始数据的计算属性。计算属性使用函数声明。它们可以像普通属性一样访问初始数据。

一个methods属性

方法可以被调用,并且可以访问计算属性以及初始数据。当方法访问计算属性时,它们像访问普通属性一样访问它:无需调用函数。

查看正在使用的配置对象时,有三种不同的方法来解释this。在data中,this根本没有任何属性。在computed中,每个函数都可以像其对象的一部分一样访问data的返回值,通过this来访问。在methods中,每个方法可以像在this中一样访问计算属性和data

const instance = create({
  data() {
    return {
      firstName: "Stefan",
      lastName: "Baumgartner",
    };
  },
  computed: {
    fullName() {
      // has access to the return object of data
      return this.firstName + " " + this.lastName;
    },
  },
  methods: {
    hi() {
      // use computed properties just like normal properties
      alert(this.fullName.toLowerCase());
    },
  },
});

这种行为是特殊的,但并不罕见。并且像这样的行为,我们绝对希望依赖良好的类型。

注意

在本课程中,我们只关注类型,而不是实际的实现,因为那将超出本章的范围。

让我们为每个属性创建类型。我们定义了一个类型Options,我们将逐步完善它。首先是data函数。data可以由用户定义,因此我们希望使用泛型类型参数来指定data的数据:

type Options<Data> = {
  data(this: {})?: Data;
};

因此,一旦我们在data函数中指定实际的返回值,Data占位符就会被实际对象类型替换。请注意,我们还定义了this指向空对象,这意味着我们不能从配置对象中访问任何其他属性。

接下来,我们定义了computedcomputed是一个函数对象。我们添加了另一个名为Computed的泛型类型参数,并允许通过使用进行类型化。在这里,this会更改为Data的所有属性。由于我们无法像在data函数中那样设置this,我们可以使用内置的辅助类型ThisType并将其设置为泛型类型参数Data

type Options<Data, Computed> = {
  data(this: {})?: Data;
  computed?: Computed & ThisType<Data>;
};

这使得我们可以访问例如this.firstName,就像前面的示例中一样。最后但并非最不重要的是,我们想要指定methodsmethods再次特殊,因为您不仅通过this获取Data,还可以通过属性访问获取所有方法和所有计算属性。

Computed保存所有计算属性作为函数。我们需要它们的值,更具体地说,是它们的返回值。如果我们通过属性访问访问fullName,我们期望它是一个string

为此,我们创建了一个名为MapFnToProp的辅助类型。它接受一个类型,该类型是函数对象,并将其映射到返回值类型。内置的ReturnType辅助类型在这种情况下非常完美:

// An object of functions ...
type FnObj = Record<string, () => any>;

// ... to an object of return types
type MapFnToProp<FunctionObj extends FnObj> = {
  [K in keyof FunctionObj]: ReturnType<FunctionObj[K]>;
};

我们可以使用MapFnToProp为新添加的泛型类型参数Methods设置ThisType。我们还将DataMethods混合在一起。为了将Computed泛型类型参数传递给MapFnToProp,它需要被限制为FnObj,这与MapFnToProp的第一个参数FunctionObj的约束相同:

type Options<Data, Computed extends FnObj, Methods> = {
  data(this: {})?: Data;
  computed?: Computed & ThisType<Data>;
  methods?: Methods & ThisType<Data & MapFnToProp<Computed> & Methods>;
};

就是这种类型!我们获取所有泛型类型属性并将它们添加到create工厂函数中:

declare function create<Data, Computed extends FnObj, Methods>(
  options: Options<Data, Computed, Methods>
): any;

通过使用,所有泛型类型参数将被替换。并且Options的类型方式,我们得到了所有必要的自动完成,以确保我们不会遇到问题,正如在图 4-1 中所见。

此示例很好地展示了 TypeScript 如何用于类型复杂的 API,其中进行了许多对象操作。^(1)

tscb 0401

图 4-1. 工厂函数中方法配置,具有访问所有正确属性的权限

4.9 向泛型类型参数添加 Const 上下文

问题

当您将复杂的文字值传递给函数时,TypeScript 会将类型扩展为更一般的东西。虽然这在许多情况下是期望的行为,但在某些情况下,您希望处理文字类型而不是扩展类型。

解决方案

在你的泛型类型参数前面添加const修饰符,以保持传递的值处于const 上下文

讨论

单页应用(SPA)框架倾向于在 JavaScript 中重新实现许多浏览器功能。例如,像History API这样的功能使得覆盖常规导航行为成为可能,SPA 框架利用它在浏览器中切换页面时避免真正的页面重新加载,通过交换页面内容和更改浏览器中的 URL 来实现。

想象一下,您正在开发一个使用所谓的路由器在页面之间导航的极简 SPA 框架。页面被定义为组件,并且ComponentConstructor接口知道如何在您的网站上实例化和渲染新元素:

interface ComponentConstructor {
  new(): Component;
}

interface Component {
  render(): HTMLElement;
}

路由器应该接受一个组件列表和相关路径,以string形式存储。在通过router函数创建路由器时,应返回一个对象,该对象允许你navigate到所需的路径:

type Route = {
  path: string;
  component: ComponentConstructor;
};

function router(routes: Route[]) {
  return {
    navigate(path: string) {
      // ...
    },
  };
}

实际导航的实现方式对我们目前不重要;相反,我们希望专注于函数接口的类型定义。

路由器按预期工作;它接受一个Route对象数组并返回一个带有navigate函数的对象,允许我们从一个 URL 导航到另一个 URL 并渲染新组件:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/faq");

您立即看到的是类型太广泛了。如果我们允许导航到每个可用的string,那么没有任何东西可以阻止我们使用导致无处可去的虚假路由。我们需要为已准备和可用的信息实现一些错误处理。那么,为什么不使用它呢?

我们的第一个想法是用泛型类型参数替换具体类型。TypeScript 处理泛型替换的方式是,如果我们有一个字面类型,TypeScript 会相应地进行子类型化。引入T代表Route,并使用T["path"]而不是string接近我们想要实现的目标:

function router<T extends Route>(routes: T[]) {
  return {
    navigate(path: T["path"]) {
      // ...
    },
  };
}

理论上,这应该有效。如果我们回顾一下 TypeScript 在这种情况下如何处理字面、基本类型,我们期望值会被缩小到字面类型:

function getPath<T extends string>(route: T): T {
  return route;
}

const path = getPath("/"); // "/"

您可以在 Recipe 4.3 中了解更多信息。前面例子中的path处于const 上下文,因为返回的值是不可变的。

唯一的问题是,我们正在处理对象和数组,并且 TypeScript 倾向于将对象和数组的类型扩展为更一般的类型,以允许值的可变性。如果我们看一个类似的例子,但带有嵌套对象,我们会发现 TypeScript 接受更广泛的类型:

type Routes = {
  paths: string[];
};

function getPaths<T extends Routes>(routes: T): T["paths"] {
  return routes.paths;
}

const paths = getPaths({ paths: ["/", "/about"] }); // string[]

对于对象,pathsconst 上下文仅适用于变量的绑定,而不适用于其内容。这最终导致我们丢失一些我们正确类型化navigate所需的信息。

解决这种限制的一种方法是手动应用const 上下文,这需要我们重新定义输入参数为readonly

function router<T extends Route>(routes: readonly T[]) {
  return {
    navigate(path: T["path"]) {
      history.pushState({}, "", path);
    },
  };
}

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
] as const);

rtr.navigate("/about");

这样做虽然有效,但也要求我们在编码时不要忘记一个非常重要的细节。而积极记住解决方法总是灾难的开端。

幸运的是,TypeScript 允许我们从泛型类型参数中请求const context。而不是将其应用于值,我们通过将const修饰符添加到泛型类型参数中,用具体值but替代const context

function router<const T extends Route>(routes: T[]) {
  return {
    navigate(path: T["path"]) {
      // tbd
    },
  };
}

我们可以像往常一样使用我们的路由器,甚至可以为可能的路径得到自动完成:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/about");

更好的是,当我们传入无效参数时,我们会得到适当的错误提示:

const rtr = router([
  {
    path: "/",
    component: Main,
  },
  {
    path: "/about",
    component: About,
  },
])

rtr.navigate("/faq");
//             ^
// Argument of type '"/faq"' is not assignable to
// parameter of type '"/" | "/about"'.(2345)

精彩的是:这一切都隐藏在函数的 API 中。我们所期望的变得更加清晰,接口告诉我们约束条件,使用router确保类型安全时无需额外操作。

^(1) 特别感谢Type Challenges的创建者提供了这个精美的例子。

第五章:条件类型

在本章中,我们将仔细研究一种 TypeScript 独有的特性:条件类型。条件类型允许我们根据子类型检查来选择类型,使我们能够在类型空间中移动,并在设计接口和函数签名时获得更大的灵活性。

条件类型是一个强大的工具,允许您动态生成类型。正如在此 GitHub 问题中展示的那样,它使得 TypeScript 的类型系统变得完备,这既令人印象深刻,也有些可怕。当您手中有这么强大的功能时,很容易失去对实际需要的类型的关注,从而陷入死胡同或制作过于难以阅读的类型。在本书中,我们将彻底讨论条件类型的使用,始终重新评估我们所做的是否确实达到了我们的预期目标。

请注意,本章比其他章节要短得多。这不是因为条件类型没有多少值得说的:恰恰相反。这更多是因为我们将在随后的章节中看到条件类型的良好使用。在这里,我们希望专注于基础知识,并建立您可以在需要时使用和参考的术语。

5.1 管理复杂的函数签名

问题

您正在创建一个具有不同参数和返回类型的函数。使用函数重载来管理所有变化变得越来越复杂。

解决方案

使用条件类型来定义一组规则,用于参数和返回类型。

讨论

您创建软件,根据用户定义的输入将某些属性显示为标签。您区分StringLabelNumberLabel以允许不同类型的过滤操作和搜索:

type StringLabel = {
  name: string;
};

type NumberLabel = {
  id: number;
};

用户输入可以是字符串或数字。createLabel函数将输入作为原始类型,并生成StringLabelNumberLabel对象:

function createLabel(input: number | string): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else {
    return { name: input };
  }
}

基本功能完成后,您会发现您的类型太过广泛。如果输入一个numbercreateLabel的返回类型仍然是NumberLabel | StringLabel,但它只能是NumberLabel。解决方案?添加函数重载以明确定义类型关系,就像我们在第二章第六部分中学到的那样:

function createLabel(input: number): NumberLabel;
function createLabel(input: string): StringLabel;
function createLabel(input: number | string): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else {
    return { name: input };
  }
}

函数重载的工作方式是,重载本身定义了用法的类型,而最后一个函数声明定义了函数体实现的类型。使用createLabel,我们可以传入一个string并获得StringLabel,或传入一个number并获得NumberLabel,因为这些是外部可用的类型。

这在我们无法事先缩小输入类型的情况下是个问题。我们缺少一个向外界传递允许输入为numberstring的函数类型:

function inputToLabel(input: string | number) {
  return createLabel(input);
  //                    ^
  // No overload matches this call. (2769)
}

为了规避这种情况,我们添加另一个重载,以匹配非常广泛的输入类型的实现函数签名:

function createLabel(input: number): NumberLabel;
function createLabel(input: string): StringLabel;
function createLabel(input: number | string): NumberLabel | StringLabel;
function createLabel(input: number | string): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else {
    return { name: input };
  }
}

我们在这里看到,我们已经需要三个重载和四个函数签名声明,以描述此功能的最基本行为。从这里开始,情况只会变得更糟。

我们希望扩展我们的函数,以能够复制现有的StringLabelNumberLabel对象。这最终意味着需要更多的重载:

function createLabel(input: number): NumberLabel;
function createLabel(input: string): StringLabel;
function createLabel(input: StringLabel): StringLabel;
function createLabel(input: NumberLabel): NumberLabel;
function createLabel(input: string | StringLabel): StringLabel;
function createLabel(input: number | NumberLabel): NumberLabel;
function createLabel(
  input: number | string | StringLabel | NumberLabel
): NumberLabel | StringLabel;
function createLabel(
  input: number | string | StringLabel | NumberLabel
): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else if (typeof input === "string") {
    return { name: input };
  } else if ("id" in input) {
    return { id: input.id };
  } else {
    return { name: input.name };
  }
}

坦率地说,根据我们希望类型提示有多么表达,我们可以编写较少但也更多的函数重载。问题仍然显而易见:更多的多样性导致更复杂的函数签名。

TypeScript 工具包中的一个工具可以帮助解决这种情况:条件类型。条件类型允许我们根据某些子类型检查选择类型。我们询问泛型类型参数是否属于某个子类型,如果是,则从true分支返回类型,否则从false分支返回类型。

例如,如果Tstring的子类型(即所有字符串或非常具体的字符串),则以下类型返回输入参数。否则,返回never

type IsString<T> = T extends string ? T : never;

type A = IsString<string>; // string
type B = IsString<"hello" | "world">; // string
type C = IsString<1000>; // never

TypeScript 从 JavaScript 的三元运算符中借用了这种语法。就像 JavaScript 的三元运算符一样,它检查某些条件是否有效。但与编程语言中通常的一套条件不同,TypeScript 的类型系统仅检查输入类型的值是否包含在我们检查的值集合中。

借助这个工具,我们能够编写一个名为GetLabel<T>的条件类型。我们检查输入是否为stringStringLabel。如果是,则返回StringLabel;否则,我们知道它必须是NumberLabel

type GetLabel<T> = T extends string | StringLabel ? StringLabel : NumberLabel;

此类型仅检查输入stringStringLabelnumberNumberLabel是否位于else分支中。如果我们希望安全,还应包括对可能产生NumberLabel的输入进行检查,通过嵌套条件类型:

type GetLabel<T> = T extends string | StringLabel
  ? StringLabel
  : T extends number | NumberLabel
  ? NumberLabel
  : never;

现在是连接泛型的时候了。我们在cr⁠ea⁠te​Lab⁠el中添加一个新的泛型类型参数T,它受到所有可能输入类型的限制。这个T参数作为GetLabel<T>的输入,将产生相应的返回类型:

function createLabel<T extends number | string | StringLabel | NumberLabel>(
  input: T
): GetLabel<T> {
  if (typeof input === "number") {
    return { id: input } as GetLabel<T>;
  } else if (typeof input === "string") {
    return { name: input } as GetLabel<T>;
  } else if ("id" in input) {
    return { id: input.id } as GetLabel<T>;
  } else {
    return { name: input.name } as GetLabel<T>;
  }
}

现在我们已经准备好处理所有可能的类型组合,并且仍然可以从getLabel中获取正确的返回类型,所有这些只需一行代码即可完成。

如果你仔细观察,你会发现我们需要解决返回类型的类型检查问题。不幸的是,TypeScript 在处理泛型和条件类型时无法进行适当的控制流分析。通过少量类型断言告诉 TypeScript 我们正在处理正确的返回类型。

另一个解决方法是将具有条件类型的函数签名视为对原始广泛类型函数的重载:

function createLabel<T extends number | string | StringLabel | NumberLabel>(
  input: T
): GetLabel<T>;
function createLabel(
  input: number | string | StringLabel | NumberLabel
): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else if (typeof input === "string") {
    return { name: input };
  } else if ("id" in input) {
    return { id: input.id };
  } else {
    return { name: input.name };
  }
}

这样,我们为外部世界提供了一个灵活的类型,准确告诉我们基于输入能得到什么输出。而对于实现来说,你可以享有来自各种类型广泛集合的全面灵活性。

这是否意味着在所有情况下你应该优先选择条件类型而不是函数重载?未必。在 Recipe 12.7 中,我们会看到在某些情况下函数重载是更好的选择。

5.2 使用 never 进行过滤

问题

你有各种类型的联合体,但你只想要所有字符串的子类型。

解决方案

使用分布式条件类型来过滤正确的类型。

讨论

假设你的应用程序中有一些遗留代码,尝试重新创建类似 jQuery 的框架。你有自己的 ElementList 类型,其中有帮助函数用于向 HTMLElement 对象添加或删除类名,或者绑定事件监听器到事件上。

此外,你还可以通过索引访问来访问列表中的每个元素。这种 ElementList 的类型可以使用数字索引访问类型以及常规字符串属性键来描述:

type ElementList = {
  addClass: (className: string) => ElementList;
  removeClass: (className: string) => ElementList;
  on: (event: string, callback: (ev: Event) => void) => ElementList;
  length: number;
  [x: number]: HTMLElement;
};

这个数据结构被设计成具有流畅的接口。这意味着如果你调用 addClassremoveClass 等方法,你会得到相同的对象返回,因此可以链式调用你的方法。

这些方法的示例实现可能如下所示:

// begin excerpt
  addClass: function (className: string): ElementList {
    for (let i = 0; i < this.length; i++) {
      this[i].classList.add(className);
    }
    return this;
  },
  removeClass: function (className: string): ElementList {
    for (let i = 0; i < this.length; i++) {
      this[i].classList.remove(className);
    }
    return this;
  },
  on: function (event: string, callback: (ev: Event) => void): ElementList {
    for (let i = 0; i < this.length; i++) {
      this[i].addEventListener(event, callback);
    }
    return this;
  },
// end excerpt

作为内置集合(如 ArrayNodeList)的扩展,更改 HTMLElement 对象集上的东西变得非常方便:

declare const myCollection: ElementList;

myCollection
  .addClass("toggle-off")
  .removeClass("toggle-on")
  .on("click", (e) => {});

假设你需要维护你的 jQuery 替代品,并发现直接元素访问在某种程度上是不安全的。当你的应用程序的某些部分可以直接更改事物时,你将更难弄清楚变化来自何处,如果不是来自你精心设计的 ElementList 数据结构:

myCollection[1].classList.toggle("toggle-on");

由于你不能改变原始的库代码(太多部门依赖它),你决定在一个Proxy中封装原始的ElementList

Proxy 对象接受一个原始目标对象和一个处理程序对象,定义如何处理访问。以下实现展示了一个 Proxy,只允许读取访问,并且只有当属性键的类型为 string 而不是表示数字的字符串时:

const safeAccessCollection = new Proxy(myCollection, {
  get(target, property) {
    if (
      typeof property === "string" &&
      property in target &&
      "" + parseInt(property) !== property
    ) {
      return target[property as keyof typeof target];
    }
    return undefined;
  },
});
注意

Proxy 对象中,处理程序对象只接收字符串或符号属性。如果你使用数字进行索引访问,例如 0,JavaScript 会将其转换为字符串 "0"

这在 JavaScript 中效果很好,但我们的类型不再匹配。Proxy 构造函数的返回类型再次是 ElementList,这意味着数字索引访问仍然保持完整:

// Works in TypeScript throws in JavaScript
safeAccessCollection[0].classList.toggle("toggle-on");

我们需要告诉 TypeScript,我们现在处理的是一个没有数字索引访问的对象,通过定义一个新类型来实现。

让我们看看 ElementList 的键。如果我们使用 keyof 操作符,我们会得到一个 ElementList 类型对象所有可能访问方法的联合类型:

// resolves to "addClass" | "removeClass" | "on" | "length" | number
type ElementListKeys = keyof ElementList;

它包含四个字符串以及所有可能的数字。现在我们有了这个联合类型,我们可以创建一个条件类型,来过滤掉不是字符串的一切:

type JustStrings<T> = T extends string ? T : never;

JustStrings<T>就是我们所谓的分布条件类型。由于条件中的T单独存在于条件中—而不是包裹在对象或数组中—TypeScript 将一个联合类型的条件类型视为条件类型的联合。事实上,TypeScript 对联合T的每个成员进行相同的条件检查。

在我们的案例中,它穿过了keyof ElementList的所有成员:

type JustElementListStrings =
  | "addClass" extends string ? "addClass" : never
  | "removeClass" extends string ? "removeClass" : never
  | "on" extends string ? "on" : never
  | "length" extends string ? "length" : never
  | number extends string ? number : never;

唯一进入false分支的条件是最后一个条件,我们检查number是否是string的子类型,它不是。如果我们解决每个条件,我们最终得到一个新的联合类型:

type JustElementListStrings =
  | "addClass"
  | "removeClass"
  | "on"
  | "length"
  | never;

具有never的联合有效地删除了never。如果您有一个没有可能值的集合,并将其与值集合合并,那么这些值将保留下来:

type JustElementListStrings =
  | "addClass"
  | "removeClass"
  | "on"
  | "length";

这确切是我们考虑安全访问的键列表!通过使用Pick辅助类型,我们可以创建一个类型,通过挑选所有类型为string的键来有效地创建一个ElementList的超类型:

type SafeAccess = Pick<ElementList, JustStrings<keyof ElementList>>;

如果我们悬停在上面,我们看到结果类型正是我们所期望的:

type SafeAccess = {
  addClass: (className: string) => ElementList;
  removeClass: (className: string) => ElementList;
  on: (event: string, callback: (ev: Event) => void) => ElementList;
  length: number;
};

让我们将类型作为注释添加到safeAccessCollection。由于可以分配给超类型,TypeScript 将从那一刻起将safeAccessCollection视为无法从数字索引访问的类型:

const safeAccessCollection: Pick<
  ElementList,
  JustStrings<keyof ElementList>
> = new Proxy(myCollection, {
  get(target, property) {
    if (
      typeof property === "string" &&
      property in target &&
      "" + parseInt(property) !== property
    ) {
      return target[property as keyof typeof target];
    }
    return undefined;
  },
});

现在,当我们尝试从safeAccessCollection访问元素时,TypeScript 将向我们报错:

safeAccessCollection[1].classList.toggle("toggle-on");
// ^ Element implicitly has an 'any' type because expression of
// type '1' can't be used to index type
// 'Pick<ElementList, "addClass" | "removeClass" | "on" | "length">'.

这正是我们所需要的。分布条件类型的威力在于我们可以更改联合的成员。我们将在配方 5.3 中看到另一个示例,我们将使用内置的辅助类型。

5.3 按种类分组元素

问题

来自配方 4.5 的您的Group类型运行良好,但组的每个条目的类型过于宽泛。

解决方案

使用Extract辅助类型从联合类型中选择正确的成员。

讨论

让我们回到 3.1 和 4.5 章节中来自玩具店示例。我们开始用精心制作的模型,通过辨别联合类型,我们可以获得有关每个可能值的精确信息:

type ToyBase = {
  name: string;
  description: string;
  minimumAge: number;
};

type BoardGame = ToyBase & {
  kind: "boardgame";
  players: number;
};

type Puzzle = ToyBase & {
  kind: "puzzle";
  pieces: number;
};

type Doll = ToyBase & {
  kind: "doll";
  material: "plush" | "plastic";
};

type Toy = Doll | Puzzle | BoardGame;

然后,我们找到了一种方法派生另一种名为GroupedToys的类型,该类型从Toy派生,我们从kind属性的联合类型成员作为映射类型的属性键,每个属性的类型为Toy[]

type GroupedToys = {
  [k in Toy["kind"]]?: Toy[];
};

多亏了泛型,我们能够定义一个辅助类型Group<Collection, Selector>以便在不同情境下重复使用相同的模式:

type Group<
  Collection extends Record<string, any>,
  Selector extends keyof Collection
> = {
  [K in Collection[Selector]]: Collection[];
};

type GroupedToys = Partial<Group<Toy, "kind">>;

辅助类型运行良好,但有一个注意事项。如果我们悬停在生成的类型上,我们会看到,尽管Group<Collection, Selector>能够正确地选择Toy联合类型的辨别,但所有属性指向非常宽泛的Toy[]

type GroupedToys = {
  boardgame?: Toy[] | undefined;
  puzzle?: Toy[] | undefined;
  doll?: Toy[] | undefined;
};

但我们难道不应该了解更多吗?例如,为什么boardgame指向Toy[],当唯一现实的类型应该是BoardGame[]。同样适用于 puzzle 和 dolls,以及我们希望添加到收藏中的所有后续玩具。我们期望的类型应该更像这样:

type GroupedToys = {
  boardgame?: BoardGame[] | undefined;
  puzzle?: Puzzle[] | undefined;
  doll?: Doll[] | undefined;
};

我们可以通过Collection联合类型中提取相应成员来实现这种类型。幸运的是,有一个辅助类型可以做到这一点:Extract<T, U>,其中T是集合,UT的一部分。

Extract<T, U> 的定义如下:

type Extract<T, U> = T extends U ? T : never;

因为条件中的T是一个裸类型,T是一个分布条件类型,这意味着 TypeScript 检查T的每个成员是否是U的子类型,如果是,它将在联合类型中保留此成员。这对于从Toy中挑选正确的玩具组会如何工作呢?

假设我们想从Toy中挑选DollDoll有一些属性,但kind属性明显与其余部分不同。因此,要使类型只查找Doll意味着我们从Toy中提取每种类型,其中{ kind: "doll" }

type ExtractedDoll = Extract<Toy, { kind: "doll" }>;

使用分布条件类型,联合的条件类型是条件类型的联合,因此会检查T的每个成员是否符合U

type ExtractedDoll =
  BoardGame extends { kind: "doll" } ? BoardGame : never |
  Puzzle extends { kind: "doll" } ? Puzzle : never |
  Doll extends { kind: "doll" } ? Doll : never;

BoardGamePuzzle都不是{ kind: "doll" }的子类型,因此它们解析为never。但是Doll { kind: "doll" }的子类型,所以解析为Doll

type ExtractedDoll = never | never | Doll;

在与never的联合中,never会直接消失。因此结果类型是Doll

type ExtractedDoll = Doll;

这正是我们所需要的。让我们把这个检查加入我们的Group辅助类型中。幸运的是,我们已经拥有所有的部件来从组的集合中提取特定类型:

  • Collection本身,最终用Toy替换。

  • Selector中的鉴别特性,最终用"kind"替换。

  • 我们想要提取的鉴别类型是一个字符串类型,巧合的是也是我们在Group中映射出的属性键:K

因此,在Group<Collection, Selector>Extract<Toy, { kind: "doll" }>的泛型版本如下:

type Group<
  Collection extends Record<string, any>,
  Selector extends keyof Collection
> = {
  [K in Collection[Selector]]: Extract<Collection, { [P in Selector]: K }>[];
};

如果我们用Toy替换Collection,用"kind"替换Selector,那么类型读起来如下:

[K in Collection[Selector]]

对于Toy["kind"]的每个成员——在这种情况下,是"boardgame""puzzle""doll"——作为新对象类型的属性键。

Extract<Collection, …​>

Collection中提取,联合类型Toy,每个成员都是...​的子类型

{ [P in Selector]: K }

遍历Selector的每个成员——在我们的案例中,它只是"kind"——并创建一个对象类型,当属性键为"boardgame"时指向"boardgame",为"puzzle"时指向"puzzle",依此类推。

这就是我们为每个属性键选择Toy的方式。结果正如预期的那样:

type GroupedToys = Partial<Group<Toy, "kind">>;
// resolves to:
type GroupedToys = {
  boardgame?: BoardGame[] | undefined;
  puzzle?: Puzzle[] | undefined;
  doll?: Doll[] | undefined;
};

太棒了!类型现在清晰多了,我们可以确保在选择棋盘游戏时不必处理拼图。但是一些新问题已经出现。

由于每个属性的类型都更加精细,并且不指向非常广泛的 Toy 类型,TypeScript 在正确解析组内每个集合时有些困难:

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    groups[toy.kind] = groups[toy.kind] ?? [];
//  ^ Type 'BoardGame[] | Doll[] | Puzzle[]' is not assignable to
//    type '(BoardGame[] & Puzzle[] & Doll[]) | undefined'. (2322)
    groups[toy.kind]?.push(toy);
//                         ^
//  Argument of type 'Toy' is not assignable to
//  parameter of type 'never'.  (2345)
  }
  return groups;
}

问题在于 TypeScript 仍然认为 toy 可能是所有玩具,而 group 的每个属性指向一些非常具体的玩具。有三种方法来解决这个问题。

首先,我们可以再次检查每个成员。由于 TypeScript 认为 toy 是一个非常广泛的类型,缩小范围可以再次明确关系:

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    switch (toy.kind) {
      case "boardgame":
        groups[toy.kind] = groups[toy.kind] ?? [];
        groups[toy.kind]?.push(toy);
        break;
      case "doll":
        groups[toy.kind] = groups[toy.kind] ?? [];
        groups[toy.kind]?.push(toy);
        break;
      case "puzzle":
        groups[toy.kind] = groups[toy.kind] ?? [];
        groups[toy.kind]?.push(toy);
        break;
    }
  }
  return groups;
}

这样做是有效的,但是我们要避免大量的重复和重复。

其次,我们可以使用类型断言来扩展 groups[toy.kind] 的类型,以便 TypeScript 可以确保索引访问:

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    (groups[toy.kind] as Toy[]) = groups[toy.kind] ?? [];
    (groups[toy.kind] as Toy[])?.push(toy);
  }
  return groups;
}

这实际上就像我们对 GroupedToys 进行更改之前一样有效,并且类型断言告诉我们,我们在这里有意改变了类型以摆脱类型错误。

第三,我们可以进行一些间接操作。我们不直接将 toy 添加到组中,而是使用一个帮助函数 assign,在其中使用泛型:

function groupToys(toys: Toy[]): GroupedToys {
  const groups: GroupedToys = {};
  for (let toy of toys) {
    assign(groups, toy.kind, toy);
  }
  return groups;
}

function assign<T extends Record<string, K[]>, K>(
  groups: T,
  key: keyof T,
  value: K
) {
  // Initialize when not available
  groups[key] = groups[key] ?? [];
  groups[key]?.push(value);
}

在这里,我们通过使用 TypeScript 的泛型替换来缩小 Toy 联合的正确成员:

  • groupsT,一个 Record<string, K[]>K[] 可能会非常广泛。

  • keyT 相关:T 的属性键。

  • value 的类型是 K

当我们调用 assign 时,所有三个函数参数都与彼此相关,并且我们设计的类型关系方式使我们可以安全地访问 groups[key] 并将 value 推入数组。

此外,当我们调用 assign 时,每个参数的类型都符合我们刚刚设置的泛型类型约束。如果您想了解更多关于这种技术的信息,请查看 Recipe 12.6。

5.4 移除特定对象属性

问题

您希望创建一个通用的帮助对象类型,根据其类型而不是属性名称选择属性。

解决方案

在映射属性键时,使用条件类型和类型断言进行过滤。

讨论

TypeScript 允许您基于其他类型创建类型,因此您可以保持它们更新,而不必维护每一个派生类型。我们已经在早期的示例中看到了一些例子,比如 Recipe 4.5。在以下场景中,我们想根据其属性的类型调整现有对象类型。让我们看一个 Person 类型:

type Person = {
  name: string;
  age: number;
  profession?: string;
};

它由两个字符串组成 — professionname — 以及一个数字:age。我们想创建一个仅包含字符串类型属性的类型:

type PersonStrings = {
  name: string;
  profession?: string;
};

TypeScript 已经有一些辅助类型来处理过滤属性名。例如,映射类型 Pick<T> 获取对象的一部分键,以创建一个仅包含这些键的新对象:

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
}

// Only includes "name"
type PersonName = Pick<Person, "name">;

// Includes "name" and "profession"
type PersonStrings = Pick<Person, "name" | "profession">;

如果我们想要移除某些属性,我们可以使用 Omit<T>,它与 Pick<T> 类似,只是我们通过一个稍微改变的属性集进行映射,其中我们移除不想包括的属性名称:

type Omit<T, K extends string | number | symbol> = {
  [P in Exclude<keyof T, K>]: T[P];
}

// Omits age, thus includes "name" and "profession"
type PersonWithoutAge = Omit<Person, "age">;

要基于它们的类型而不是名称选择正确的属性,我们需要创建一个类似的辅助类型,其中我们映射一个动态生成的属性名集,该集仅指向我们正在寻找的类型。我们从第 5.2 节知道,当在联合类型上使用条件类型时,我们可以使用never来过滤这个联合中的元素。

因此,第一种可能性是我们映射Person的所有属性键,并检查Person[K]是否是我们期望类型的子集。如果是,则返回该类型;否则返回never

// Not there yet
type PersonStrings = {
  [K in keyof Person]: Person[K] extends string ? Person[K] : never;
};

这很好,但要注意:我们检查的类型不是联合类型,而是映射类型的类型。因此,与过滤属性键不同,我们将获得指向类型never的属性,这意味着我们将禁止设置某些属性。

另一个想法是将类型设置为undefined,将属性视为可选的,但正如我们在第 3.11 节中学到的那样,缺失的属性和undefined值并不相同。

我们实际想要做的是删除指向特定类型的属性键。这可以通过将条件放在对象的左侧而不是右侧来实现,属性是在左侧创建的。

就像Omit类型一样,我们需要确保映射特定的属性集。当映射Personkeyof时,可以通过类型断言改变属性键的类型。与常规类型断言一样,有一种故障安全机制,这意味着您不能断言它为任何东西:它必须在属性键的边界内。

我们想要断言K是集合的一部分,如果Person[K]string类型。如果是这样,我们保留K;否则,我们用never过滤集合中的元素。由于never位于对象的左侧,属性将被删除:

type PersonStrings = {
  [K in keyof Person as Person[K] extends string ? K : never]: Person[K];
};

现在,我们只选择指向字符串值的属性键。有一个要注意的地方:可选的字符串属性比普通字符串具有更广泛的类型,因为undefined也可能是其可能的值之一。使用联合类型可以确保可选属性也被保留:

type PersonStrings = {
  [K in keyof Person as Person[K] extends string | undefined
    ? K
    : never]: Person[K];
};

下一步是使这种类型泛化。我们通过用O替换Person,用T替换string来创建类型Select<O, T>

type Select<O, T> = {
  [K in keyof O as O[K] extends T | undefined ? K : never]: O[K];
};

这种新的辅助类型非常灵活。我们可以用它来从我们自己的对象类型中选择特定类型的属性:

type PersonStrings = Select<Person, string>;
type PersonNumbers = Select<Person, number>;

但是我们也可以找出,例如,字符串原型中返回数字的函数:

type StringFnsReturningNumber = Select<String, (...args: any[]) => number>;

一个反向的辅助类型Remove<O, T>,我们希望删除特定类型的属性键,与Select<O, T>非常相似。唯一的区别在于切换条件并在true分支中返回never

type Remove<O, T> = {
  [K in keyof O as O[K] extends T | undefined ? never : K]: O[K];
};

type PersonWithoutStrings = Remove<Person, string>;

如果您创建对象类型的可序列化版本,这尤其有帮助:

type User = {
  name: string;
  age: number;
  profession?: string;
  posts(): string[];
  greeting(): string;
};

type SerializeableUser = Remove<User, Function>;

通过了解在映射键时可以使用条件类型,您突然就可以访问各种潜在的辅助类型。关于这一点的更多信息,请参阅 第八章。

5.5 条件中的类型推断

问题

您希望创建一个对象序列化的类,它会删除对象的所有不可序列化属性,如函数。如果您的对象具有 serialize 函数,则序列化器将使用该函数的返回值,而不是自行序列化对象。如何定义这种类型?

解决方案

使用递归条件类型来修改现有的对象类型。对于实现了 serialize 的对象,使用 infer 关键字将通用返回类型固定到一个具体类型。

讨论

序列化是将数据结构和对象转换为可以存储或传输的格式的过程。想象一下,将 JavaScript 对象的数据存储在磁盘上,然后通过再次反序列化它将其取回到 JavaScript 中。

JavaScript 对象可以包含任何类型的数据:像字符串或数字这样的原始类型,以及对象和甚至函数这样的复合类型。函数很有趣,因为它们不包含数据,而是行为:这是一些无法很好地序列化的内容。序列化 JavaScript 对象的一种方法是完全摒弃函数。这正是我们想在本课程中实现的内容。

我们从一个简单的对象类型 Person 开始,其中包含我们想要存储的数据的常规主题:人的姓名和年龄。它还有一个 hello 方法,生成一个字符串:

type Person = {
  name: string;
  age: number;
  hello: () => string;
};

我们希望序列化这种类型的对象。一个 Serializer 类包含一个空的构造函数和一个通用函数 serialize。注意,我们将通用类型参数添加到 serialize 而不是类本身。这样,我们可以为不同的对象类型重复使用 serialize。返回类型指向一个通用类型 Serialize<T>,这将是序列化过程的结果:

class Serializer {
  constructor() {}
  serialize<T>(obj: T): Serialize<T> {
    // tbd...
  }
}

我们稍后会处理具体的实现。现在让我们专注于 Serialize<T> 类型。首先想到的一个想法是仅丢弃函数属性。我们已经在 5.4 节 中定义了一个 Remove<O, T> 类型,它非常方便,因为它正是做这件事——删除特定类型的属性:

type Remove<O, T> = {
  [K in keyof O as O[K] extends T | undefined ? never : K]: O[K];
};

type Serialize<T> = Remove<T, Function>;

第一次迭代已经完成,并且适用于简单的一级深度对象。然而,对象可以很复杂。例如,Person 可以嵌套其他对象,这些对象本身也可能具有函数:

type Person = {
  name: string;
  age: number;
  profession: {
    title: string;
    level: number;
    printProfession: () => void;
  };
  hello: () => string;
};

要解决这个问题,我们需要检查每个属性是否是另一个对象,如果是,则再次使用 Serialize<T> 类型。名为 NestSerialization 的映射类型在条件类型中检查每个属性是否为 object 类型,在 true 分支中返回该类型的序列化版本,在 false 分支中返回该类型本身:

type NestSerialization<T> = {
  [K in keyof T]: T[K] extends object ? Serialize<T[K]> : T[K];
};

我们通过在NestSerialization中包装原始的Remove<T, Function>类型来重新定义Serialize<T>,从而有效地创建了一个递归类型Serialize<T>使用NestSerialization<T>使用Serialize<T>,依此类推:

type Serialize<T> = NestSerialization<Remove<T, Function>>;

TypeScript 可以在一定程度上处理类型递归。在这种情况下,它可以看到在NestSerialization中确实存在一种条件来打破类型递归。

这就是序列化类型!现在来实现这个函数,这个函数奇怪地直接翻译了我们在 JavaScript 中的类型声明。我们检查每个属性,如果是对象,我们再次调用serialize。如果不是,则只转移该属性,但前提是它不是一个函数:

class Serializer {
  constructor() {}
  serialize<T>(obj: T): Serialize<T> {
    const ret: Record<string, any> = {};

    for (let k in obj) {
      if (typeof obj[k] === "object") {
        ret[k] = this.serialize(obj[k]);
      } else if (typeof obj[k] !== "function") {
        ret[k] = obj[k];
      }
    }
    return ret as Serialize<T>;
  }
}

注意,由于我们在serialize中生成了一个新对象,我们从一个非常广泛的Record<string, any>开始,这允许我们将任何字符串属性键设置为基本任何内容,并在最后断言我们创建了一个符合返回类型的对象。当您创建新对象时,这种模式很常见,但最终需要您确保百分之百正确。请广泛测试此函数。

第一次实现完成后,我们可以创建一个新的Person类型对象,并将其传递给我们新生成的序列化程序:

const person: Person = {
  name: "Stefan",
  age: 40,
  profession: {
    title: "Software Developer",
    level: 5,
    printProfession() {
      console.log(`${this.title}, Level ${this.level}`);
    },
  },
  hello() {
    return `Hello ${this.name}`;
  },
};

const serializer = new Serializer();
const serializedPerson = serializer.serialize(person);
console.log(serializedPerson);

结果如预期:serializedPerson的类型缺少所有方法和函数的信息。如果我们记录serializedPerson,我们还会看到所有方法和函数都已经消失了。类型与实现结果匹配:

[LOG]: {
  "name": "Stefan",
  "age": 40,
  "profession": {
    "title": "Software Developer",
    "level": 5
  }
}

但我们还没有完成。序列化程序有一个特殊功能。对象可以实现serialize方法,如果是这样,序列化程序将获取此方法的输出,而不是自行序列化对象。让我们扩展Person类型,以包含一个serialize方法:

type Person = {
  name: string;
  age: number;
  profession: {
    title: string;
    level: number;
    printProfession: () => void;
  };
  hello: () => string;
  serialize: () => string;
};

const person: Person = {
  name: "Stefan",
  age: 40,
  profession: {
    title: "Software Developer",
    level: 5,
    printProfession() {
      console.log(`${this.title}, Level ${this.level}`);
    },
  },
  hello() {
    return `Hello ${this.name}`;
  },
  serialize() {
    return `${this.name}: ${this.profession.title} L${this.profession.level}`;
  },
};

我们需要调整Serialize<T>类型。在运行NestSerialization之前,我们在条件类型中检查对象是否实现了serialize方法。我们通过询问T是否是包含serialize方法的类型的子类型来做到这一点。如果是这样,我们需要获取返回类型,因为那就是序列化的结果。

这就是infer关键字发挥作用的地方。它允许我们从条件中获取一个类型,并在true分支中将其用作类型参数。我们告诉 TypeScript,如果这个条件为真,则取出你在那里找到的类型并使其对我们可用:

type Serialize<T> = T extends { serialize(): infer R }
  ? R
  : NestSerialization<Remove<T, Function>>;

R想象成一开始是any。如果我们将Person{ serialize(): any }进行比较,我们就进入了true分支,因为Person有一个serialize函数,使其成为有效的子类型。但是any是广泛的,我们感兴趣的是在any位置的具体类型。infer关键字可以选择确切的类型。因此,Serialize<T>现在读取:

  • 如果T包含一个serialize方法,则获取其返回类型并返回它。

  • 否则,通过深度移除所有类型为Function的属性来开始序列化。

我们也希望在我们的 JavaScript 实现中反映该类型的行为。我们进行了一些类型检查(检查serialize是否可用以及它是否是一个函数),最终调用它。TypeScript 要求我们使用类型守卫明确表示,以确保这个函数确实存在:

class Serializer {
  constructor() {}
  serialize<T>(obj: T): Serialize<T> {
    if (
      // is an object
      typeof obj === "object" &&
      // not null
      obj &&
      // serialize is available
      "serialize" in obj &&
      // and a function
      typeof obj.serialize === "function"
    ) {
      return obj.serialize();
    }

    const ret: Record<string, any> = {};

    for (let k in obj) {
      if (typeof obj[k] === "object") {
        ret[k] = this.serialize(obj[k]);
      } else if (typeof obj[k] !== "function") {
        ret[k] = obj[k];
      }
    }
    return ret as Serialize<T>;
  }
}

有了这个改变,serializedPerson 的类型是string,而结果也如预期的那样:

[LOG]: "Stefan: Software Developer L5"

这个强大的工具在对象生成方面提供了很大帮助。并且,我们用 TypeScript 的类型系统作为声明性元语言来创建类型,最终以 JavaScript 的命令式写法看到相同的过程,这其中蕴含着美感。

第六章:字符串模板字面类型

在 TypeScript 的类型系统中,每个值也是一种类型。我们称之为字面类型,在与其他字面类型的联合中,您可以定义一个非常清晰的类型,指明它可以接受哪些值。让我们以string的子集为例。您可以确切地定义应包含在集合中的字符串,并排除大量错误。另一端的极端是字符串的整个集合。

但如果之间有什么呢?如果我们可以定义检查特定字符串模式是否可用的类型,并让其余部分更加灵活呢?字符串模板字面类型正是如此。它们允许我们定义类型,其中字符串的某些部分是预定义的;其余部分则是开放的,可以用于各种用途。

但更重要的是,与条件类型结合使用时,可以将字符串分割为各个部分并将相同的部分重复使用,这是一种非常强大的工具,特别是当您考虑 JavaScript 中多少代码依赖于字符串内的模式时。

在这一章中,我们将看到各种字符串模板字面类型的用例。从简单的字符串模式到根据格式字符串提取参数和类型,您将看到解析字符串作为类型的强大功能。

但我们保持现实。这里的一切都来自真实世界的例子。使用字符串模板字面类型可以实现的功能似乎是无穷无尽的。人们通过编写拼写检查器或实现SQL 解析器将字符串模板字面类型的用法推向极限;看起来这个令人惊叹的功能可以做的事情没有极限。

6.1 定义自定义事件系统

问题

您正在创建一个自定义事件系统,并希望确保每个事件名称都遵循约定并以"on"开头。

解决方案

使用字符串模板字面类型描述字符串模式。

讨论

JavaScript 事件系统通常具有指示特定字符串是事件的前缀。通常,事件或事件处理程序字符串以on开头,但具体实现可能有所不同。

您希望创建自己的事件系统,并希望遵循这一约定。通过 TypeScript 的字符串类型,可以接受所有可能的字符串或子集作为字符串字面量类型的联合类型。尽管一个太广泛,另一个对我们的需求不够灵活。我们不想预先定义每个可能的事件名称;我们想遵循一种模式。

幸运的是,一种称为字符串模板字面类型或简称模板字面类型的类型正是我们所需要的。模板字面类型允许我们定义字符串字面量,但保留某些部分的灵活性。

例如,一个接受以on开头的所有字符串的类型可能如下所示:

type EventName = `on${string}`;

在语法上,模板文字类型借鉴了 JavaScript 的模板字符串。它们以反引号开始和结束,后跟任意字符串。

使用${}的特定语法允许向字符串添加 JavaScript 表达式,例如变量、函数调用等:

function greet(name: string) {
  return `Hi, ${name}!`;
}

greet("Stefan"); // "Hi, Stefan!"

TypeScript 中的模板文字类型非常相似。与 JavaScript 表达式不同,它们允许我们添加一组类型形式的值。定义 HTML 中所有可用标题元素的字符串表示的类型可以如下所示:

type Levels = 1 | 2 | 3 | 4 | 5 | 6;

// resolves to "H1" | "H2" | "H3" | "H4" | "H5" | "H6"
type Headings = `H${Levels}`;

Levelsnumber的一个子集,而Headings读作“以 H 开头,后跟与Levels兼容的值”。你不能在这里放入每一种类型,只能是那些有字符串表示的类型。

回到EventName

type EventName = `on${string}`;

像这样定义,EventName读起来像“以"on"开头,后跟任意字符串”。这包括空字符串。让我们使用EventName来创建一个简单的事件系统。在第一步中,我们只想收集回调函数。

为此,我们定义了一个Callback类型,它是一个带有一个参数的函数类型:EventObjectEventObject是一个通用类型,包含事件信息的值:

type EventObject<T> = {
  val: T;
};

type Callback<T = any> = (ev: EventObject<T>) => void;

此外,我们需要一个类型来存储所有注册的事件回调,Events

type Events = {
  [x: EventName]: Callback[] | undefined;
};

我们将EventName用作索引访问,因为它是string的有效子类型。每个索引指向一个回调函数数组。有了我们定义的类型,我们设置了一个EventSystem类:

class EventSystem {
  events: Events;
  constructor() {
    this.events = {};
  }

  defineEventHandler(ev: EventName, cb: Callback): void {
    this.events[ev] = this.events[ev] ?? [];
    this.events[ev]?.push(cb);
  }

  trigger(ev: EventName, value: any) {
    let callbacks = this.events[ev];
    if (callbacks) {
      callbacks.forEach((cb) => {
        cb({ val: value });
      });
    }
  }
}

构造函数创建了一个新的事件存储,defineEventHandler接受一个Ev⁠en⁠t​Na⁠meCallback并将它们存储在所述的事件存储中。此外,trigger接受一个Ev⁠ent​Na⁠me,如果注册了回调,则执行每个注册的回调函数,并传递一个Ev⁠ent​Obj⁠ect

第一步完成了。我们现在在定义事件时拥有了类型安全性:

const system = new EventSystem();
system.defineEventHandler("click", () => {});
// ^ Argument of type '"click"' is not assignable to parameter
//.  of type '`on${string}`'.(2345)
system.defineEventHandler("onClick", () => {});
system.defineEventHandler("onchange", () => {});

在配方 6.2 中,我们将看看如何使用字符串操作类型和键重映射来增强我们的系统。

6.2 使用字符串操作类型和键重映射创建事件回调

问题

您希望提供一个watch函数,该函数接受任何对象并为每个属性添加观察者函数,允许您定义事件回调。

解决方案

使用键重映射创建新的字符串属性键。使用字符串操作类型为观察函数设置适当的驼峰命名。

讨论

我们的事件系统在配方 6.1 中逐渐成形。我们能够注册事件处理程序并触发事件。现在我们想要添加观察功能。这个想法是扩展有效对象,使其具有注册回调的方法,每当属性更改时就会执行这些回调。例如,当我们定义一个person对象时,我们应该能够监听onAgeChangedonNameChanged事件:

let person = {
  name: "Stefan",
  age: 40,
};

const watchedPerson = system.watch(person);

watchedPerson.onAgeChanged((ev) => {
  console.log(ev.val, "changed!!");
});

watchedPerson.age = 41; // triggers callbacks

因此,对于每个属性,将有一个方法以on开头,以Changed结尾,并接受带有事件对象参数的回调函数。

为了定义新的事件处理程序方法,我们创建了一个名为 Wa⁠tch⁠ed​Ob⁠jec⁠t<T> 的辅助类型,在其中添加定制方法:

type WatchedObject<T> = {
  [K in string & keyof T as `on${K}Changed`]: (
    ev: Callback<T[K]>
  ) => void;
};

有很多东西需要理清。让我们逐步进行:

  1. 我们通过迭代 T 的所有键来定义一个映射类型。由于我们只关心 string 属性键,我们使用交集 string & keyof T 来摆脱潜在的符号或数字。

  2. 接下来,我们将这个键重映射到一个新的字符串,由字符串模板字面量类型定义。它以 on 开头,然后取自我们映射过程的键 K,并附加 Changed

  3. 属性 key 指向一个接受回调函数的函数。回调函数本身将事件对象作为参数,并通过正确地替换其泛型,确保此事件对象包含我们观察对象的原始类型。这意味着当我们调用 onAgeChanged 时,事件对象实际上将包含一个 number

这已经很棒了,但缺少重要的细节。当我们像这样在 person 上使用 WatchedObject 时,所有生成的事件处理方法在 on 后都缺少大写字母。为了解决这个问题,我们可以使用内置的字符串操作类型之一来将字符串类型大写:

type WatchedObject<T> = {
  [K in string & keyof T as `on${Capitalize<K>}Changed`]: (
    ev: Callback<T[K]>
  ) => void;
};

接下来是 CapitalizeLowercaseUppercaseUncapitalize。如果我们悬停在 WatchedObject<typeof person> 上,我们可以看到生成的类型是什么样子的:

type WatchedPerson = {
  onNameChanged: (ev: Callback<string>) => void;
  onAgeChanged: (ev: Callback<number>) => void;
};

随着我们的类型设置,我们开始实施。首先,我们创建两个辅助函数:

function capitalize(inp: string) {
  return inp.charAt(0).toUpperCase() + inp.slice(1);
}

function handlerName(name: string): EventName {
  return `on${capitalize(name)}Changed` as EventName;
}

我们需要这两个辅助函数来模仿 TypeScript 重映射和操作字符串的行为。capitalize 将字符串的第一个字母更改为大写,并且 handlerName 在其前后添加前缀。使用 handlerName 我们需要进行一点类型断言,以向 TypeScript 指示类型已更改。通过 JavaScript 中可以转换字符串的多种方法,TypeScript 无法确定这将导致大写版本。

接下来,我们在事件系统中实现 watch 功能。我们创建一个通用函数,接受任何对象并返回一个包含原始属性和观察者属性的对象。

为了成功实现在属性更改时触发事件处理程序,我们使用 Proxy 对象来拦截 getset 调用:

class EventSystem {
  // cut for brevity
  watch<T extends object>(obj: T): T & WatchedObject<T> {
    const self = this;
    return new Proxy(obj, {
      get(target, property) {
        // (1)
        if (
          typeof property === "string" &&
          property.startsWith("on") &&
          property.endsWith("Changed")
        ) {
          // (2)
          return (cb: Callback) => {
            self.defineEventHandler(property as EventName, cb);
          };
        }
        // (3)
        return target[property as keyof T];
      },
      // set to be done ...
    }) as T & WatchedObject<T>;
  }
}

我们想要拦截的 get 调用是每当我们访问 WatchedObject<T> 的属性时:

  • 它们以 on 开头并以 Changed 结尾。

  • 如果是这种情况,我们返回一个接受回调函数的函数。该函数本身通过 defineEventHandler 将回调函数添加到事件存储中。

  • 在所有其他情况下,我们进行常规的属性访问。

现在,每当我们设置原始对象的值时,我们希望触发存储的事件。这就是为什么我们修改所有 set 调用的原因:

class EventSystem {
  // ... cut for brevity
  watch<T extends object>(obj: T): T & WatchedObject<T> {
    const self = this;
    return new Proxy(obj, {
      // get from above ...
      set(target, property, value) {
        if (property in target && typeof property === "string") {
          // (1)
          target[property as keyof T] = value;
          // (2)
          self.trigger(handlerName(property), value);
          return true;
        }
        return false;
      },
    }) as T & WatchedObject<T>;
  }
}

这个过程如下:

  1. 设置值。无论如何,我们都需要更新对象。

  2. 调用 trigger 函数执行所有已注册的回调。

请注意,我们需要进行一些类型断言来引导 TypeScript 朝正确的方向发展。毕竟,我们正在创建新对象。

就是这样!从头开始尝试示例,看看您的事件系统如何运行:

let person = {
  name: "Stefan",
  age: 40,
};

const watchedPerson = system.watch(person);

watchedPerson.onAgeChanged((ev) => {
  console.log(ev.val, "changed!!");
});

watchedPerson.age = 41; // logs "41 changed!!"

字符串模板字面类型与字符串操作类型和键重映射允许我们动态创建新对象的类型。这些强大的工具使得使用高级 JavaScript 对象创建更加健壮。

6.3 编写格式化函数

问题

您想为一个接受格式字符串并用实际值替换占位符的函数创建类型。

解决方案

创建一种条件类型,从字符串模板字面类型中推断占位符名称。

讨论

您的应用程序通过定义带有花括号占位符的格式字符串来定义格式字符串的方法。第二个参数接受一个带有替换值的对象,因此对于格式字符串中定义的每个占位符,都有一个相应值的属性键:

format("Hello {world}. My name is {you}.", {
  world: "World",
  you: "Stefan",
});

让我们为此函数创建类型定义,确保您的用户不会忘记添加所需的属性。作为第一步,我们使用一些非常广泛的类型定义函数接口。格式字符串的类型为 string,格式化参数在具有字面值的 string 键的 Record 中。我们首先关注类型;函数体的实现稍后处理:

function format(fmtString: string, params: Record<string, any>): string {
  throw "unimplemented";
}

作为下一步,我们希望通过添加泛型将函数参数锁定为具体值或字面类型。我们将 fmtString 的类型更改为泛型类型 T,它是 string 的子类型。这使我们仍然可以将字符串传递给函数,但是一旦我们传递字面字符串,我们就可以分析字面类型并查找模式(有关详细信息,请参见 Recipe 4.3):

function format<T extends string>(
  fmtString: T,
  params: Record<string, any>
): string {
  throw "unimplemented";
}

现在我们已经确定了 T,我们可以将其作为泛型类型 FormatKeys 的类型参数传递。这是一种条件类型,用于扫描我们的格式字符串以查找花括号:

type FormatKeys<
  T extends string
> = T extends `${string}{${string}}${string}`
  ? T
  : never;

在这里,我们检查格式字符串是否:

  • 以字符串开头;这也可以是空字符串

  • 包含 {,跟随任何字符串,跟随 }

  • 再次由任何字符串跟随。

这实际上意味着我们检查格式字符串中是否恰好有一个占位符。如果是这样,我们返回整个格式字符串,如果不是,我们返回 never

type A = FormatKeys<"Hello {world}">; // "Hello {world}"
type B = FormatKeys<"Hello">; // never

FormatKeys 可以告诉我们传入的字符串是否是格式字符串,但我们实际上对格式字符串的一个特定部分更感兴趣:即花括号之间的部分。使用 TypeScript 的 infer 关键字,我们可以告诉 TypeScript,如果格式字符串匹配此模式,那么获取花括号之间的任何文本类型并放入类型变量中:

type FormatKeys<
  T extends string
> = T extends `${string}{${infer Key}}${string}`
  ? Key
  : never;

这样,我们可以提取子字符串并根据需要重用它们:

type A = FormatKeys<"Hello {world}">; // "world"
type B = FormatKeys<"Hello">; // never

真棒!我们提取了第一个占位符名称。现在处理其余部分。由于可能会有后续的占位符,我们取第一个占位符之后的所有内容,并将其存储在名为Rest的类型变量中。这个条件总是成立,因为Rest要么是空字符串,要么包含我们可以再次分析的实际字符串。

我们取出Rest,在true分支中调用FormatKeys<Rest>,在Key的联合类型中:

type FormatKeys<
  T extends string
> = T extends `${string}{${infer Key}}${infer Rest}`
  ? Key | FormatKeys<Rest>
  : never;

这是一个递归条件类型。结果将是占位符的联合,我们可以用作格式化对象的键:

type A = FormatKeys<"Hello {world}">; // "world"
type B = FormatKeys<"Hello {world}. I'm {you}.">; // "world" | "you"
type C = FormatKeys<"Hello">; // never

现在是时候连接FormatKeys了。由于我们已经锁定了T,我们可以将其作为参数传递给FormatKeys,然后用作Record的参数:

function format<T extends string>(
  fmtString: T,
  params: Record<FormatKeys<T>, any>
): string {
  throw "unimplemented";
}

有了这个,我们的类型都准备好了。来实现吧!实现方式与我们定义的类型完美呼应。我们遍历params的所有键,并用括号内的相应值替换所有出现:

function format<T extends string>(
  fmtString: T,
  params: Record<FormatKeys<T>, any>
): string {
  let ret: string = fmtString;
  for (let k in params) {
    ret = ret.replaceAll(`{${k}}`, params[k as keyof typeof params]);
  }
  return ret;
}

注意两个特定的类型:

  • 我们需要使用stringret进行注释。fmtStringT的子类型,因此ret也将是T。这意味着我们无法更改值,因为T的类型将发生变化。通过将其注释为更广泛的string类型,帮助我们修改ret

  • 我们还需要断言对象键k实际上是params的一个键。这是一个不幸的解决方案,由于 TypeScript 的一些故障安全机制所致。有关此主题的更多信息,请参阅食谱 9.1。

通过食谱 9.1 中的信息,我们可以重新定义format,消除一些类型断言,以达到format函数的最终版本:

function format<T extends string, K extends Record<FormatKeys<T>, any>>(
  fmtString: T,
  params: K
): string {
  let ret: string = fmtString;
  for (let k in params) {
    ret = ret.replaceAll(`{${k}}`, params[k]);
  }
  return ret;
}

能够分割字符串并提取属性键非常强大。全球范围内的 TypeScript 开发人员都使用这种模式来加强类型,例如用于像Express这样的 Web 服务器。我们将看到更多如何使用这个工具来获得更好类型的示例。

6.4 提取格式参数类型

Problem

您希望扩展来自食谱 6.3 的格式化函数,使其能够为占位符定义类型。

Solution

创建一个嵌套的条件类型,并使用类型映射查找类型。

Discussion

让我们扩展上一课的例子。我们现在不仅想知道所有占位符,还想能够为这些占位符定义一定的类型集合。类型应该是可选的,并在占位符名称后面用冒号表示,类型应为 JavaScript 的基本类型之一。当我们传入类型不正确的值时,我们期望得到类型错误:

format("Hello {world:string}. I'm {you}, {age:number} years old.", {
  world: "World",
  age: 40,
  you: "Stefan",
});

供参考,让我们看一下来自食谱 6.3 的原始实现:

type FormatKeys<
  T extends string
> = T extends `${string}{${infer Key}}${infer Rest}`
  ? Key | FormatKeys<Rest>
  : never;

function format<T extends string>(
  fmtString: T,
  params: Record<FormatKeys<T>, any>
): string {
  let ret: string = fmtString;
  for (let k in params) {
    ret = ret.replace(`{${k}}`, params[k as keyof typeof params]);
  }
  return ret;
}

为了实现这一点,我们需要做两件事:

  1. params的类型从Record<FormatKeys<T>, any>更改为一个实际对象类型,该对象类型具有与每个属性键相关联的适当类型。

  2. 调整FormatKeys中的字符串模板字面类型,以便提取原始 JavaScript 类型。

对于第一步,我们引入了一个名为FormatObj<T>的新类型。它与FormatKeys的工作方式相同,但不是简单返回字符串键,而是将相同的键映射到新对象类型。这要求我们使用交集类型链式递归而不是联合类型(我们在每次递归中添加更多属性),并将打破条件从never更改为{}。如果我们使用never进行交集,整个返回类型变为never。这样,我们不会向返回类型添加任何新属性:

type FormatObj<
  T extends string
> = T extends `${string}{${infer Key}}${infer Rest}`
  ? { [K in Key]: any } & FormatObj<Rest>
  : {};

FormatObj<T>的工作方式与Record<FormatKeys<T>, any>相同。我们仍然没有提取任何占位符类型,但现在我们可以轻松设置每个占位符的类型,因为我们控制整个对象类型。

作为下一步,我们将在FormatObj<T>中更改解析条件,以便还要查找冒号分隔符。如果找到:字符,则推断Type中的后续字符串文字类型,并将其用作映射键的类型:

type FormatObj<
  T extends string
> = T extends `${string}{${infer Key}:${infer Type}}${infer Rest}`
  ? { [K in Key]: Type } & FormatObj<Rest>
  : {};

我们非常接近;只有一个警告。我们推断了字符串文字类型。这意味着,例如,解析{age:number}age的类型将是字面字符串"number"。我们需要将此字符串转换为实际类型。我们可以使用另一个条件类型或使用映射类型作为查找:

type MapFormatType = {
  string: string;
  number: number;
  boolean: boolean;
  [x: string]: any;
};

这样,我们可以简单地检查每个键关联的类型,并为所有其他字符串提供一个出色的备选方案:

type A = MapFormatType["string"]; // string
type B = MapFormatType["number"]; // number
type C = MapFormatType["notavailable"]; // any

让我们将MapFormatType连接到FormatObj<T>

type FormatObj<
  T extends string
> = T extends `${string}{${infer Key}:${infer Type}}${infer Rest}`
  ? { [K in Key]: MapFormatType[Type] } & FormatObj<Rest>
  : {};

我们快要成功了!现在的问题是我们期望每个占位符也定义类型。我们想要使类型是可选的。但是我们的解析条件明确要求:分隔符,因此每个没有定义类型的占位符也不会产生属性。

解决方案是在检查占位符之后再次检查类型:

type FormatObj<
  T extends string
> = T extends `${string}{${infer Key}}${infer Rest}`
  ? Key extends `${infer KeyPart}:${infer TypePart}`
    ? { [K in KeyPart]: MapFormatType[TypePart] } & FormatObj<Rest>
    : { [K in Key]: any } & FormatObj<Rest>
  : {};

类型读取如下:

  1. 检查是否有可用的占位符。

  2. 如果有可用的占位符,请检查是否有类型注释。如果有,则将键映射到格式类型;否则,将原始键映射到any

  3. 在所有其他情况下,返回空对象。

就是这样。我们可以添加一个故障安全保护。与其允许占位符没有类型定义,我们至少可以期望类型实现toString()。这确保我们始终获得字符串表示:

type FormatObj<
  T extends string
> = T extends `${string}{${infer Key}}${infer Rest}`
  ? Key extends `${infer KeyPart}:${infer TypePart}`
    ? { [K in KeyPart]: MapFormatType[TypePart] } & FormatObj<Rest>
    : { [K in Key]: { toString(): string } } & FormatObj<Rest>
  : {};

有了这个,让我们将新类型应用于format并更改实现:

function format<T extends string, K extends FormatObj<T>>(
  fmtString: T,
  params: K
): string {
  let ret: string = fmtString;
  for (let k in params) {
    let val = `${params[k]}`;
    let searchPattern = new RegExp(`{${k}:?.*?}`, "g");
    ret = ret.replaceAll(searchPattern, val);
  }
  return ret;
}

我们利用正则表达式替换可能带有类型注释的名称。在函数内部无需检查类型。在这种情况下,TypeScript 应该足够帮助我们。

我们看到的是,条件类型与字符串模板字面类型以及递归和类型查找等工具的结合,使我们能够用几行代码指定复杂的关系。我们的类型变得更好,我们的代码变得更加健壮,对开发者来说使用这样的 API 是一种享受。

6.5 处理递归限制

问题

您可以通过精心设计的字符串模板字面类型将任何字符串转换为有效的属性键。使用您设置的辅助类型,您可能会遇到递归限制。

解决方案

使用累积技术启用尾调用优化。

讨论

TypeScript 的字符串模板字面类型与条件类型结合使用,允许您动态创建新的字符串类型,这些类型可以作为属性键或检查程序的有效字符串。

它们使用递归工作,这意味着就像函数一样,您可以多次调用同一类型,直到达到某个限制。

例如,这种类型 Trim<T> 可以去除字符串类型开头和结尾的空格:

type Trim<T extends string> =
  T extends ` ${infer X}` ? Trim<X> :
  T extends `${infer X} ` ? Trim<X> :
  T;

它检查是否有起始空白,推断其余部分,并再次进行相同的检查。一旦所有起始空格都消失,相同的检查将发生在末尾的空格上。一旦起始和末尾的所有空格都消失,它就完成了,并跳到最后一个分支——返回剩余的字符串:

type Trimmed = Trim<"     key   ">; // "key"

调用类型多次就是递归,并且像这样写是合理的。TypeScript 可以从类型中看出递归调用是独立的,并且可以将其作为尾调用优化来评估,这意味着它可以在同一个调用堆栈帧内评估递归的下一步。

注意

如果您想了解更多有关 JavaScript 中调用堆栈的信息,Thomas Hunter 的书籍 使用 Node.js 进行分布式系统(O’Reilly 出版)提供了很好的介绍。

我们想要利用 TypeScript 的特性,通过递归调用条件类型,从任何字符串中创建一个有效的字符串标识符,方法是去除空白和无效字符。

首先,我们写一个类似于 Trim<T> 的辅助类型,它去掉找到的所有空格:

type RemoveWhiteSpace<T extends string> = T extends `${infer A} ${infer B}`
  ? RemoveWhiteSpace<`${Uncapitalize<A>}${Capitalize<B>}`>
  : T;

它检查是否有空白,推断空格前后的字符串(可以是空字符串),然后使用新形成的字符串类型再次调用相同的类型。它还将第一个推断小写化,并将第二个推断大写化,以创建类似驼峰命名的字符串标识符。

它一直这样做,直到所有空格都消失:

type Identifier = RemoveWhiteSpace<"Hello World!">; // "helloWorld!"

接下来,我们想要检查剩余字符是否有效。我们再次使用递归,将有效字符的字符串拆分为只包含一个字符的单字符串类型,并创建大写和小写版本:

type StringSplit<T extends string> = T extends `${infer Char}${infer Rest}`
  ? Capitalize<Char> | Uncapitalize<Char> | StringSplit<Rest>
  : never;

type Chars = StringSplit<"abcdefghijklmnopqrstuvwxyz">;
//  "a" | "A" | "b" | "B" | "c" | "C" | "d" | "D" | "e" | "E" |
//  "f" | "F" | "g" | "G" | "h" | "H" | "i" | "I" | "j" | "J" |
//  "k" | "K" | "l" | "L" | "m" | "M" | "n" | "N" | "o" | "O" |
//  "p" | "P" | "q" | "Q" | "r" | "R" | "s" | "S" | "t" | "T" |
//  "u" | "U" | "v" | "V" | "w" | "W" | "x" | "X" | "y" | "Y" |
//  "z" | "Z"

我们刮掉我们找到的第一个字符,将其大写,将其小写,然后对其余部分执行相同操作,直到没有更多的字符串为止。请注意,由于我们将递归调用放在联合类型中的结果中,这种递归无法进行尾调用优化。当我们达到 50 个字符时(TypeScript 编译器的硬限制),我们将达到递归限制。对于基本字符,我们没有问题!

但是当我们进行下一步,创建Identifier时,我们首先要检查有效字符。首先,我们调用RemoveWhiteSpace<T>类型,这允许我们摆脱空格并将其余部分改为驼峰式。然后我们检查结果是否符合有效字符。

就像在StringSplit<T>中一样,我们删掉了第一个字符,但在推断中进行了另一种类型检查。我们看看刚刚刮掉的字符是否是有效字符之一。然后我们获取剩下的部分。我们再次组合相同的字符串,但在剩余字符串上进行递归检查。如果第一个字符无效,则我们调用Cr⁠ea⁠te​Id⁠en⁠ti⁠fie⁠r<T>处理剩余部分:

type CreateIdentifier<T extends string> =
  RemoveWhiteSpace<T> extends `${infer A extends Chars}${infer Rest}`
  ? `${A}${CreateIdentifier<Rest>}`
//  ^ Type instantiation is excessively deep and possibly infinite.(2589)_.
  : RemoveWhiteSpace<T> extends `${infer A}${infer Rest}`
  ? CreateIdentifier<Rest>
  : T;

这里我们首次触及递归限制。TypeScript 通过错误警告我们,这种类型实例化可能是无限的且过于深层。似乎如果我们在字符串模板文字类型内使用递归调用,可能会导致调用栈错误并引发故障。因此,TypeScript 中断了。它无法在这里进行尾调用优化。

注意

CreateIdentifier<T>可能仍然会生成正确的结果,即使在编写类型时 TypeScript 出错。这些是难以察觉的错误,因为它们可能在您不期望时出现。确保在错误发生时不要让 TypeScript 生成任何结果。

有一种解决方法。为了激活尾调用优化,递归调用需要独立存在。我们可以通过使用所谓的累加器技术来实现这一点。在这里,我们传递第二个类型参数称为Acc,它是string类型,并用空字符串进行实例化。我们将其用作累加器,存储中间结果,并一遍又一遍地将其传递给下一个调用:

type CreateIdentifier<T extends string, Acc extends string = ""> =
  RemoveWhiteSpace<T> extends `${infer A extends Chars}${infer Rest}`
  ? CreateIdentifier<Rest, `${Acc}${A}`>
  : RemoveWhiteSpace<T> extends `${infer A}${infer Rest}`
  ? CreateIdentifier<Rest, Acc>
  : Acc;

这样,递归调用再次独立存在,并且结果是第二个参数。当我们完成递归调用时,递归断开的分支,我们返回累加器,因为它是我们的最终结果:

type Identifier = CreateIdentifier<"Hello Wor!ld!">; // "helloWorld"

可能有更聪明的方法从任意字符串生成标识符,但请注意,在使用递归的复杂条件类型中,可能会遇到相同的问题。累加器技术是减轻此类问题的好方法。

6.6 使用字符串模板文字作为判别因子

问题

你的模型将对后端的请求作为状态机处理,从pending状态转换为errorsuccess状态。这些状态应适用于不同的后端请求,但底层类型应该是相同的。

解决方案

使用字符串模板文字作为判别联合类型的判别因子。

讨论

从后端获取数据的方式始终遵循相同的结构。您发出请求,它可能是待处理的,要么实现并返回一些数据(成功),要么拒绝并返回错误。例如,要登录用户,所有可能的状态可能如下所示:

type UserRequest =
  | {
      state: "USER_PENDING";
    }
  | {
      state: "USER_ERROR";
      message: string;
    }
  | {
      state: "USER_SUCCESS";
      data: User;
    };

当我们获取用户订单时,我们可以使用相同的状态。唯一的区别在于成功载荷和每个状态的名称,这些名称根据请求类型进行了定制:

type OrderRequest =
  | {
      state: "ORDER_PENDING";
    }
  | {
      state: "ORDER_ERROR";
      message: string;
    }
  | {
      state: "ORDER_SUCCESS";
      data: Order;
    };

当我们处理全局状态处理机制,比如Redux,我们希望通过像这样的标识符进行区分。我们仍然希望将其缩小到相应的状态类型!

TypeScript 允许您创建有区别的联合类型,其中辨别器是字符串模板文字类型。因此,我们可以使用相同的模式总结所有可能的后端请求:

type Pending = {
  state: `${Uppercase<string>}_PENDING`;
};

type Err = {
  state: `${Uppercase<string>}_ERROR`;
  message: string;
};

type Success = {
  state: `${Uppercase<string>}_SUCCESS`;
  data: any;
};

type BackendRequest = Pending | Err | Success;

这已经给了我们一个优势。我们知道每个联合类型成员的状态属性需要以大写字符串开头,后跟下划线和相应的状态字符串。我们可以像往常一样缩小到子类型:

function execute(req: BackendRequest) {
  switch (req.state) {
    case "USER_PENDING":
      // req: Pending
      console.log("Login pending...");
      break;
    case "USER_ERROR":
      // req: Err
      throw new Error(`Login failed: ${req.message}`);
    case "USER_SUCCESS":
      // req: Success
      login(req.data);
      break;
    case "ORDER_PENDING":
      // req: Pending
      console.log("Fetching orders pending");
      break;
    case "ORDER_ERROR":
      // req: Err
      throw new Error(`Fetching orders failed: ${req.message}`);
    case "ORDER_SUCCESS":
      // req: Success
      displayOrder(req.data);
      break;
  }
}

将整个字符串集作为辨别器的第一部分可能有点过于复杂。我们可以缩小到各种已知请求,并使用字符串操作类型来获取正确的子类型:

type RequestConstants = "user" | "order";

type Pending = {
  state: `${Uppercase<RequestConstants>}_PENDING`;
};

type Err = {
  state: `${Uppercase<RequestConstants>}_ERROR`;
  message: string;
};

type Success = {
  state: `${Uppercase<RequestConstants>}_SUCCESS`;
  data: any;
};

这就是摆脱拼写错误的方法!更好的是,假设我们将所有数据存储在类型为Data的全局状态对象中。我们可以从这里派生所有可能的BackendRequest类型。通过使用keyof Data,我们获得组成BackendRequest状态的字符串键:

type Data = {
  user: User | null;
  order: Order | null;
};

type RequestConstants = keyof Data;

type Pending = {
  state: `${Uppercase<RequestConstants>}_PENDING`;
};

type Err = {
  state: `${Uppercase<RequestConstants>}_ERROR`;
  message: string;
};

这已经对PendingErr很有效,但在Success情况下,我们希望具有与"user""order"关联的实际数据类型。

第一种选择是使用索引访问从Data中获取data属性的正确类型:

type Success = {
  state: `${Uppercase<RequestConstants>}_SUCCESS`;
  data: NonNullable<Data[RequestConstants]>;
};
提示

NonNullable<T>消除联合类型中的nullundefined。启用编译器标志strictNullChecks后,所有类型都排除了nullundefined。这意味着如果存在空值状态,则需要手动添加它们,并在想要确保它们不包含时手动排除它们。

但这意味着data对于所有后端请求可以是UserOrder,如果添加新的请求,则更多。为了避免断开标识符与其关联的数据类型之间的连接,我们通过所有RequestConstants进行映射,创建状态对象,然后再次使用RequestConstants的索引访问来生成联合类型:

type Success = {
  [K in RequestConstants]: {
    state: `${Uppercase<K>}_SUCCESS`;
    data: NonNullable<Data[K]>;
  };
}[RequestConstants];

Success现在等于手动创建的联合类型:

type Success = {
    state: "USER_SUCCESS";
    data: User;
} | {
    state: "ORDER_SUCCESS";
    data: Order;
};

第七章:可变元组类型

元组类型是长度固定且每个元素类型已定义的数组。元组在像 React 这样的库中被广泛使用,因为易于解构和命名元素,但除了 React 外,它们也因为是对象的良好替代品而受到认可。

可变元组类型 是一种具有相同属性(定义长度和每个元素的类型已知),但其确切形状尚未定义的元组类型。它们基本上告诉类型系统会有一些元素,但我们还不知道它们将是哪些元素。它们是泛型的,旨在用真实类型替换。

当我们理解到元组类型也可以用于描述函数签名时,听起来像是一个相当无聊的功能,变得更加令人兴奋,因为元组可以展开到函数调用中作为参数。这意味着我们可以使用可变元组类型从函数和函数调用中获取最多的信息,以及接受函数作为参数的函数。

本章提供了许多用例,展示了我们如何使用可变元组类型来描述使用函数作为参数并需要从中获取最多信息的几种场景。如果没有可变元组类型,这些场景将很难开发或根本不可能。阅读完后,您将会将可变元组类型视为函数式编程模式的关键特性。

7.1 类型化 concat 函数

问题

你有一个concat函数,它接受两个数组并将它们连接起来。你想要确切的类型,但使用函数重载太过繁琐。

解决方案

使用可变元组类型。

讨论

concat 是一个可爱的辅助函数,它接受两个数组并将它们组合在一起。它使用数组展开,简短、优雅且可读:

function concat(arr1, arr2) {
  return [...arr1, ...arr2];
}

为这个函数创建类型可能很难,特别是如果你对你的类型有特定的期望。传入两个数组很容易,但返回类型应该是什么样的?你是否满意返回单个数组类型,还是想知道返回数组中每个元素的类型?

我们选择后者:我们想要元组,这样我们就知道将传递给此函数的每个元素的类型。为了正确地为这样的函数打上类型标记,以便考虑到所有可能的边界情况,我们最终会陷入一堆重载之中:

// 7 overloads for an empty second array
function concat(arr1: [], arr2: []): [];
function concat<A>(arr1: [A], arr2: []): [A];
function concat<A, B>(arr1: [A, B], arr2: []): [A, B];
function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C];
function concat<A, B, C, D>(arr1: [A, B, C, D], arr2: []): [A, B, C, D];
function concat<A, B, C, D, E>(
  arr1: [A, B, C, D, E],
  arr2: []
): [A, B, C, D, E];
function concat<A, B, C, D, E, F>(
  arr1: [A, B, C, D, E, F],
  arr2: []
): [A, B, C, D, E, F];
// 7 more for arr2 having one element
function concat<A2>(arr1: [], arr2: [A2]): [A2];
function concat<A1, A2>(arr1: [A1], arr2: [A2]): [A1, A2];
function concat<A1, B1, A2>(arr1: [A1, B1], arr2: [A2]): [A1, B1, A2];
function concat<A1, B1, C1, A2>(
  arr1: [A1, B1, C1],
  arr2: [A2]
): [A1, B1, C1, A2];
function concat<A1, B1, C1, D1, A2>(
  arr1: [A1, B1, C1, D1],
  arr2: [A2]
): [A1, B1, C1, D1, A2];
function concat<A1, B1, C1, D1, E1, A2>(
  arr1: [A1, B1, C1, D1, E1],
  arr2: [A2]
): [A1, B1, C1, D1, E1, A2];
function concat<A1, B1, C1, D1, E1, F1, A2>(
  arr1: [A1, B1, C1, D1, E1, F1],
  arr2: [A2]
): [A1, B1, C1, D1, E1, F1, A2];
// and so on, and so forth

这仅考虑了具有多达六个元素的数组。为像这样的函数编写类型重载是非常耗费精力的。但有一种更简单的方法:可变元组类型。

TypeScript 中的元组类型是具有以下特征的数组:

  • 数组的长度是定义好的。

  • 每个元素的类型是已知的(且不必相同)。

例如,这是一个元组类型:

type PersonProps = [string, number];

const [name, age]: PersonProps = ['Stefan', 37];

可变元组类型是一种元组类型,具有相同的属性——定义长度和每个元素的类型已知——但确切形状尚未定义。由于我们还不知道类型和长度,所以只能在泛型中使用可变元组类型:

type Foo<T extends unknown[]> = [string, ...T, number];

type T1 = Foo<[boolean]>;  // [string, boolean, number]
type T2 = Foo<[number, number]>;  // [string, number, number, number]
type T3 = Foo<[]>;  // [string, number]

这类似于函数中的剩余元素,但其主要区别在于可变元组类型可以在元组中的任何位置和多次出现:

type Bar<
  T extends unknown[],
  U extends unknown[]
> = [...T, string, ...U];

type T4 = Bar<[boolean], [number]>;  // [boolean, string, number]
type T5 = Bar<[number, number], [boolean]>;  // [number, number, string, boolean]
type T6 = Bar<[], []>;  // [string]

当我们将其应用于concat函数时,我们必须引入两个泛型参数,每个数组一个。两者都需要约束为数组。然后,我们可以创建一个返回类型,将两个数组类型组合成一个新创建的元组类型:

function concat<T extends unknown[], U extends unknown[]>(
  arr1: T,
  arr2: U
): [...T, ...U] {
  return [...arr1, ...arr2];
}

// const test: (string | number)[]
const test = concat([1, 2, 3], [6, 7, "a"]);

语法非常美观;它与 JavaScript 中实际的串联非常相似。结果也非常好:我们得到了一个(string | number)[],这已经是我们可以使用的内容。

但我们使用的是元组类型。如果我们想要确切地了解我们正在连接的哪些元素,我们必须将数组类型转换为元组类型,通过将泛型数组类型展开为元组类型:

function concat<T extends unknown[], U extends unknown[]>(
  arr1: [...T],
  arr2: [...U]
): [...T, ...U] {
  return [...arr1, ...arr2];
}

并且,作为返回值,我们也得到一个元组类型:

// const test: [number, number, number, number, number, string]
const test = concat([1, 2, 3], [6, 7, "a"]);

好消息是我们不会丢失任何东西。如果我们传递的是我们还不知道每个元素的数组,我们仍然会得到数组类型作为返回值:

declare const a: string[]
declare const b: number[]

// const test: (string | number)[]
const test = concat(a, b);

能够用单一类型描述这种行为显然比在函数重载中写入每种可能的组合更加灵活和可读。

7.2 给 promisify 函数添加类型

问题

您希望将回调样式的函数转换为 Promises,并完全对其进行类型化。

解决方案

函数参数是元组类型。使用可变元组类型使它们成为泛型。

讨论

在 JavaScript 中,Promises 成为一种事物之前,使用回调进行异步编程非常常见。函数通常会接受一系列参数,然后是一个回调函数,一旦结果出现,就会执行该函数,例如用于加载文件或进行非常简化的 HTTP 请求的函数:

function loadFile(
  filename: string,
  encoding: string,
  callback: (result: File) => void
) {
  // TODO
}

loadFile("./data.json", "utf-8", (result) => {
  // do something with the file
});

function request(url: URL, callback: (result: JSON) => void) {
  // TODO
}

request("https://typescript-cookbook.com", (result) => {
  // TODO
});

两者都遵循相同的模式:首先是参数,最后是带有结果的回调。这种方法有效,但如果有大量异步调用,其中包含回调中的回调,也被称为“噩梦金字塔”,可能会显得笨拙:

loadFile("./data.txt", "utf-8", (file) => {
  // pseudo API
  file.readText((url) => {
    request(url, (data) => {
      // do something with data
    })
  })
})

Promises 负责处理这一切。它们不仅找到了一种方法来链式调用异步调用而不是嵌套它们,而且它们还是async/await的门户,使我们能够以同步形式编写异步代码。

loadFilePromise("./data.txt", "utf-8")
  .then((file) => file.text())
  .then((url) => request(url))
  .then((data) => {
      // do something with data
  });

// with async/await

const file = await loadFilePromise("./data.txt". "utf-8");
const url = await file.text();
const data = await request(url);
// do something with data.

好多了!幸运的是,我们可以将符合回调模式的每个函数都转换为Promise。我们希望创建一个promisify函数来自动为我们完成这项工作:

function promisify(fn: unknown): Promise<unknown> {
  // To be implemented
}

const loadFilePromise = promisify(loadFile);
const requestPromise = promisify(request);

但是我们如何对其进行类型化呢?可变元组类型来拯救我们!

每个函数头都可以描述为一个元组类型。例如:

declare function hello(name: string, msg: string): void;

就像是:

declare function hello(...args: [string, string]): void;

并且我们可以在定义时非常灵活地进行定义:

declare function h(a: string, b: string, c: string): void;
// equal to
declare function h(a: string, b: string, ...r: [string]): void;
// equal to
declare function h(a: string, ...r: [string, string]): void;
// equal to
declare function h(...r: [string, string, string]): void;

这也被称为 剩余元素,在 JavaScript 中我们拥有它,允许你定义具有几乎无限参数列表的函数,其中最后一个元素,剩余元素,吸收了所有多余的参数。

例如,这个通用元组函数接受任何类型的参数列表,并将其创建为元组:

function tuple<T extends any[]>(...args: T): T {
    return args;
}

const numbers: number[] = getArrayOfNumbers();
const t1 = tuple("foo", 1, true);  // [string, number, boolean]
const t2 = tuple("bar", ...numbers);  // [string, ...number[]]

问题是,其余元素必须始终位于最后。在 JavaScript 中,不可能在中间定义一个几乎无限的参数列表。然而,使用可变元组类型,我们可以在 TypeScript 中实现这一点!

让我们再次看看 loadFilerequest 函数。如果我们将两个函数的参数描述为元组,它们会是这样的:

function loadFile(...args: [string, string, (result: File) => void]) {
  // TODO
}

function request2(...args: [URL, (result: JSON) => void]) {
  // TODO
}

让我们寻找相似之处。两者都以具有不同结果类型的回调结束。我们可以通过用一个通用的结果类型替代这些变化来对齐两个回调的类型。稍后在使用中,我们将泛型替换为实际类型。因此,JSONFile 变成了泛型类型参数 Res

现在来看看 Res 之前的参数。它们可能完全不同,但它们甚至有一些共同点:它们是元组内的元素。这就需要用到可变元组。我们知道它们将具有具体的长度和具体的类型,但现在我们只是用一个占位符来表示它们。让我们称之为 Args

因此,描述两个函数签名的函数类型可能如下所示:

type Fn<Args extends unknown[], Res> = (
  ...args: [...Args, (result: Res) => void]
) => void;

让你的新类型试试水:

type LoadFileFn = Fn<[string, string], File>;
type RequestFn = Fn<[URL], JSON>;

这正是我们需要的 promisify 函数。我们能够提取所有相关参数——即回调之前的参数和结果类型——并将它们组织成新的顺序。

让我们首先直接将新创建的函数类型内联到 promisify 的函数签名中:

function promisify<Args extends unknown[], Res>(
  fn: (...args: [...Args, (result: Res) => void]) => void
): (...args: Args) => Promise<Res> {
  // soon
}

现在 promisify 看起来是这样的:

  • 有两个泛型类型参数:Args,它需要是一个数组(或元组),以及 Res

  • promisify 的参数是一个函数,其中前几个参数是 Args 的元素,最后一个参数是具有类型 Res 的函数。

  • promisify 返回一个接受 Args 作为参数并返回 ResPromise 的函数。

如果你尝试使用 promisify 的新类型,你会发现我们确实得到了想要的类型。

但情况会更好。如果你看一下函数签名,那么我们期望的参数显而易见,即使它们是可变的,并将被实际类型替代。我们可以在 promisify 的实现中使用相同的类型:

function promisify<Args extends unknown[], Res>(
  fn: (...args: [...Args, (result: Res) => void]) => void
): (...args: Args) => Promise<Res> {
  return function (...args: Args) { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/1.png)
    return new Promise((resolve) => { ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/2.png)
      function callback(res: Res) { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/3.png)
        resolve(res);
      }
      fn.call(null, ...[...args, callback]); ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/4.png)
    });
  };
}

所以它的作用是什么?

1

我们返回一个接受除回调外的所有参数的函数。

2

这个函数返回一个新创建的 Promise

3

由于我们还没有一个回调函数,我们需要构建它。它的作用是什么?它调用 Promise 中的 resolve 函数,生成一个结果。

4

已分割的内容需要重新组合!我们将回调函数添加到参数中,并调用原始函数。

就是这样。一个用于符合回调模式函数的工作promisify函数。完美地类型化。而且我们甚至保留了参数名。

7.3 编写柯里化函数的类型

问题

编写一个curry函数。柯里化是一种将接受多个参数的函数转换为一系列每个仅接受单个参数的函数的技术。

您希望提供优秀的类型。

解决方案

结合条件类型和可变参数元组类型,始终剥离第一个参数。

讨论

柯里化(Currying)是函数式编程中非常著名的技术。柯里化将接受多个参数的函数转换为一系列每个仅接受单个参数的函数。

其基本概念被称为“函数参数的部分应用”。我们用它来最大化函数的复用。柯里化的“Hello, World!”示例实现了一个可以稍后部分应用第二个参数的add函数:

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

const curriedAdd = curry(add); // convert: (a: number) => (b: number) => number
const add5 = curriedAdd(5); // apply first argument. (b: number) => number
const result1 = add5(2); // second argument. Result: 7
const result2 = add5(3); // second argument. Result: 8

起初看起来随意的东西在处理长参数列表时非常有用。下面这个通用函数要么添加要么移除HTMLElement的类。

我们可以准备好所有内容,除了最终事件:

function applyClass(
  this: HTMLElement, // for TypeScript only
  method: "remove" | "add",
  className: string,
  event: Event
) {
  if (this === event.target) {
    this.classListmethod;
  }
}

const applyClassCurried = curry(applyClass); // convert
const removeToggle = applyClassCurried("remove")("hidden");

document.querySelector(".toggle")?.addEventListener("click", removeToggle);

这样,我们可以为多个元素上的多个事件重复使用removeToggle。我们也可以在许多其他情况下使用applyClass

柯里化是编程语言 Haskell 的基本概念,向数学家 Haskell Brooks Curry 致敬,为编程语言和技术命名。在 Haskell 中,每个操作都是柯里化的,并且程序员能够充分利用它。

JavaScript 在很大程度上借鉴了函数式编程语言,可以利用其内置的绑定功能实现部分应用:

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

// Partial application
const partialAdd5And3 = add.bind(this, 5, 3);
const result = partialAdd5And3(2); // third argument

由于 JavaScript 中函数是一等公民,我们可以创建一个curry函数,它接受一个函数作为参数,并在执行之前收集所有参数:

function curry(fn) {
  let curried = (...args) => {
    // if you haven't collected enough arguments
    if (fn.length !== args.length) {
      // partially apply arguments and
      // return the collector function
      return curried.bind(null, ...args);
    }
    // otherwise call all functions
    return fn(...args);
  };
  return curried;
}

技巧在于每个函数都在其length属性中存储了定义参数的数量。这就是我们如何递归地收集所有必要的参数,然后将它们应用于传递的函数的方法。

那么还缺什么?类型!让我们创建一个类型,适用于每个顺序函数都可以接受恰好一个参数的柯里化模式。我们通过创建一个条件类型来实现这一点,该类型执行与curry函数内部的curried函数相反的操作:移除参数。

因此,让我们创建一个Curried<F>类型。首先要检查类型是否确实是一个函数:

type Curried<F> = F extends (...args: infer A) => infer R
  ? /* to be done */
  : never; // not a function, this should not happen

我们还推断出参数为A,返回类型为R。下一步,我们将第一个参数作为F剥离,并将所有剩余参数存储在L中(用于last):

type Curried<F> = F extends (...args: infer A) => infer R
  ? A extends [infer F, ...infer L]
    ? /* to be done */
    : () => R
  : never;

如果没有参数,我们返回一个不带参数的函数。最后检查:我们检查剩余参数是否为空。这意味着我们已经达到了从参数列表中移除参数的末端:

type Curried<F> = F extends (...args: infer A) => infer R
  ? A extends [infer F, ...infer L]
    ? L extends []
      ? (a: F) => R
      : (a: F) => Curried<(...args: L) => R>
    : () => R
  : never;

如果某些参数保留下来,我们再次调用Curried类型,但带上剩余的参数。这样,我们逐步去除一个参数,如果你仔细观察,你会发现这个过程几乎与curried函数中所做的相同。在Curried<F>中我们解构参数,而在curried(fn)中我们重新收集它们。

有了类型完成后,让我们把它添加到curry中:

function curry<F extends Function>(fn: F): Curried<F> {
  let curried: Function = (...args: any) => {
    if (fn.length !== args.length) {
      return curried.bind(null, ...args);
    }
    return fn(...args);
  };
  return curried as Curried<F>;
}

由于类型的灵活性,我们需要一些断言和any。但通过asany关键字,我们标记了哪些部分被认为是不安全的类型。

就是这样!我们可以很轻松地进行函数柯里化!

7.4 创建一个灵活的curry函数的类型

问题

来自第 7.3 节的curry函数允许传递任意数量的参数,但你的类型定义只允许一次接收一个参数。

解决方案

扩展您的类型以创建所有可能元组组合的函数重载。

讨论

在第 7.3 节的配方中,我们最终得到了允许我们逐个应用函数参数的函数类型:

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

const adder = curried(addThree);
const add7 = adder(5)(2);
const result = add7(2);

然而,curry函数本身可以接受任意列表的参数:

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

const adder = curried(addThree);
const add7 = adder(5, 2); // this is the difference
const result = add7(2);

这使我们能够处理相同的用例,但调用函数的次数大大减少了。因此,让我们调整我们的类型以充分利用完整的curry体验。

注意

此示例非常好地说明了类型系统如何仅作为 JavaScript 之上的薄层工作。通过在正确位置添加断言和any,我们有效地定义了curry的工作方式,而函数本身则更加灵活。请注意,当您在复杂功能的基础上定义复杂类型时,可能会以某种方式达到目标,而最终如何工作则取决于您。请相应地进行测试。

我们的目标是创建一个类型,可以为每个部分应用生成所有可能的函数签名。对于addThree函数,所有可能的类型看起来像这样:

type Adder = (a: number) => (b: number) => (c: number) => number;
type Adder = (a: number) => (b: number, c: number) => number;
type Adder = (a: number, b: number) => (c: number) => number;
type Adder = (a: number, b: number, c: number) => number;

另请参见图 7-1,显示所有可能调用图的可视化。

tscb 0701

图 7-1. 显示addThree在柯里化时所有可能函数调用组合的图表;从三个分支开始,可能还有第四个分支

我们首先要做的是稍微调整我们调用Curried辅助类型的方式。在原始类型中,我们在辅助类型中进行函数参数和返回类型的推断。现在,我们需要在多个类型调用中传递返回值,因此我们直接从curry函数中提取返回类型和参数:

function curry<A extends any[], R extends any>(
  fn: (...args: A) => R
): Curried<A, R> {
  // see before, we're not changing the implementation
}

接下来,我们重新定义Curried类型。现在它包含两个泛型类型参数:A代表参数,R代表返回类型。作为第一步,我们检查参数是否包含元组元素。我们提取第一个元素F和所有剩余的元素L。如果没有剩余元素,我们返回返回类型R

type Curried<A extends any[], R extends any> = A extends [infer F, ...infer L]
  ? // to be done
  : R;

不可能通过剩余运算符提取多个元组。这就是为什么我们仍然需要去掉第一个元素并收集L中的剩余元素。但没关系,我们至少需要一个参数来有效地执行部分应用。

当我们处于true分支时,我们创建函数定义。在前面的例子中,我们返回了一个返回递归调用的函数;现在我们需要提供所有可能的部分应用程序。

由于函数参数仅仅是元组类型(参见 Recipe 7.2),函数重载的参数可以描述为元组类型的并集。类型Overloads接受函数参数的元组并创建所有的部分应用程序:

type Overloads<A extends any[]> = A extends [infer A, ...infer L]
  ? [A] | [A, ...Overloads<L>] | []
  : [];

如果我们传递一个元组,我们将从空元组开始,并逐步增长到一个参数,然后到两个参数,依此类推,直到包含所有参数的元组:

// type Overloaded = [] | [string, number, string] | [string] | [string, number]
type Overloaded = Overloads<[string, number, string]>;

现在我们可以定义所有的重载,我们获取原始函数参数列表的剩余参数,并创建所有可能的函数调用,这些调用也包括第一个参数:

type Curried<A extends any[], R extends any> = A extends [infer F, ...infer L]
  ? <K extends Overloads<L>>(
      arg: F,
      ...args: K
    ) => /* to be done */
  : R;

应用于之前的addThree示例,这部分会将第一个参数F创建为number,然后与[][number][number, number]组合。

现在是返回类型的问题。这再次是对Curried的递归调用,就像在 Recipe 7.2 中一样。请记住,我们按顺序链接函数。我们传入相同的返回类型——最终我们需要到达那里——但也需要传递我们在函数重载中尚未展开的所有剩余参数。因此,如果我们只使用number调用addThree,则下一次Curried的迭代需要这两个剩余数字作为参数。这是我们如何创建可能调用的树。

要到达可能的组合,我们需要从剩余的参数中删除我们已在函数签名中描述的参数。一个辅助类型Remove<T, U>遍历这两个元组,并每次去掉一个元素,直到其中一个元组耗尽为止:

type Remove<T extends any[], U extends any[]> = U extends [infer _, ...infer UL]
  ? T extends [infer _, ...infer TL]
    ? Remove<TL, UL>
    : never
  : T;

将其应用于Curried,我们得到最终结果:

type Curried<A extends any[], R extends any> = A extends [infer F, ...infer L]
  ? <K extends Overloads<L>>(
      arg: F,
      ...args: K
    ) => Curried<Remove<L, K>, R>
  : R;

Curried<A, R>现在产生与 Figure 7-1 中描述的调用图相同的结果,但对我们传递给curry的所有可能函数都很灵活。最大灵活性的类型安全(向提供了他们在类型挑战解决方案中提供了缺失部分的 GitHub 用户 Akira Matsuzaki 致敬)。

7.5 Typing the Simplest curry function

问题

curry函数及其类型令人印象深刻,但伴随着许多注意事项。是否有更简单的解决方案?

解决方案

创建一个仅有单一顺序步骤的curry函数。TypeScript 能够自行确定适当的类型。

讨论

在柯里化三部曲的最后一部分中,我希望你坐下来思考一下我们在 Recipes 7.3 和 7.4 中看到的内容。我们通过 TypeScript 的元编程特性创建了非常复杂的类型,几乎与实际实现一样工作。尽管结果令人印象深刻,但我们必须考虑一些注意事项:

  • 为了 Recipes 7.3 和 7.4 中的类型实现,实现的方式有些不同,但结果差异很大!然而,curry函数的底层实现保持不变。这样做的唯一方法是在参数中使用any和在返回类型上进行类型断言。这意味着我们通过强制 TypeScript 遵循我们的世界观,有效地禁用了类型检查。TypeScript 能够做到这一点非常棒,有时也是必要的(例如创建新对象),但它也可能适得其反,特别是当实现和类型变得非常复杂时。测试类型和实现都是必须的。我们在 Recipe 12.4 中讨论测试类型。

  • 你会丢失信息。特别是在柯里化时,保持参数名对于知道哪些参数已经应用是至关重要的。之前的解决方案无法保留参数名,而是默认为通用的aargs。例如,如果你的参数类型全都是字符串,你无法知道你当前正在写哪个字符串。

  • 虽然 Recipe 7.4 中的结果提供了适当的类型检查,但由于类型的特性,自动补全受到限制。你只知道在键入它的时刻需要第二个参数。TypeScript 的一个主要功能是提供正确的工具和信息,使你更加高效。灵活的Curried类型再次将你的生产力降低到猜测的程度。

尽管这些类型令人印象深刻,但不可否认它们伴随着一些巨大的权衡。这引发了一个问题:我们应该选择它吗?我认为这实际取决于你试图达成什么。

在柯里化和部分应用的情况下,存在两个派别。第一派喜欢函数式编程模式,并尽可能利用 JavaScript 的函数能力。他们希望尽可能多地重用部分应用,并需要先进的柯里化功能。另一派看到了在特定情况下函数式编程模式的好处,例如,等待最终参数将相同函数提供给多个事件。他们通常乐意尽可能多地应用,然后在第二步提供其余部分。

到目前为止,我们只处理了第一类营地。如果你属于第二类营地,你很可能只需要一个部分应用参数的柯里化函数,这样你可以在第二步中传递其余的参数:不是一个参数的参数序列,也不是灵活应用任意多个参数。一个理想的接口看起来像这样:

function applyClass(
  this: HTMLElement, // for TypeScript only
  method: "remove" | "add",
  className: string,
  event: Event
) {
  if (this === event.target) {
    this.classListmethod;
  }
}

const removeToggle = curry(applyClass, "remove", "hidden");

document.querySelector("button")?.addEventListener("click", removeToggle);

curry 是一个函数,它接受另一个函数 f 作为参数,然后是 f 的一系列参数 t。它返回一个函数,该函数接受 f 的剩余参数 u,并调用 f 以所有可能的参数。该函数在 JavaScript 中可能看起来像这样:

function curry(f, ...t) {
  return (...u) => f(...t, ...u);
}

由于剩余和展开运算符,curry 成为了一个一行代码的函数。现在让我们给它加上类型!我们将不得不使用泛型,因为我们处理的是尚不知道的参数。这里有返回类型 R,以及函数参数 TU 的两个部分。后者是变长元组类型,需要如此定义。

用泛型类型参数 TU 来组成 f 的参数,f 的类型看起来像这样:

type Fn<T extends any[], U extends any[]> =
    (...args: [...T, ...U]) => any;

函数参数可以描述为元组,这里我们说这些函数参数应该分成两部分。让我们将这种类型内联到 curry 中,并为返回类型 R 使用另一个泛型类型参数:

function curry<T extends any[], U extends any[], R>(
  f: (...args: [...T, ...U]) => R,
  ...t: T
) {
  return (...u: U) => f(...t, ...u);
}

这就是我们需要的所有类型:简单、直接,类型看起来非常类似于实际实现。通过一些变长元组类型,TypeScript 给我们提供了:

  • 100% 类型安全。TypeScript 直接从你的使用中推断出泛型类型,并且它们是正确的。没有通过条件类型和递归繁琐地构造类型。

  • 我们获得了所有可能解决方案的自动完成。当你添加一个,来宣布你的参数的下一步时,TypeScript 将调整类型并给出提示,告诉你可以期待什么。

  • 我们不会丢失任何信息。由于我们不构建新类型,TypeScript 会保留原始类型的标签,我们知道可以期待哪些参数。

是的,curry 不像原始版本那样灵活,但对于许多用例而言,这可能是正确的选择。这完全取决于我们为我们的用例接受的权衡。

提示

如果你经常使用元组,你可以为元组类型的元素命名:type Person = [name: string, age: number];。这些标签只是注释,在转译后会被移除。

最终,curry 函数及其许多不同的实现代表了你可以使用 TypeScript 解决特定问题的许多方法。你可以全力投入类型系统,并用它来处理非常复杂和精细的类型,或者你可以稍微减少范围,让编译器为你完成工作。你的选择取决于你的目标和你试图实现的内容。

7.6 从元组创建枚举

问题

你喜欢枚举如何简化选择有效值,但在阅读 Recipe 3.12 之后,你可能不想处理它们的所有警告。

解决方案

从元组创建您的枚举。使用条件类型、可变元组类型和"length"属性来为数据结构添加类型。

讨论

在 Recipe 3.12 中,我们讨论了在使用数字和字符串枚举时可能遇到的所有注意事项。我们最终得出的模式更接近类型系统,但给您提供了与常规枚举相同的开发者体验:

const Direction = {
  Up: 0,
  Down: 1,
  Left: 2,
  Right: 3,
} as const;

// Get to the const values of Direction
type Direction = (typeof Direction)[keyof typeof Direction];

// (typeof Direction)[keyof typeof Direction] yields 0 | 1 | 2 | 3
function move(direction: Direction) {
  // tbd
}

move(30); // This breaks!

move(0); //This works!

move(Direction.Left); // This also works!

这是一个非常直接的模式,没有任何意外,但如果您处理大量条目,特别是想要有字符串枚举时,可能需要为此付出很多工作:

const Commands = {
  Shift: "shift",
  Xargs: "xargs",
  Tail: "tail",
  Head: "head",
  Uniq: "uniq",
  Cut: "cut",
  Awk: "awk",
  Sed: "sed",
  Grep: "grep",
  Echo: "echo",
} as const;

存在重复,这可能导致拼写错误,进而导致未定义的行为。一个为您创建这种枚举的辅助函数可以帮助处理冗余和重复。假设您有这样一组项目:

const commandItems = [
  "echo",
  "grep",
  "sed",
  "awk",
  "cut",
  "uniq",
  "head",
  "tail",
  "xargs",
  "shift",
] as const;

辅助函数createEnum遍历每个项,创建一个对象,其中包含大写的键,指向字符串值或数字值,具体取决于您的输入参数:

function capitalize(x: string): string {
  return x.charAt(0).toUpperCase() + x.slice(1);
}

// Typings to be done
function createEnum(arr, numeric) {
  let obj = {};
  for (let [i, el] of arr.entries()) {
    obj[capitalize(el)] = numeric ? i : el;
  }
  return obj;
}

const Command = createEnum(commandItems); // string enum
const CommandN = createEnum(commandItems, true); // number enum

让我们为此创建类型!我们需要处理两件事:

  • 从元组创建一个对象。键都是大写的。

  • 将每个属性键的值设置为字符串值或数字值。数字值应从 0 开始,并随每一步递增。

要创建对象键,我们需要一个可以映射出的联合类型。为了获取所有对象键,我们需要将元组转换为联合类型。辅助类型TupleToUnion接受一个字符串元组并将其转换为联合类型。为什么只有字符串元组?因为我们需要对象键,而字符串键最容易使用。

TupleToUnion<T> 是一个递归类型。就像我们在其他课程中所做的那样,我们一直在削减单个元素——这次是元组末尾的元素——然后再次调用剩余元素的类型。我们将每次调用放入一个联合类型中,有效地得到了元组元素的联合类型:

type TupleToUnion<T extends readonly string[]> = T extends readonly [
  ...infer Rest extends string[],
  infer Key extends string
]
  ? Key | TupleToUnion<Rest>
  : never;

借助映射类型和字符串操作类型,我们可以创建Enum<T>的字符串枚举版本:

type Enum<T extends readonly string[], N extends boolean = false> = Readonly<
  {
    [K in TupleToUnion<T> as Capitalize<K>]: K
  }
>;

对于数字枚举版本,我们需要获取每个值的数字表示。如果我们仔细想一想,我们已经在原始数据中的某个地方存储了它。让我们看看TupleToUnion如何处理一个四元素元组:

// The type we want to convert to a union type
type Direction = ["up", "down", "left", "right"];

// Calling the helper type
type DirectionUnion = TupleToUnion<Direction>;

// Extracting the last, recursively calling TupleToUnion with the Rest
type DirectionUnion = "right" | TupleToUnion<["up", "down", "left"]>;

// Extracting the last, recursively calling TupleToUnion with the Rest
type DirectionUnion = "right" | "left" | TupleToUnion<["up", "down"]>;

// Extracting the last, recursively calling TupleToUnion with the Rest
type DirectionUnion = "right" | "left" | "down" | TupleToUnion<["up"]>;

// Extracting the last, recursively calling TupleToUnion with an empty tuple
type DirectionUnion = "right" | "left" | "down" | "up" | TupleToUnion<[]>;

// The conditional type goes into the else branch, adding never to the union
type DirectionUnion = "right" | "left" | "down" | "up" | never;

// never in a union is swallowed
type DirectionUnion = "right" | "left" | "down" | "up";

如果您仔细观察,可以看到每次调用时元组的长度在减少。首先是三个元素,然后是两个,然后是一个,最后没有元素了。元组由数组的长度和数组中每个位置的类型定义。TypeScript 将元组的长度存储为一个数字,可通过"length"属性访问:

type DirectionLength = Direction["length"]; // 4

因此,通过每次递归调用,我们可以获得剩余元素的长度,并将其作为枚举的值。我们不仅返回枚举键,还返回一个对象,其中包含键及其可能的数字值:

type TupleToUnion<T extends readonly string[]> = T extends readonly [
  ...infer Rest extends string[],
  infer Key extends string
]
  ? { key: Key; val: Rest["length"] } | TupleToUnion<Rest>
  : never;

我们使用这个新创建的对象来决定我们的枚举中是要有数字值还是字符串值:

type Enum<T extends readonly string[], N extends boolean = false> = Readonly<
  {
    [K in TupleToUnion<T> as Capitalize<K["key"]>]: N extends true
      ? K["val"]
      : K["key"];
  }
>;

就是这样!我们将新的Enum<T, N>类型连接到createEnum函数:

type Values<T> = T[keyof T];

function createEnum<T extends readonly string[], B extends boolean>(
  arr: T,
  numeric?: B
) {
  let obj: any = {};
  for (let [i, el] of arr.entries()) {
    obj[capitalize(el)] = numeric ? i : el;
  }
  return obj as Enum<T, B>;
}

const Command = createEnum(commandItems, false);
type Command = Values<typeof Command>;

能够在类型系统中访问元组的长度是 TypeScript 中的一个隐藏宝石。这允许许多事情,如本例所示,以及实现计算器在类型系统中的有趣功能。与 TypeScript 中所有高级特性一样,明智地使用它们。

7.7 分割函数签名的所有元素

问题

你知道如何在函数内部获取参数类型和返回类型,但你也想在外部使用相同的类型。

解决方案

使用内置的Parameters<F>ReturnType<F>辅助类型。

讨论

在本章中,我们处理了辅助函数以及它们如何从作为参数的函数中获取信息。例如,这个defer函数接受一个函数及其所有参数,并返回另一个将执行它的函数。通过一些泛型类型,我们可以捕获所有需要的内容:

function defer<Par extends unknown[], Ret>(
  fn: (...par: Par) => Ret,
  ...args: Par
): () => Ret {
  return () => fn(...args);
}

const log = defer(console.log, "Hello, world!");
log();

如果我们将函数作为参数传递,这将非常有效,因为我们可以轻松地提取详细信息并重复使用它们。但某些情况需要一个函数的参数及其返回类型在泛型函数之外。幸运的是,我们可以利用一些内置的 TypeScript 辅助类型。使用Parameters<F>我们获取一个函数的参数作为元组;使用ReturnType<F>我们获取一个函数的返回类型。因此,之前的defer函数可以这样编写:

type Fn = (...args: any[]) => any;

function defer<F extends Fn>(
  fn: F,
  ...args: Parameters<F>
): () => ReturnType<F> {
  return () => fn(...args);
}

Parameters<F>ReturnType<F>都是条件类型,依赖于函数/元组类型,非常相似。在Parameters<F>中,我们推断参数,在ReturnType<F>中,我们推断返回类型:

type Parameters<F extends (...args: any) => any> =
  F extends (...args: infer P) => any ? P : never;

type ReturnType<F extends (...args: any) => any> =
  F extends (...args: any) => infer R ? R : any;

我们可以使用这些辅助类型,例如,在函数外部准备函数参数。以这个search函数为例:

type Result = {
  page: URL;
  title: string;
  description: string;
};

function search(query: string, tags: string[]): Promise<Result[]> {
  throw "to be done";
}

使用Parameters<typeof search>我们可以了解期望的参数。我们在函数调用之外定义它们,并在调用时将它们作为参数展开:

const searchParams: Parameters<typeof search> = [
  "Variadic tuple tpyes",
  ["TypeScript", "JavaScript"],
];

search(...searchParams);
const deferredSearch = defer(search, ...searchParams);

当你生成新类型时,这两个辅助函数也很有用;参见 Recipe 4.8 作为一个例子。

第八章:辅助类型

TypeScript 的一个优势是能够从其他类型派生出新的类型。这使你能够定义类型之间的关系,其中对一个类型的更新会自动传播到所有派生类型中。这减少了维护成本,最终导致更加健壮的类型设置。

在创建派生类型时,通常会应用相同的类型修改,但组合方式不同。TypeScript 已经内置了一组 实用工具类型,本书中我们已经见过其中一些。但有时候这些还不够。有些情况下,你需要以不同的方式应用已知技术,或者深入了解类型系统的内部工作原理来实现期望的结果。你可能需要自己的一组辅助类型。

本章介绍了辅助类型的概念,并展示了一些使用案例,说明自定义辅助类型如何极大地扩展你从其他类型中推导类型的能力。每种类型都设计用于不同的情况,并且每种类型应该教会你类型系统的一个新方面。当然,你在这里看到的类型列表并不全面,但它们为你提供了一个良好的起点和足够的资源来展开研究。

最终,TypeScript 的类型系统可以被视为自己的功能性元编程语言,在这里,你可以将小型、单一目的的辅助类型与更大的辅助类型结合起来,使类型派生就像将单一类型应用于现有模型一样简单。

8.1 设置特定属性为可选

问题

你希望推导出设置特定属性为可选的类型。

解决方案

创建一个自定义辅助类型 SetOptional,它交集两个对象类型:一个使用可选映射类型修饰符映射所有选定属性,另一个映射所有剩余属性。

讨论

TypeScript 项目中的所有模型都已设置和定义,并且你希望在整个代码中引用它们:

type Person = {
  name: string;
  age: number;
  profession: string;
};

经常出现的一种情况是,你需要像 Person 这样的东西,但不要求设置所有属性;其中一些可以是 可选的。这将使你的 API 更容易适应其他结构和类型,它们形状相似但可能缺少一个或两个字段。你不希望维护不同的类型(参见 Recipe 12.1),而是希望从原始模型中派生它们,该模型仍在使用中。

TypeScript 有一个内置的辅助类型叫做 Partial<T>,可以将所有属性修改为可选的:

type Partial<T> = { [P in keyof T]?: T[P]; };

这是一个 映射类型,它在所有键上进行映射,并使用 可选映射类型修饰符 将每个属性设置为可选的。制作 SetOptional 类型的第一步是减少可以设置为可选的键集:

type SelectPartial<T, K extends keyof T> = {
  [P in K]?: T[P]
};
注意

可选映射类型修饰符会将一个可选属性的符号(问号)应用于一组属性。你在 Recipe 4.5 中学习了关于映射类型修饰符的内容。

SelectPartial<T, K extends keyof T>中,我们不会映射所有键,只映射提供的键的子集。通过extends keyof T泛型约束,我们确保仅传递有效的属性键。如果我们将SelectPartial应用于Person来选择"age",我们最终得到的类型将仅显示age属性,并将其设置为可选:

type Age = SelectPartial<Person, "age">;

// type Age = { age?: number | undefined };

第一步已经完成:我们希望设置为可选的所有内容都是可选的。但是其余的属性却丢失了。让我们将它们还原到对象类型中。

扩展现有对象类型并添加更多属性的最简单方法是创建与另一个对象类型的交集类型。因此,在我们的情况下,我们将SelectPartial中编写的内容与包含所有剩余键的类型相交。

我们可以通过使用Exclude辅助类型获取所有剩余的键。Exclude<T, U>是一种条件类型,用于比较两个集合。如果集合T中的元素在U中,则使用never将它们移除;否则,它们保留在类型中:

type Exclude<T, U> = T extends U ? never : T;

这与我们在食谱 5.3 中描述的Extract<T, U>相反。Exclude<T, U>是一种分布条件类型(见食谱 5.2),它将条件类型应用于联合类型的每个元素:

// This example shows how TypeScript evaluates a
// helper type step by step.

type ExcludeAge = Exclude<"name" | "age", "age">;

// 1\. Distribute
type ExcludeAge =
  "name" extends "age" ? never : "name" |
  "age" extends "age" ? never : "age";

// 2\. Evaluate
type ExcludeAge = "name" | never;

// 3\. Remove unnecessary `never`
type ExcludeAge = "name";

这正是我们想要的!在SetOptional中,我们创建一个类型,挑选所有选定的键并使它们可选,然后从所有对象键的更大集合中排除相同的键:

type SetOptional<T, K extends keyof T> = {
  [P in K]?: T[P];
} &
  {
    [P in Exclude<keyof T, K>]: T[P];
  };

两种类型的交集是新的对象类型,我们可以将其用于任何我们喜欢的模型:

type OptionalAge = SetOptional<Person, "age">;

/*
type OptionalAge = {
 name: string;
 age?: number | undefined;
 profession: string;
};
*/

如果我们想要使多个键可选,我们需要提供一个包含所有期望属性键的联合类型:

type OptionalAgeAndProf = SetOptional<Person, "age" | "profession">;

TypeScript 不仅允许您自己定义这样的类型,还提供了一组内置的辅助类型,您可以轻松地结合使用它们来达到类似的效果。我们可以仅基于辅助类型编写完全相同的SetOptional类型:

type SetOptional<T, K extends keyof T> = Partial<Pick<T, K>> & Omit<T, K>;
  • Pick<T, K>从对象T中选择键K

  • Omit<T, K>从对象T中选择除了K之外的所有内容(在内部使用Exclude)。

  • 而我们已经了解了Partial<T>的作用。

根据您喜欢阅读类型的方式,这些辅助类型的组合可能更易于阅读和理解,特别是因为内置类型在开发者中更为广为人知。

只有一个问题:如果您悬停在新生成的类型上,TypeScript 将向您显示类型的构建方式,而不是实际的属性。通过来自食谱 8.3 的Remap辅助类型,我们可以使我们的类型更易读和可用:

type SetOptional<T, K extends keyof T> = Remap<
  Partial<Pick<T, K>> & Omit<T, K>
>;

如果您将您的类型参数视为函数接口,您可能也希望考虑您的类型参数。您可以进行的一个优化是将第二个参数——所选对象键——设置为默认值:

type SetOptional<T, K extends keyof T = keyof T> = Remap<
  Partial<Pick<T, K>> & Omit<T, K>
>;

通过K extends keyof T = keyof T,我们可以确保将所有属性键都设置为可选,并且仅在需要时选择特定的键。我们的辅助类型刚刚变得更加灵活。

同样,您还可以开始为其他情况创建类型,比如 SetRequired,在这种情况下,您希望确保某些键绝对是必需的:

type SetRequired<T, K extends keyof T = keyof T> = Remap<
  Required<Pick<T, K>> & Omit<T, K>
>;

或者 OnlyRequired,在这种情况下,你提供的所有键都是必需的,但其余的是可选的:

type OnlyRequired<T, K extends keyof T = keyof T> = Remap<
  Required<Pick<T, K>> & Partial<Omit<T, K>>
>;

最好的一点是:您最终会拥有一整套辅助类型,可以在多个项目中使用。

8.2 修改嵌套对象

问题

PartialRequiredReadonly 这样的对象辅助类型仅修改对象的第一层级,并且不会触及嵌套对象属性。

解决方案

创建递归辅助类型,在嵌套对象上执行相同操作。

讨论

假设你的应用程序具有可以由用户配置的不同设置。为了使您能够随时间轻松扩展设置,您只存储一组默认设置与用户配置的设置之间的差异:

type Settings = {
  mode: "light" | "dark";
  playbackSpeed: number;
  subtitles: {
    active: boolean;
    color: string;
  };
};

const defaults: Settings = {
  mode: "dark",
  playbackSpeed: 1.0,
  subtitles: {
    active: false,
    color: "white",
  },
};

函数 applySettings 接受默认设置和用户设置。您将它们定义为 Partial<Settings>,因为用户只需要提供一些键;其余的将从默认设置中获取:

function applySettings(
  defaultSettings: Settings,
  userSettings: Partial<Settings>
): Settings {
  return { ...defaultSettings, ...userSettings };
}

如果你需要在第一层级设置某些属性,这种方法非常有效:

let settings = applySettings(defaults, { mode: "light" });

但是,如果你想修改对象更深层次的特定属性,比如将 subtitles 设置为 active,就会遇到问题:

let settings = applySettings(defaults, { subtitles: { active: true } });
//                        ^
// Property 'color' is missing in type '{ active: true; }'
// but required in type '{ active: boolean; color: string; }'.(2741)

TypeScript 提醒我们,对于 subtitles,你需要提供整个对象。这是因为 Partial<T> ——就像它的兄弟 Required<T>Readonly<T> 一样——仅修改对象的第一层级。嵌套对象将被视为简单值。

要解决这个问题,我们需要创建一个名为 DeepPartial<T> 的新类型,它会递归地遍历每个属性,并为每个级别应用可选映射类型修改器

type DeepPartial<T> = {
  [K in keyof T]?: DeepPartial<T[K]>;
};

第一个草案已经运行良好,感谢 TypeScript 在原始值上停止递归,但是它可能会导致无法阅读的输出。一个简单的条件检查,只有在处理对象时我们才会深入,使我们的类型更加健壮,结果更易读:

type DeepPartial<T> = T extends object
  ? {
      [K in keyof T]?: DeepPartial<T[K]>;
    }
  : T;

例如,DeepPartial<Settings> 会得到以下输出:

type DeepPartialSettings = {
  mode?: "light" | "dark" | undefined;
  playbackSpeed?: number | undefined;
  subtitles?: {
    active?: boolean | undefined;
    color?: string | undefined;
  } | undefined;
};

这正是我们的目标所在。如果我们在 applySettings 中使用 DeepPartial<T>,我们会发现 applySettings 的实际使用有效,但 TypeScript 会提示我们另一个错误:

function applySettings(
  defaultSettings: Settings,
  userSettings: DeepPartial<Settings>
): Settings {
  return { ...defaultSettings, ...userSettings };
//       ^
// Type '{ mode: "light" | "dark"; playbackSpeed: number;
//   subtitles: { active?: boolean | undefined;
//   color?: string | undefined; }; }' is not assignable to type 'Settings'.
}

在这里,TypeScript 抱怨它无法将两个对象合并为 Settings 的结果,因为一些 DeepPartial 设置元素可能无法分配给 Settings。这是真的!对象合并使用解构也仅在第一级上有效,就像 Partial<T> 为我们所定义的那样。这意味着如果我们像之前那样调用 applySettings,我们将得到一个与 settings 完全不同的类型:

let settings = applySettings(defaults, { subtitles: { active: true } });

// results in

let settings = {
  mode: "dark",
  playbackSpeed: 1,
  subtitles: {
    active: true
  }
};

color都消失了!这是一个情况,TypeScript 的类型可能一开始不直观:为什么对象修改类型只深入一层?因为 JavaScript 也只深入一层!但最终,它们指出了您否则可能不会发现的错误。

为了避免这种情况,您需要递归地应用您的设置。自己实现这一功能可能会很麻烦,因此我们借助lodash和其merge函数来实现:

import { merge } from "lodash";

function applySettings(
  defaultSettings: Settings,
  userSettings: DeepPartial<Settings>
): Settings {
  return merge(defaultSettings, userSettings)
}

merge定义了其接口以生成两个对象的交集:

function merge<TObject, TSource>(
  object: TObject, source: TSource
): TObject & TSource {
  // ...
}

再次强调,这正是我们寻求的。SettingsDe⁠ep​Par⁠tia⁠l<Set⁠tin⁠gs>的交集也生成了两者的交集,这是由于类型的性质,再次是Settings

因此,我们最终得到了表达力强的类型,准确预期输出的结果,以及我们工具库中的另一个辅助类型。你可以类似地创建DeepReadonlyDeepRequired

8.3 重新映射类型

问题

构建类型为您提供了灵活、自我维护的类型,但编辑器提示还有很大的改进空间。

解决方案

使用Remap<T>DeepRemap<T>辅助类型来改善编辑器提示。

讨论

当您使用 TypeScript 的类型系统来构建新类型时,通过使用辅助类型、复杂条件类型,甚至简单的交集,您可能会得到难以解读的编辑器提示。

让我们看一看来自 Recipe 8.1 的OnlyRequired。该类型使用四个辅助类型和一个交集来构建一个新类型,在这个新类型中,第二个类型参数中提供的所有键都设置为必需,而其他所有键则设置为可选:

type OnlyRequired<T, K extends keyof T = keyof T> =
  Required<Pick<T, K>> & Partial<Omit<T, K>>;

这种类型编写方式使您对发生的情况有很好的理解。您可以根据辅助类型如何与彼此组合来读取功能。然而,当您实际在模型上使用这些类型时,您可能希望了解更多,而不仅仅是类型的实际构造:

type Person = {
  name: string;
  age: number;
  profession: string;
};

type NameRequired = OnlyRequired<Person, "name">;

如果您将鼠标悬停在NameRequired上,您会看到 TypeScript 提供了有关如何基于您提供的参数构造类型的信息,但是编辑器提示不会显示结果,最终类型是如何使用这些辅助类型构建的。您可以在图 8-1 中看到编辑器的反馈。

tscb 0801

图 8-1. 复杂类型的编辑器提示扩展非常浅;如果不了解底层类型及其功能,理解结果就变得困难。

要使最终结果看起来像实际类型并拼写出所有属性,我们必须使用一种简单而有效的类型称为Remap

type Remap<T> = {
  [K in keyof T]: T[K];
};

Remap<T>只是一个对象类型,遍历每个属性并将其映射到定义的值。没有修改,没有过滤,只是将输入放出。TypeScript 将打印出映射类型的每个属性,因此您看到的是实际类型,如图 8-2 所示。

tscb 0802

图 8-2。使用Remap<T>NameRequired的展示变得更加可读

太棒了!这已经成为 TypeScript 实用类型库中的一个基本功能。有些人称其为Debug,其他人称其为SimplifyRemap只是同一个工具和同一个效果的另一个名称:了解您的结果将会是什么样子。

与其他映射类型Partial<T>Readonly<T>Required<T>一样,Remap<T>也仅在第一级上工作。包含Subtitles类型的Settings等嵌套类型将被重新映射为相同的输出,并且编辑器反馈将是相同的:

type Subtitles = {
  active: boolean;
  color: string;
};

type Settings = {
  mode: "light" | "dark";
  playbackSpeed: number;
  subtitles: Subtitles;
};

但是,正如食谱 8.2 所示,我们可以创建一个递归变体来重新映射所有嵌套对象类型:

type DeepRemap<T> = T extends object
  ? {
      [K in keyof T]: DeepRemap<T[K]>;
    }
  : T;

DeepRemap<T>应用于Settings也将扩展Subtitles

type SettingsRemapped = DeepRemap<Settings>;

// results in

type SettingsRemapped = {
    mode: "light" | "dark";
    playbackSpeed: number;
    subtitles: {
        active: boolean;
        color: string;
    };
};

使用Remap主要是一种品味问题。有时您想了解实现,有时嵌套类型的简洁视图比扩展版本更可读。但在其他情况下,实际上您关心的是结果本身。在这些情况下,拥有一个可用的Remap<T>辅助类型肯定是有帮助的。

8.4 获取所有必需的键

问题

您希望创建一种类型,从对象中提取所有必需属性。

解决方案

创建一个映射辅助类型GetRequired<T>,根据其必需对应项的子类型检查来过滤键。

讨论

可选属性对类型兼容性有很大影响。一个简单的类型修饰符,问号,显著扩展了原始类型。它们允许我们定义可能存在的字段,但只有在进行额外检查时才能使用。

这意味着我们可以使我们的函数和接口与完全缺少某些属性的类型兼容:

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

function printPerson(person: Person): void {
  // ...
}

type Student = {
  name: string;
  semester: number;
};

const student: Student = {
  name: "Stefan",
  semester: 37,
};

printPerson(student); // all good!

我们看到agePerson中定义,但在Student中根本没有定义。由于它是可选的,这并不妨碍我们使用printPerson来处理Student类型的对象。兼容值集更广泛,因为我们可以使用完全省略age的类型对象。

TypeScript 通过将undefined附加到可选属性来解决了这个问题。这是“可能存在”的真实表示。

如果我们想要检查属性键是否是必需的或不是必需的,这个事实就很重要。让我们从做最基本的检查开始。我们有一个对象,并希望检查所有键是否都是必需的。我们使用辅助类型Required<T>,将所有属性修改为必需的。最简单的检查是查看对象类型(例如Name)是否是其Required<T>对应项的子集:

type Name = {
  name: string;
};

type Test = Name extends Required<Name> ? true : false;
// type Test = true

在这里,如果我们使用Required<T>将所有属性更改为requiredTest结果为true,因为我们仍然得到相同的类型。但是,如果我们引入一个可选属性,情况就会改变:

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

type Test = Person extends Required<Person> ? true : false;
// type Test = false

在这里,Test的结果为false,因为带有可选属性age的类型Person接受比Required<Person>更广泛的值集合,其中age需要被设置。与此检查相反,如果我们交换PersonRequired<Person>,我们可以看到更窄的类型Required<Person>实际上是Person的子集:

type Test = Required<Person> extends Person ? true : false;
// type Test = true

到目前为止,我们所检查的是整个对象是否具有所需的键。但实际上我们想要的是获取一个仅包含已设置为必需的属性键的对象。这意味着我们需要对每个属性键进行这种检查。需要在一组键上进行相同检查的需求是映射类型的一个很好的指标。

我们的下一步是创建一个映射类型,对每个属性进行子集检查,以查看结果值是否包括undefined

type RequiredPerson = {
  [K in keyof Person]: Person[K] extends Required<Person[K]> ? true : false;
};

/*
type RequiredPerson = {
 name: true;
 age?: true | undefined;
}
*/

这是一个很好的猜测,但给出的结果却不起作用。每个属性都解析为true,意味着子集检查仅针对没有undefined的值类型。这是因为Required<T>作用于对象,而不是基本类型。我们需要更健壮的结果,即检查Person[K]是否包含任何可为空的值。NonNullable<T>移除了undefinednull

type RequiredPerson = {
  [K in keyof Person]: Person[K] extends NonNullable<Person[K]> ? true : false;
};

/*
type RequiredPerson = {
 name: true;
 age?: false | undefined;
}
*/

这样做更好了,但还不是我们想要的。undefined又回来了,因为它被属性修饰符添加了。此外,属性仍然存在于类型中,而我们希望摆脱它。

我们需要做的是减少可能键的集合。因此,我们不是检查值,而是在映射键时对每个属性进行条件检查。我们检查Person[K]是否是Required<Person>[K]的子集,对更大的子集进行适当的检查。如果是这种情况,我们打印出键K;否则,我们使用never来删除属性(见 Recipe 5.2):

type RequiredPerson = {
  [K in keyof Person as Person[K] extends Required<Person>[K]
    ? K
    : never]: Person[K];
};

这给了我们想要的结果。现在我们用一个泛型类型参数替换Person,并完成了我们的辅助类型GetRequired<T>

type GetRequired<T> = {
  [K in keyof T as T[K] extends Required<T>[K]
    ? K
    : never]: T[K];
};

从这里开始,我们可以派生出像GetOptional<T>这样的变体。然而,检查某些内容是否可选并不像检查某些属性键是否必需那么容易,但我们可以使用GetRequired<T>keyof运算符来获取所有必需的属性键:

type RequiredKeys<T> = keyof GetRequired<T>;

然后,我们使用RequiredKeys<T>省略它们从我们的目标对象中:

type GetOptional<T> = Omit<T, RequiredKeys<T>>;

再次,多个辅助类型的组合产生了衍生的、自我维护的类型。

8.5 允许至少一个属性

问题

你有一个类型,你希望确保至少设置一个属性。

解决方案

创建一个Split<T>辅助类型,将对象拆分为单属性对象的并集。

讨论

你的应用程序在一个对象中存储一组 URL,例如视频格式,在这个对象中,每个键标识不同的格式:

type VideoFormatURLs = {
  format360p: URL;
  format480p: URL;
  format720p: URL;
  format1080p: URL;
};

你想创建一个能够加载任何视频格式 URL 的函数loadVideo,但需要至少加载一个 URL。

如果loadVideo接受类型为VideoFormatURLs的参数,你需要提供所有视频格式 URL:

function loadVideo(formats: VideoFormatURLs) {
  // tbd
}

loadVideo({
  format360p: new URL("..."),
  format480p: new URL("..."),
  format720p: new URL("..."),
  format1080p: new URL("..."),
});

但是有些视频可能不存在,因此实际上你要找的是所有可用类型的子集。Partial<VideoFormatURLs>正是给你这个的:

function loadVideo(formats: Partial<VideoFormatURLs>) {
  // tbd
}

loadVideo({
  format480p: new URL("..."),
  format720p: new URL("..."),
});

但由于所有键都是可选的,你还将允许空对象作为有效参数:

loadVideo({});

这导致了未定义行为。你希望至少有一个 URL,这样你就可以加载那个视频。

我们需要找到一个类型,表示我们希望至少有一个可用的视频格式:这种类型允许我们通过所有格式和其中一些格式,但也防止我们通过没有格式的情况。

让我们从“只有一个”情况开始。我们不是找到一种类型,而是创建一个联合类型,其中组合了只有一个属性设置的所有情况:

type AvailableVideoFormats =
  | {
      format360p: URL;
    }
  | {
      format480p: URL;
    }
  | {
      format720p: URL;
    }
  | {
      format1080p: URL;
    };

这使我们能够传递只有一个属性设置的对象。接下来,让我们添加具有两个属性设置的情况:

type AvailableVideoFormats =
  | {
      format360p: URL;
    }
  | {
      format480p: URL;
    }
  | {
      format720p: URL;
    }
  | {
      format1080p: URL;
    };

等等!那不是相同的类型吗?但这就是联合类型的工作方式。如果它们没有被区分(参见 Recipe 3.2),联合类型将允许位于原始集合交集中的值,如 Figure 8-3 所示。

tscb 0803

图 8-3。联合类型AvailableVideoFormats

每个联合成员定义了一组可能的值。交集描述了两种类型重叠的值。所有可能的组合都可以用这个联合来表示。

现在我们知道了类型,从原始类型中派生出来将是很棒的。我们希望将对象类型拆分为包含恰好一个属性的类型联合。

获取与VideoFormatURLs相关的联合类型的一种方法是使用keyof操作符:

type AvailableVideoFormats = keyof VideoFormatURLs;

这产生了"format360p" | "format480p" | "format720p" | "format1080p",这是键的联合。我们可以使用keyof操作符索引访问原始类型:

type AvailableVideoFormats = VideoFormatURLs[keyof VideoFormatURLs];

这产生了URL,它只是一种类型,但实际上它是值类型的一个联合。现在我们只需要找到一种方法,以获取表示实际对象类型并与每个属性键相关联的正确值。

再次阅读这句话:“与每个属性键相关”。这需要一个映射类型!我们可以通过所有VideoFormatURLs映射,将属性键映射到对象的右侧:

type AvailableVideoFormats = {
  [K in keyof VideoFormatURLs]: K;
};

/* yields
type AvailableVideoFormats = {
 format360p: "format360p";
 format480p: "format480p";
 format720p: "format720p";
 format1080p: "format1080p";
}; */

有了这个,我们可以再次使用索引访问映射类型,并获取每个元素的值类型。但我们不仅将键设置为右侧,而且还创建了另一个对象类型,将此字符串作为属性键,并将其映射到相应的值类型:

type AvailableVideoFormats = {
  [K in keyof VideoFormatURLs]: {
    [P in K]: VideoFormatURLs[P]
  };
};

/* yields
type AvailableVideoFormats = {
  format360p: {
    format360p: URL;
  };
  format480p: {
    format480p: URL;
  };
  format720p: {
    format720p: URL;
  };
  format1080p: {
    format1080p: URL;
  };
};

现在我们可以再次使用索引访问,从右侧获取每个值类型并形成一个联合:

type AvailableVideoFormats = {
  [K in keyof VideoFormatURLs]: {
    [P in K]: VideoFormatURLs[P]
  };
}[keyof VideoFormatURLs];

/* yields
type AvailableVideoFormats =
 | {
 format360p: URL;
 }
 | {
 format480p: URL;
 }
 | {
 format720p: URL;
 }
 | {
 format1080p: URL;
 };
*/

这正是我们一直在寻找的!作为下一步,我们将具体类型替换为泛型,并得到Split<T>辅助类型:

type Split<T> = {
  [K in keyof T]: {
    [P in K]: T[P];
  };
}[keyof T];

我们武器库中的另一个辅助类型。与loadVideo一起使用它,正好得到了我们一直期望的行为:

function loadVideo(formats: Split<VideoFormatURLs>) {
  // tbd
}

loadVideo({});
//        ^
// Argument of type '{}' is not assignable to parameter
// of type 'Split<VideoFormatURLs>'

loadVideo({
  format480p: new URL("..."),
}); // all good

Split<T> 是查看基本类型系统功能如何显著更改接口行为的好方法,以及如何使用一些简单的类型技术如映射类型、索引访问类型和属性键来获得一个小而强大的辅助类型。

8.6 允许确切一个和全部或无

问题

除了要求 至少一个 参数像 Recipe 8.5 中一样,您还希望提供用户提供 确切一个全部或无 的情景。

解决方案

创建 ExactlyOne<T>AllOrNone<T, K>。两者都依赖于 可选的 never 技术,结合 Split<T> 的衍生。

讨论

使用 Split<T> 自 Recipe 8.5,我们创建了一个很好的辅助类型,可以描述我们希望至少提供一个参数的场景。这是 Partial<T> 无法为我们提供的,但常规的联合类型可以。

从这个想法开始,我们可能也会遇到需要用户提供 确切一个 参数的场景,确保他们不会添加过多选项。

在这里可以使用的一种技术是可选的 never,我们在 Recipe 3.8 中学到了它。除了允许的所有属性外,您将不想允许的所有属性设置为可选,并将它们的值设置为 never。这意味着一旦您写下属性名,TypeScript 将要求您将其值设置为与 never 兼容的某些内容,但您无法这样做,因为 never 没有值。

将所有属性名称放在 排他或 关系中的联合类型是关键。我们得到了一个联合类型,其中每个属性都已经使用 Split<T>

type Split<T> = {
  [K in keyof T]: {
    [P in K]: T[P];
  };
}[keyof T];

我们所需做的就是与剩余的键交集,并将它们设置为可选的 never

type ExactlyOne<T> = {
  [K in keyof T]: {
    [P in K]: T[P];
  } &
    {
      [P in Exclude<keyof T, K>]?: never; // optional never
    };
}[keyof T];

有了这个,生成的类型更加详尽,但告诉我们要排除哪些属性:

type ExactlyOneVideoFormat = ({
    format360p: URL;
} & {
    format480p?: never;
    format720p?: never;
    format1080p?: never;
}) | ({
    format480p: URL;
} & {
    format360p?: never;
    format720p?: never;
    format1080p?: never;
}) | ({
    format720p: URL;
} & {
    format320p?: never;
    format480p?: never;
    format1080p?: never;
}) | ({
    format1080p: URL;
} & {
    format320p?: never;
    format480p?: never;
    format720p?: never;
});

并且它按预期工作:

function loadVideo(formats: ExactlyOne<VideoFormatURLs>) {
  // tbd
}

loadVideo({
  format360p: new URL("..."),
}); // works

loadVideo({
  format360p: new URL("..."),
  format1080p: new URL("..."),
});
// ^
// Argument of type '{ format360p: URL; format1080p: URL; }'
// is not assignable to parameter of type 'ExactlyOne<VideoFormatURLs>'.

ExactlyOne<T>Split<T> 如此相似,我们可以考虑扩展 Split<T>,以包括可选的 never 模式功能。

type Split<T, OptionalNever extends boolean = false> = {
  [K in keyof T]: {
    [P in K]: T[P];
  } &
    (OptionalNever extends false
      ? {}
      : {
          [P in Exclude<keyof T, K>]?: never;
        });
}[keyof T];

type ExactlyOne<T> = Split<T, true>;

我们添加了一个新的泛型类型参数 OptionalNever,默认为 false。然后,我们与一个条件类型相交,检查参数 OptionalNever 是否实际上为 false。如果是这样,我们与空对象相交(保留原始对象);否则,我们向对象添加可选的 never 部分。ExactlyOne<T> 被重构为 Split<T, true>,在这里我们激活了 OptionalNever 标志。

另一个与 Split<T>ExactlyOne<T> 非常相似的场景是提供所有参数或不提供任何参数。考虑将视频格式分为标准定义(SD:360p 和 480p)和高清晰度(HD:720p 和 1080p)。在您的应用程序中,您希望确保如果用户提供了 SD 格式,则应该提供所有可能的格式。单个 HD 格式是可以接受的。

这也是可选的永远技术的应用之处。我们定义了一个类型,要求所有选定的键或如果只提供一个则将它们设置为never

type AllOrNone<T, Keys extends keyof T> = (
  | {
      [K in Keys]-?: T[K]; // all available
    }
  | {
      [K in Keys]?: never; // or none
    }
);

如果您希望确保还提供了所有高清格式,请通过交集将其余部分添加到其中:

type AllOrNone<T, Keys extends keyof T> = (
  | {
      [K in Keys]-?: T[K];
    }
  | {
      [K in Keys]?: never;
    }
) & {
  [K in Exclude<keyof T, Keys>]: T[K] // the rest, as it was defined
}

或者,如果高清格式完全是可选的,则通过Partial<T>添加它们:

type AllOrNone<T, Keys extends keyof T> = (
  | {
      [K in Keys]-?: T[K];
    }
  | {
      [K in Keys]?: never;
    }
) & Partial<Omit<T, Keys>>; // the rest, but optional

但然后您会遇到与 Recipe 8.5 中相同的问题,您可以提供不包含任何格式的值。与Split<T>相交的全有或全无变体是我们的目标解决方案:

type AllOrNone<T, Keys extends keyof T> = (
  | {
      [K in Keys]-?: T[K];
    }
  | {
      [K in Keys]?: never;
    }
) & Split<T>;

它如预期般工作:

function loadVideo(
  formats: AllOrNone<VideoFormatURLs, "format360p" | "format480p">
) {
  // TBD
}

loadVideo({
  format360p: new URL("..."),
  format480p: new URL("..."),
}); // OK

loadVideo({
  format360p: new URL("..."),
  format480p: new URL("..."),
  format1080p: new URL("..."),
}); // OK

loadVideo({
  format1080p: new URL("..."),
}); // OK

loadVideo({
  format360p: new URL("..."),
  format1080p: new URL("..."),
});
// ^ Argument of type '{ format360p: URL; format1080p: URL; }' is
// not assignable to parameter of type
// '({ format360p: URL; format480p: URL; } & ... (abbreviated)

如果我们仔细查看AllOrNone的作用,我们可以轻松使用内置的辅助类型重写它:

type AllOrNone<T, Keys extends keyof T> = (
  | Required<Pick<T, Keys>>
  | Partial<Record<Keys, never>>
) &
  Split<T>;

这可能更易读,但更偏向于类型系统中的元编程要点。你有一组辅助类型,可以组合它们来创建新的辅助类型:几乎像是一种函数式编程语言,但针对值的集合,在类型系统中实现。

8.7 将联合类型转换为交集类型

问题

您的模型被定义为多个变体的联合类型。要从中派生其他类型,首先需要将联合类型转换为交集类型。

解决方案

创建一个UnionToIntersection<T>辅助类型,该类型使用逆变位。

讨论

在 Recipe 8.5 中,我们讨论了如何将模型类型分割成其变体的联合体。根据您的应用程序的工作方式,您可能希望从一开始就将模型定义为多个变体的联合类型:

type BasicVideoData = {
  // tbd
};

type Format320 = { urls: { format320p: URL } };
type Format480 = { urls: { format480p: URL } };
type Format720 = { urls: { format720p: URL } };
type Format1080 = { urls: { format1080p: URL } };

type Video = BasicVideoData & (Format320 | Format480 | Format720 | Format1080);

类型Video允许您定义多种格式,但要求至少定义一种:

const video1: Video = {
  // ...
  urls: {
    format320p: new URL("https://..."),
  },
}; // OK

const video2: Video = {
  // ...
  urls: {
    format320p: new URL("https://..."),
    format480p: new URL("https://..."),
  },
}; // OK

const video3: Video = {
  // ...
  urls: {
    format1080p: new URL("https://..."),
  },
}; // OK

然而,将它们放在一个联合体中也会带来一些副作用,例如当你需要所有可用的键时:

type FormatKeys = keyof Video["urls"];
// FormatKeys = never

// This is not what we want here!
function selectFormat(format: FormatKeys): void {
  // tbd.
}

你可能期望FormatKeys提供一个嵌套在urls中的所有键的联合类型。然而,联合类型的索引访问试图找到最低公共分母。在这种情况下,不存在这样的最低公共分母。要获得所有格式键的联合类型,你需要将所有键放在一个类型中:

type Video = BasicVideoData & {
  urls: {
    format320p: URL;
    format480p: URL;
    format720p: URL;
    format1080p: URL;
  };
};

type FormatKeys = keyof Video["urls"];
// type FormatKeys =
//   "format320p" | "format480p" | "format720p" | "format1080p";

创建类似这样的对象的一种方法是将联合类型修改为交集类型。

注意

在 Recipe 8.5 中,将数据建模为单一类型是正确的方式;而在这个配方中,我们看到将数据建模为联合类型更符合我们的喜好。事实上,关于如何定义您的模型并没有一个单一的答案。使用最适合您应用程序领域的表示方法,而且不会太过于阻碍您。重要的是能够根据需要派生其他类型。这样可以减少维护工作并允许您创建更加健壮的类型。在第十二章和特别是 Recipe 12.1 中,我们将介绍“低维护类型”的原则。

将联合类型转换为交集类型是 TypeScript 中一个特殊的任务,需要对类型系统内部工作原理有深入的了解。要学习所有这些概念,我们看看完成的类型,然后看看在幕后发生了什么:

type UnionToIntersection<T> =
  (T extends any ? (x: T) => any : never) extends
  (x: infer R) => any ? R : never;

这里有很多需要理解的内容:

  • 我们有两种条件类型。第一个似乎总是返回true分支,那我们为什么需要它呢?

  • 第一个条件类型将类型包装在函数参数中,第二个条件类型则再次解包它。为什么这是必要的?

  • 这两种条件类型如何将联合类型转换为交集类型?

让我们逐步分析UnionToIntersection<T>

UnionToIntersection<T>中的第一个条件中,我们将泛型类型参数用作裸类型

type UnionToIntersection<T> =
  (T extends any ? (x: T) => any : never) //...

这意味着我们检查T是否处于某种子类型条件中,而不将其包装在其他类型中:

type Naked<T> = T extends ...; // a naked type

type NotNaked<T> = { o: T } extends ...; // a non-naked type

在条件类型中,裸类型具有一定的特性。如果T是一个联合类型,它们会为联合的每个成员运行条件类型。因此,使用裸类型时,联合类型的条件变成了条件类型的联合

type WrapNaked<T> =  T extends any ? { o: T } : never;

type Foo = WrapNaked<string | number | boolean>;

// A naked type, so this equals to

type Foo =
  WrapNaked<string> | WrapNaked<number> | WrapNaked<boolean>;

// equals to

type Foo =
  string extends any ? { o: string } : never |
  number extends any ? { o: number } : never |
  boolean extends any ? { o: boolean } : never;

type Foo =
  { o: string } | { o: number } | { o: boolean };

与非裸版本相比:

type WrapNaked<T> = { o: T } extends any ? { o: T } : never;

type Foo = WrapNaked<string | number | boolean>;

// A non-naked type, so this equals to

type Foo =
  { o: string | number | boolean } extends any ?
  { o: string | number | boolean } : never;

type Foo = { o: string | number | boolean };

微妙之处,在复杂类型中显著不同!

在我们的示例中,我们使用裸类型,并询问它是否扩展了any(它总是这样的;any是允许一切的顶级类型):

type UnionToIntersection<T> =
  (T extends any ? (x: T) => any : never) //...

由于这个条件总是为真,我们将我们的泛型类型包装在一个函数中,其中T是函数参数的类型。但为什么我们要这样做呢?

这导致了第二个条件:

type UnionToIntersection<T> =
  (T extends any ? (x: T) => any : never) extends
  (x: infer R) => any ? R : never

由于第一个条件总是为真,意味着我们将我们的类型包装在一个函数类型中,另一个条件也总是为真。我们基本上是在检查我们刚刚创建的类型是否是其自身的子类型。但是,我们不是直接通过T,而是推断出一个新的类型R,并返回推断出的类型。

我们所做的是通过函数类型包装和解包类型T

通过函数参数执行此操作将新推断出的类型R置于逆变位置

那么逆变性意味着什么?逆变性的相反是协变性,与正常的子类型化预期相反:

declare let b: string;
declare let c: string | number;

c = b // OK

stringstring | number的子类型;string的所有元素都出现在string | number中,所以我们可以将b分配给cc仍然表现出我们最初的意图。这就是协变性。

这种情况却行不通:

type Fun<X> = (...args: X[]) => void;

declare let f: Fun<string>;
declare let g: Fun<string | number>;

g = f // this cannot be assigned

我们不能将f分配给g,因为那样我们还可以用一个数字来调用f!我们遗漏了g的一部分契约。这就是逆变性。

有趣的是逆变性实际上像交集一样工作:如果f接受string并且g接受string | number,那么被两者接受的类型是(string | number) & string,即string

当我们将类型放在条件类型的逆变位置时,TypeScript 将其创建为一个交集。这意味着由于我们从函数参数推断,TypeScript 知道我们必须满足完整的约定,从而创建联合的所有组成部分的交集。

基本上,联合到交集

让我们通过一下:

type UnionToIntersection<T> =
  (T extends any ? (x: T) => any : never) extends
  (x: infer R) => any ? R : never;

type Intersected = UnionToIntersection<Video["urls"]>;

// equals to

type Intersected = UnionToIntersection<
  { format320p: URL } |
  { format480p: URL } |
  { format720p: URL } |
  { format1080p: URL }
>;

我们有一个裸类型;这意味着我们可以对条件进行联合:

type Intersected =
  | UnionToIntersection<{ format320p: URL }>
  | UnionToIntersection<{ format480p: URL }>
  | UnionToIntersection<{ format720p: URL }>
  | UnionToIntersection<{ format1080p: URL }>;

让我们扩展 UnionToIntersection<T>

type Intersected =
  | ({ format320p: URL } extends any ?
      (x: { format320p: URL }) => any : never) extends
      (x: infer R) => any ? R : never
  | ({ format480p: URL } extends any ?
      (x: { format480p: URL }) => any : never) extends
      (x: infer R) => any ? R : never
  | ({ format720p: URL } extends any ?
      (x: { format720p: URL }) => any : never) extends
      (x: infer R) => any ? R : never
  | ({ format1080p: URL } extends any ?
     (x: { format1080p: URL }) => any : never) extends
      (x: infer R) => any ? R : never;

然后评估第一个条件:

type Intersected =
  | ((x: { format320p: URL }) => any) extends (x: infer R) => any ? R : never
  | ((x: { format480p: URL }) => any) extends (x: infer R) => any ? R : never
  | ((x: { format720p: URL }) => any) extends (x: infer R) => any ? R : never
  | ((x: { format1080p: URL }) => any) extends (x: infer R) => any ? R : never;

让我们评估第二个条件,我们推断 R

type Intersected =
  | { format320p: URL } | { format480p: URL }
  | { format720p: URL } | { format1080p: URL };

但等等!R 是从逆变位置推断出来的。我们必须做一个交集,否则会失去类型兼容性:

type Intersected =
  { format320p: URL } & { format480p: URL } &
  { format720p: URL } & { format1080p: URL };

这正是我们一直在寻找的!因此,应用到我们的原始示例中:

type FormatKeys = keyof UnionToIntersection<Video["urls"]>;

FormatKeys 现在是 "format320p" | "format480p" | "format720p" | "format1080p"#。每当我们向原始联合添加另一个格式时,FormatKeys 类型会自动更新。只需维护一次,随处使用。

8.8 使用 type-fest

问题

你非常喜欢你的辅助类型,以至于你想创建一个实用库以便轻松访问。

解决方案

Type-fest 很可能已经包含你所需的一切。

讨论

本章的整个目的是介绍几个有用的辅助类型,这些类型并不是 TypeScript 标准的一部分,但已被证明对许多场景非常灵活:单一目的的通用辅助类型,可以组合和组成,从而基于现有模型导出类型。你只需写一次你的模型,所有其他类型就会自动更新。这种从其他类型派生类型的低维护类型的理念是 TypeScript 的独特之处,被许多开发人员赞赏,他们创建复杂的应用程序或库。

你可能经常使用你的辅助类型,所以你开始将它们结合在一个实用库中以便轻松访问,但很可能已经有现有的库包含了你所需的一切。使用一组定义良好的辅助类型并不新鲜,市面上有很多可以提供本章中所见的所有东西的库。有时它们完全相同,只是名字不同;其他时候,它们可能是相似的想法但解决方式不同。大多数类型库基本上都覆盖了基础知识,但一个库,type-fest,不仅有用而且积极维护,文档完善且广泛使用。

Type-fest 有几个方面使其脱颖而出。首先,它有详尽的文档。它的文档不仅包括某个辅助类型的使用,还包括使用案例和场景,告诉你何时可能需要使用这个辅助类型。一个例子是 Integer<T>,它确保你提供的数字没有任何小数。

这是一个几乎进入 TypeScript Cookbook 的实用类型,但我发现提供来自 type-fest 的片段告诉你关于这种类型的所有需要知道的信息:

/**
A `number` that is an integer.
You can't pass a `bigint` as they are already guaranteed to be integers.
Use-case: Validating and documenting parameters.

@example

import type {Integer} from 'type-fest';

声明一个函数setYear<T extends number>(length: Integer<T>): void;

@see NegativeInteger
@see NonNegativeInteger
@category Numeric
*/
// `${bigint}` is a type that matches a valid bigint
// literal without the `n` (ex. 1, 0b1, 0o1, 0x1)
// Because T is a number and not a string we can effectively use
// this to filter out any numbers containing decimal points

export type Integer<T extends number> = `${T}` extends `${bigint}` ? T : never;

文件的其余部分涉及负整数、非负整数、浮点数等。如果你想进一步了解类型的构建方式,这是一个真正的宝藏信息。

第二,type-fest处理边缘情况。在 Recipe 8.2 中,我们学习了递归类型并定义了DeepPartial<T>。它的type-fest对应物,PartialDeep<T>,则更为广泛:

export type PartialDeep<T, Opts extends PartialDeepOptions = {}> =
  T extends BuiltIns
  ? T
  : T extends Map<infer KeyType, infer ValueType>
    ? PartialMapDeep<KeyType, ValueType, Opts>
    : T extends Set<infer ItemType>
      ? PartialSetDeep<ItemType, Opts>
      : T extends ReadonlyMap<infer KeyType, infer ValueType>
        ? PartialReadonlyMapDeep<KeyType, ValueType, Opts>
        : T extends ReadonlySet<infer ItemType>
          ? PartialReadonlySetDeep<ItemType, Opts>
          : T extends ((...arguments: any[]) => unknown)
            ? T | undefined
            : T extends object
              ? T extends ReadonlyArray<infer ItemType>
                ? Opts['recurseIntoArrays'] extends true
                  ? ItemType[] extends T
                    ? readonly ItemType[] extends T
                      ? ReadonlyArray<PartialDeep<ItemType | undefined, Opts>>
                      : Array<PartialDeep<ItemType | undefined, Opts>>
                    : PartialObjectDeep<T, Opts>
                  : T
                : PartialObjectDeep<T, Opts>
              : unknown;

/**
Same as `PartialDeep`, but accepts only `Map`s and as inputs.
Internal helper for `PartialDeep`.
*/
type PartialMapDeep<KeyType, ValueType, Options extends PartialDeepOptions> =
  {} & Map<PartialDeep<KeyType, Options>, PartialDeep<ValueType, Options>>;

/**
Same as `PartialDeep`, but accepts only `Set`s as inputs.
Internal helper for `PartialDeep`.
*/
type PartialSetDeep<T, Options extends PartialDeepOptions> =
  {} & Set<PartialDeep<T, Options>>;

/**
Same as `PartialDeep`, but accepts only `ReadonlyMap`s as inputs.
Internal helper for `PartialDeep`.
*/
type PartialReadonlyMapDeep<
  KeyType, ValueType,
  Options extends PartialDeepOptions
> = {} & ReadonlyMap<
    PartialDeep<KeyType, Options>,
    PartialDeep<ValueType, Options>
  >;

/**
Same as `PartialDeep`, but accepts only `ReadonlySet`s as inputs.
Internal helper for `PartialDeep`.
*/
type PartialReadonlySetDeep<T, Options extends PartialDeepOptions> =
  {} & ReadonlySet<PartialDeep<T, Options>>;

/**
Same as `PartialDeep`, but accepts only `object`s as inputs.
Internal helper for `PartialDeep`.
*/
type PartialObjectDeep<
  ObjectType extends object,
  Options extends PartialDeepOptions
> = {
  [KeyType in keyof ObjectType]?: PartialDeep<ObjectType[KeyType], Options>
};

不需要完整了解这个实现,但它应该给你一个关于它们为某些实用类型提供了多么坚固的实现的概念。

注意

PartialDeep<T>非常广泛并处理所有可能的边缘情况,但也因为对 TypeScript 类型检查器而言过于复杂和难以理解。根据你的使用情况,也许 Recipe 8.2 中更简单的版本才是你需要的。

第三,他们不是为了添加而添加帮助类型。他们的Readme文件列出了已拒绝类型及其背后的原因:要么使用案例有限,要么存在更好的替代方案。就像所有东西一样,他们对自己的选择做了非常非常好的文档记录。

第四,type-fest教育关于现有的帮助类型。帮助类型在 TypeScript 中一直存在,但过去几乎没有被文档化。多年前,我的博客试图成为内置帮助类型的资源,直到官方文档增加了一个实用类型章节。实用类型不是你只需使用 TypeScript 就能轻松掌握的东西。你需要意识到它们的存在,并需要深入阅读。type-fest有一个专门介绍内置类型的部分,包含示例和用例。

最后但并非最不重要的是,它被可靠的开源开发者广泛采用并开发。它的创造者Sindre Sorhus已经为开源项目工作了几十年,并拥有许多出色的项目记录。type-fest只是他的又一杰作。很可能你的很多工作都依赖于他的工作。

通过type-fest,你可以获得另一个可以添加到项目中的帮助类型资源。你可以自行决定是保持一小组帮助类型还是依赖社区的实现。

第九章:标准库和外部类型定义

TypeScript 的首席架构师 Anders Hejlsberg 曾说过,他设想“TypeScript 是 JavaScript 的瑞士”,这意味着它不偏爱或努力与单一框架兼容,而是试图迎合所有 JavaScript 框架和变体。过去,TypeScript 曾致力于实现装饰器,说服 Google 不再推广带有装饰器的 JavaScript 方言AtScript,后者是 TypeScript 加上装饰器。TypeScript 的装饰器实现也作为相应的ECMAScript 装饰器提案的模板。TypeScript 还理解 JSX 语法扩展,允许像 React 或 Preact 这样的框架无限制地使用 TypeScript。

但即使 TypeScript 试图迎合所有 JavaScript 开发者,并为众多框架整合新的有用特性做出巨大努力,仍有一些它无法或不会做到的事情。也许因为某个特性太小众,或者因为一个决策会对太多开发者产生巨大影响。

这就是为什么 TypeScript 被设计为默认可扩展的原因。像命名空间、模块和接口这样的许多 TypeScript 特性允许声明合并,这使你可以根据自己的喜好添加类型定义。

在本章中,我们将看到 TypeScript 如何处理标准 JavaScript 功能,如模块、数组和对象。我们将看到它们的一些限制,分析其背后的原因,并提供合理的解决方法。你将看到 TypeScript 被设计为对各种 JavaScript 变体非常灵活,从合理的默认值开始,并在需要时提供扩展的机会。

9.1 使用 Object.keys 迭代对象

问题

当你尝试通过迭代其键来访问对象属性时,TypeScript 会向你抛出红色波浪线,告诉你"‘string’不能用于索引类型。”

解决方案

使用for-in循环而不是Object.keys,并使用泛型类型参数锁定你的类型。

讨论

TypeScript 中一个引人注目的令人头疼的问题是尝试通过迭代其键来访问对象属性。这种模式在 JavaScript 中非常常见,然而 TypeScript 似乎竭尽全力阻止你使用它。我们使用这样一行简单的代码来迭代对象的属性:

Object.keys(person).map(k => person[k])

这导致 TypeScript 向你抛出红色波浪线,开发者翻桌:“元素隐式具有'any'类型,因为类型为'string'的表达式不能用于索引类型'Person'。”在这种情况下,经验丰富的 JavaScript 开发者感觉 TypeScript 在与他们作对。但就像 TypeScript 的所有决策一样,TypeScript 这样做有很好的理由。

让我们找出原因。看看这个函数:

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

function printPerson(p: Person) {
  Object.keys(p).forEach((k) => {
    console.log(k, p[k]);
//                ^
// Element implicitly has an 'any' type because expression
// of type 'string' can't be used to index type 'Person'.
  });
}

我们只想通过访问其键来打印Person的字段。TypeScript 不允许这样做。Object.keys(p) 返回一个string[],这对于访问非常明确定义的对象结构Person来说太宽泛了。

但为什么会这样?难道我们只能访问可用的键不是显而易见的吗?这正是使用Object.keys的整个目的!是的,但我们也可以传递Person的子类型的对象,这些对象可能具有比在Person中定义的更多的属性:

const me = {
  name: "Stefan",
  age: 40,
  website: "https://fettblog.eu",
};

printPerson(me); // All good!

printPerson 仍然应该正确工作。它打印更多的属性,但不会中断。它仍然是p的键,因此应该可以访问每个属性。但是如果你不仅仅访问p呢?

假设Object.keys 给你(keyof Person)[]。你可以轻松地写出像这样的东西:

function printPerson(p: Person) {
  const you: Person = {
    name: "Reader",
    age: NaN,
  };

  Object.keys(p).forEach((k) => {
    console.log(k, you[k]);
  });
}

const me = {
  name: "Stefan",
  age: 40,
  website: "https://fettblog.eu",
};

printPerson(me);

如果 Object.keys(p) 返回一个keyof Person[] 类型的数组,你将能够访问其他Person对象。这可能不会累积。在我们的例子中,我们只是打印未定义的内容。但是如果你尝试对这些值做些什么,这将在运行时出错。

TypeScript 阻止你像这样的场景。虽然我们可能认为Object.keyskeyof Person,但实际上它可能会更多。

缓解这个问题的一种方法是使用类型保护:

function isKey<T>(x: T, k: PropertyKey): k is keyof T {
  return k in x;
}

function printPerson(p: Person) {
  Object.keys(p).forEach((k) => {
    if (isKey(p, k)) console.log(k, p[k]); // All fine!
  });
}

但这增加了一个本不应该存在的额外步骤。

还有另一种迭代对象的方法,使用for-in循环:

function printPerson(p: Person) {
  for (let k in p) {
    console.log(k, p[k]);
//                 ^
// Element implicitly has an 'any' type because expression
// of type 'string' can't be used to index type 'Person'.
  }
}

TypeScript 将因为同样的原因抛出同样的错误,因为你仍然可以做像这样的事情:

function printPerson(p: Person) {
  const you: Person = {
    name: "Reader",
    age: NaN,
  };

  for (let k in p) {
    console.log(k, you[k]);
  }
}

const me = {
  name: "Stefan",
  age: 40,
  website: "https://fettblog.eu",
};

printPerson(me);

但它将在运行时中断。但是,像这样写会给你比Object.keys版本更多的优势。如果你添加一个泛型,TypeScript 在这种情况下可以更加精确:

function printPerson<T extends Person>(p: T) {
  for (let k in p) {
    console.log(k, p[k]); // This works
  }
}

而不是要求pPerson(因此与所有Person的子类型兼容),我们添加一个新的泛型类型参数T,它是Person的子类型。这意味着所有兼容该函数签名的类型仍然兼容,但一旦我们使用p,我们处理的是一个显式的子类型,而不是更广泛的超类型Person

我们用T替换了与Person兼容的东西,但 TypeScript 知道它足够具体,以防止错误。

前面的代码有效。k的类型是keyof T。这就是为什么我们可以访问p,它的类型是T。而这种技术仍然防止我们访问缺少特定属性的类型:

function printPerson<T extends Person>(p: T) {
  const you: Person = {
    name: "Reader",
    age: NaN,
  };
  for (let k in p) {
    console.log(k, you[k]);
//                 ^
//  Type 'Extract<keyof T, string>' cannot be used to index type 'Person'
  }
}

我们无法使用keyof T访问Person。它们可能不同。但由于TPerson的子类型,如果我们知道确切的属性名称,我们仍然可以分配属性。

p.age = you.age

这正是我们想要的。

TypeScript 在这里对其类型非常保守,这可能起初看起来有些奇怪,但它帮助你在你不会考虑到的情况下解决问题。我猜这是 JavaScript 开发人员通常会对编译器尖叫并认为他们在“与之战斗”的部分,但也许 TypeScript 在你不知情的情况下拯救了你。对于这种让人烦恼的情况,TypeScript 至少给了你解决方法。

9.2 明确通过类型断言和 unknown 强调不安全操作

问题

通过 JSON 操作解析任意数据可能会出错,如果数据不正确的话。TypeScript 的默认设置不提供这些不安全操作的任何保障。

解决方案

通过使用类型断言而不是类型注释明确突出不安全操作,并确保它们通过对 unknown 的原始类型进行修补来实施。

讨论

在 Recipe 3.9 中,我们讨论了如何有效地使用类型断言。类型断言是对类型系统的显式调用,以表明某些类型应该是不同的,并基于一些保护措施(例如,不将 number 实际上视为 string)使 TypeScript 将这个特定值视为新类型。

由于 TypeScript 强大且广泛的类型系统,有时不可避免地需要类型断言。有时甚至是我们想要的,就像在 Recipe 3.9 中展示的那样,我们使用 fetch API 从后端获取 JSON 数据。一种方法是调用 fetch 并将结果分配给注释类型:

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

const ppl: Person[] = await fetch("/api/people").then((res) => res.json());

res.json() 的结果是 any,^(1) 且一切都可以通过类型注释更改为任何其他类型。不能保证结果实际上是 Person[]

另一种方法是使用类型断言而不是类型注释:

const ppl = await fetch("/api/people").then((res) => res.json()) as Person[];

对于类型系统而言,这是相同的事情,但我们可以轻松地扫描可能存在问题的情况。如果我们不对传入的值进行类型验证(例如使用 Zod 可参考 Recipe 12.5),那么在此处使用类型断言是突出不安全操作的有效方法。

在类型系统中,不安全 操作是指我们告诉类型系统我们期望值是某种类型,但是我们没有任何来自类型系统本身的保证,它实际上会成为真实情况。这在我们应用程序的边界处经常发生,我们从某个地方加载数据、处理用户输入或使用内置方法解析数据时。

通过使用特定的关键字来突出显示不安全操作,这些关键字表明类型系统中的显式类型更改。类型断言 (as)、类型预测 (is) 或断言签名 (asserts) 帮助我们找到这些情况。在某些情况下,TypeScript 甚至要求我们遵守其类型视图或根据我们的情况显式更改规则。但并非总是如此。

当我们从某个后端获取数据时,标注类型和写入类型断言一样容易。如果我们不强迫自己使用正确的技术,这些事情可能被忽视。

但我们可以借助 TypeScript 帮助我们做正确的事情。问题出在对 res.json() 的调用,它来自 lib.dom.d.ts 中的 Body 接口:

interface Body {
  readonly body: ReadableStream<Uint8Array> | null;
  readonly bodyUsed: boolean;
  arrayBuffer(): Promise<ArrayBuffer>;
  blob(): Promise<Blob>;
  formData(): Promise<FormData>;
  json(): Promise<any>;
  text(): Promise<string>;
}

json() 调用返回一个 Promise<any>,而 any 是 TypeScript 中一种松散的类型,TypeScript 在这种情况下完全忽略类型检查。我们需要 any 的谨慎兄弟 unknown。由于声明合并,我们可以重写 Body 类型定义,并将 json() 定义得更为严格:

interface Body {
  json(): Promise<unknown>;
}

当我们进行类型注释时,TypeScript 会警告我们不能将 unknown 分配给 Person[]

const ppl: Person[] = await fetch("/api/people").then((res) => res.json());
//    ^
// Type 'unknown' is not assignable to type 'Person[]'.ts(2322)

但是如果我们进行类型断言,TypeScript 仍然会欣然接受:

const ppl = await fetch("/api/people").then((res) => res.json()) as Person[];

有了这个,我们可以强制 TypeScript 突出显示不安全的操作。^(2)

9.3 使用 defineProperty 进行操作

问题

您可以使用 Object.defineProperty 动态定义属性,但 TypeScript 不会检测到更改。

解决方案

创建一个包装函数,并使用断言签名来更改对象的类型。

讨论

在 JavaScript 中,您可以使用 Ob⁠je⁠ct.de⁠fi⁠ne​Pr⁠op⁠er⁠ty 动态定义对象属性。如果要求属性为只读,则此方法非常有用。想象一个存储对象,它具有不应被覆盖的最大值:

const storage = {
  currentValue: 0
};

Object.defineProperty(storage, 'maxValue', {
  value: 9001,
  writable: false
});

console.log(storage.maxValue); // 9001

storage.maxValue = 2;

console.log(storage.maxValue); // still 9001

defineProperty 和属性描述符非常复杂。它们允许您以通常保留给内置对象的方式处理属性,因此在较大的代码库中非常常见。TypeScript 在处理 defineProperty 时存在问题:

const storage = {
  currentValue: 0
};

Object.defineProperty(storage, 'maxValue', {
  value: 9001,
  writable: false
});

console.log(storage.maxValue);
//                  ^
// Property 'maxValue' does not exist on type '{ currentValue: number; }'.

如果我们不明确断言为新类型,那么 maxValue 将不会附加到 storage 的类型中。然而,对于简单的用例,我们可以使用断言签名来帮助自己。

注意

虽然 TypeScript 在使用 Object.defineProperty 时可能不支持对象更改,但团队未来可能会为这类情况添加类型或特殊行为。例如,使用 in 关键字检查对象是否具有某个特定属性多年来未影响类型。这在 2022 年随着 TypeScript 4.9 发生了改变。

想象一个 assertIsNumber 函数,您可以确保某个值是 number 类型。否则,它会抛出一个错误。这类似于 Node.js 中的 assert 函数:

function assertIsNumber(val: any) {
  if (typeof val !== "number") {
    throw new AssertionError("Not a number!");
  }
}

function multiply(x, y) {
  assertIsNumber(x);
  assertIsNumber(y);
  // at this point I'm sure x and y are numbers
  // if one assert condition is not true, this position
  // is never reached
  return x * y;
}

为了符合这种行为,我们可以添加一个断言签名,告诉 TypeScript 我们在此函数后对类型有更多了解:

function assertIsNumber(val: any) : asserts val is number
  if (typeof val !== "number") {
    throw new AssertionError("Not a number!");
  }
}

这与类型预测器(参见 Recipe 3.5)非常类似,但没有像 ifswitch 这样基于条件的控制流:

function multiply(x, y) {
  assertIsNumber(x);
  assertIsNumber(y);
  // Now also TypeScript knows that both x and y are numbers
  return x * y;
}

如果您仔细观察它,您会发现这些断言签名可以 即时更改参数或变量的类型。这正是 Object.defineProperty 所做的。

下面的帮助函数并不力求完全准确或完整。它可能存在错误,可能无法处理 defineProperty 规范的每个边缘情况。但它将为我们提供基本功能。首先,我们定义一个名为 de⁠fin⁠e​Pr⁠ope⁠rty 的新函数,用作 Object.defineProperty 的包装函数:

function defineProperty<
  Obj extends object,
  Key extends PropertyKey,
  PDesc extends PropertyDescriptor>
  (obj: Obj, prop: Key, val: PDesc) {
  Object.defineProperty(obj, prop, val);
}

我们使用三种泛型:

  • 我们想要修改的对象,类型为 Obj,是 object 的子类型。

  • Key类型,是PropertyKey的子类型(内置):string | number | ​symbol

  • PDescPropertyDescriptor的子类型(内置)。这允许我们定义带有所有特性的属性(可写性、可枚举性、可重配置性)。

我们使用泛型,因为 TypeScript 可以将它们缩小到非常具体的单元类型。例如,PropertyKey 就是所有数字、字符串和符号。但如果我们使用Key extends PropertyKey,我们可以精确定位prop为例如类型"maxValue"。如果我们想通过添加更多属性来改变原始类型,这是很有帮助的。

Object.defineProperty函数要么改变对象,要么在出现问题时抛出错误。这正是断言函数所做的事情。因此,我们的自定义辅助函数defineProperty也是如此。

让我们添加一个断言签名。一旦defineProperty成功执行,我们的对象就有了另一个属性。我们正在创建一些辅助类型来做这件事。首先是签名:

function defineProperty<
  Obj extends object,
  Key extends PropertyKey,
  PDesc extends PropertyDescriptor>
   (obj: Obj, prop: Key, val: PDesc):
     asserts obj is Obj & DefineProperty<Key, PDesc> {
  Object.defineProperty(obj, prop, val);
}

然后,obj的类型是Obj(通过泛型缩小)和我们新定义的属性。

这是DefineProperty辅助类型:

type DefineProperty<
  Prop extends PropertyKey,
  Desc extends PropertyDescriptor> =
    Desc extends { writable: any, set(val: any): any } ? never :
    Desc extends { writable: any, get(): any } ? never :
    Desc extends { writable: false } ? Readonly<InferValue<Prop, Desc>> :
    Desc extends { writable: true } ? InferValue<Prop, Desc> :
    Readonly<InferValue<Prop, Desc>>;

首先,我们处理PropertyDescriptorwritable属性。这是一组定义原始属性描述符如何工作的边界情况和条件:

  • 如果我们设置了writable和任何属性访问器(getset),我们失败了。never告诉我们发生了错误。

  • 如果我们将writable设置为false,该属性是只读的。我们推迟到InferValue辅助类型。

  • 如果我们将writable设置为true,该属性就不是只读的。我们同样推迟处理。

  • 最后的默认情况与writable: false相同,因此是Readonly<InferValue<Prop, Desc>>。(Readonly<T>是内置的。)

这是处理设置value属性的InferValue辅助类型:

type InferValue<Prop extends PropertyKey, Desc> =
  Desc extends { get(): any, value: any } ? never :
  Desc extends { value: infer T } ? Record<Prop, T> :
  Desc extends { get(): infer T } ? Record<Prop, T> : never;

再次一组条件:

  • 我们有一个 getter 和一个已设置的值吗?Object.defineProperty会抛出错误,所以never

  • 如果我们设置了一个值,让我们推断出这个值的类型,并创建一个带有我们定义的属性键和值类型的对象。

  • 或者我们推断一个 getter 的返回类型。

  • 我们还有其他遗漏的吗?TypeScript 不会让我们像它变成never那样处理对象。

有很多辅助类型,但大约 20 行代码可以完美解决:

type InferValue<Prop extends PropertyKey, Desc> =
  Desc extends { get(): any, value: any } ? never :
  Desc extends { value: infer T } ? Record<Prop, T> :
  Desc extends { get(): infer T } ? Record<Prop, T> : never;

type DefineProperty<
  Prop extends PropertyKey,
  Desc extends PropertyDescriptor> =
    Desc extends { writable: any, set(val: any): any } ? never :
    Desc extends { writable: any, get(): any } ? never :
    Desc extends { writable: false } ? Readonly<InferValue<Prop, Desc>> :
    Desc extends { writable: true } ? InferValue<Prop, Desc> :
    Readonly<InferValue<Prop, Desc>>

function defineProperty<
  Obj extends object,
  Key extends PropertyKey,
  PDesc extends PropertyDescriptor>
  (obj: Obj, prop: Key, val: PDesc):
    asserts  obj is Obj & DefineProperty<Key, PDesc> {
  Object.defineProperty(obj, prop, val)
}

让我们看看 TypeScript 如何处理我们的更改:

const storage = {
  currentValue: 0
};

defineProperty(storage, 'maxValue', {
  writable: false, value: 9001
});

storage.maxValue; // it's a number
storage.maxValue = 2; // Error! It's read-only

const storageName = 'My Storage';
defineProperty(storage, 'name', {
  get() {
    return storageName
  }
});

storage.name; // it's a string!

// it's not possible to assign a value and a getter
defineProperty(storage, 'broken', {
  get() {
    return storageName
  },
  value: 4000
});

// storage is never because we have a malicious
// property descriptor
storage;

虽然这可能不涵盖所有内容,但已经完成了简单属性定义的大部分工作。

9.4 扩展类型 Array.prototype.includes

问题

TypeScript 将无法在非常窄的元组或数组中查找广泛类型(如stringnumber)的元素。

解决方案

使用类型谓词创建泛型辅助函数,从而改变类型参数之间的关系。

讨论

我们创建了一个名为actions的数组,其中包含我们要执行的一组操作的字符串格式。这个actions数组的结果类型是string[]

execute 函数以任何字符串作为参数。我们检查这是否是有效的操作,如果是,则执行某些操作:

// actions: string[]
const actions = ["CREATE", "READ", "UPDATE", "DELETE"];

function execute(action: string) {
  if (actions.includes(action)) {
    // do something with action
  }
}

如果我们想将 string[] 缩小为更具体的子集,这就变得有些棘手了。通过 as const 添加 const 上下文,我们可以将 actions 缩小为 readonly ["CREATE", "READ", "UPDATE", "DELETE"] 类型。

如果我们想要做详尽检查以确保我们为所有可用的操作都有案例,这非常方便。然而,actions.includes 却不认同我们:

// Adding const context
// actions: readonly ["CREATE", "READ", "UPDATE", "DELETE"]
const actions = ["CREATE", "READ", "UPDATE", "DELETE"] as const;

function execute(action: string) {
  if (actions.includes(action)) {
//                     ^
// Argument of type 'string' is not assignable to parameter of type
// '"CREATE" | "READ" | "UPDATE" | "DELETE"'.(2345)
  }
}

为什么会这样呢?让我们看一下 Array<T>ReadonlyArray<T> 的类型定义(由于 const 上下文,我们使用后者):

interface Array<T> {
  /**
 * Determines whether an array includes a certain element,
 * returning true or false as appropriate.
 * @param searchElement The element to search for.
 * @param fromIndex The position in this array at which
 *   to begin searching for searchElement.
 */
  includes(searchElement: T, fromIndex?: number): boolean;
}

interface ReadonlyArray<T> {
  /**
 * Determines whether an array includes a certain element,
 * returning true or false as appropriate.
 * @param searchElement The element to search for.
 * @param fromIndex The position in this array at which
 *   to begin searching for searchElement.
 */
  includes(searchElement: T, fromIndex?: number): boolean;
}

我们想要搜索的元素 (searchElement) 需要与数组本身的类型相同!因此,如果我们有 Array<string>(或 string[]Re⁠ad⁠on⁠ly​Ar⁠ra⁠y<s⁠tr⁠in⁠g>),我们只能搜索字符串。在我们的情况下,这意味着 action 需要是 "CREATE" | "READ" | "UPDATE" | "DELETE" 类型。

突然间,我们的程序变得毫无意义。如果类型已经告诉我们它只能是四个字符串之一,为什么还要搜索某些内容呢?如果我们将 action 类型更改为 "CREATE" | "READ" | "UPDATE" | "DELETE"actions.includes 就变得无用了。如果我们不改变它,TypeScript 将向我们抛出一个错误,这是理所当然的!

问题之一是,TypeScript 缺乏检查逆变类型的可能性,例如上界泛型。我们可以使用像 extends 这样的构造告诉一个类型应该是 T 类型的 子集,但我们无法检查一个类型是否是 T 类型的 超集。至少目前还不能!

那么我们可以做些什么呢?

选项 1:重新声明 ReadonlyArray

我们可以改变 ReadonlyArrayincludes 的行为方式。由于声明合并,我们可以添加对于 Re⁠ad⁠on⁠ly​Ar⁠ray 的自定义定义,这些定义在参数上更宽松,在结果上更具体,就像这样:

interface ReadonlyArray<T> {
  includes(searchElement: any, fromIndex?: number): searchElement is T;
}

允许传递更广泛的 searchElement 值集合(字面上的任何值!),如果条件成立,我们通过 类型预测 告诉 TypeScript se⁠ar⁠ch​Ele⁠men⁠t is⁠ T(我们正在寻找的子集)。

结果表明,这非常有效:

const actions = ["CREATE", "READ", "UPDATE", "DELETE"] as const;

function execute(action: string) {
  if(actions.includes(action)) {
    // action: "CREATE" | "READ" | "UPDATE" | "DELETE"
  }
}

不过,这里有一个问题。虽然解决方案可以运行,但它假设了正确的内容和需要检查的内容。如果将 action 更改为 number,TypeScript 通常会抛出一个错误,指出不能搜索这种类型。actions 只包含 string,为什么还要查找 number?这是一个你想要捕捉的错误:

// type number has no relation to actions at all
function execute(action: number) {
  if(actions.includes(action)) {
    // do something
  }
}

通过对 ReadonlyArray 的修改,我们失去了这个检查,因为 searchElementany。虽然 action.includes 的功能仍然按预期工作,但一旦我们在路途中更改函数签名,我们可能就看不到正确的 问题 了。

而且更重要的是,我们改变了内置类型的行为。这可能会影响到其他地方的类型检查,并可能在长期运行中造成问题!

提示

如果你通过改变标准库的行为来进行 类型补丁,确保这是在模块范围内进行,而不是全局的。

还有另一种方法。

选项 2:带有类型断言的辅助函数

正如最初所述, TypeScript 缺乏检查一个值是否属于泛型参数的 超集 的可能性之一。通过一个辅助函数,我们可以改变这种关系:

function includes<T extends U, U>(coll: ReadonlyArray<T>, el: U): el is T {
  return coll.includes(el as T);
}

includes 函数以 ReadonlyArray<T> 作为参数,并搜索类型为 U 的元素。通过我们的泛型边界检查 T extends U,这意味着 UT 的超集(或者 TU 的子集)。如果方法返回 true,我们可以肯定 el 是更窄类型 U

唯一需要让实现起作用的事情是在将 el 传递给 Array.prototype.includes 时进行一点类型断言。原始问题仍然存在!虽然类型断言 el as T 是可以的,因为我们已经在函数签名中检查可能的问题。

这意味着一旦我们将例如 action 更改为 number,我们就能在整个代码中获得正确的错误:

function execute(action: number) {
  if(includes(actions, action)) {
//            ^
// Argument of type 'readonly ["CREATE", "READ", "UPDATE", "DELETE"]'
// is not assignable to parameter of type 'readonly number[]'.
  }
}

这就是我们想要的行为。一个很好的点是 TypeScript 希望我们改变数组,而不是我们正在查找的元素。这是由泛型类型参数之间的关系所决定的。

提示

如果你遇到与 Array.prototype.indexOf 类似的问题,同样的解决方案也适用。

TypeScript 的目标是正确处理所有标准 JavaScript 功能,但有时你必须做出权衡。这种情况需要权衡:你是允许比预期更宽松的参数列表,还是对你已经应该了解更多的类型抛出错误?

类型断言、声明合并和其他工具帮助我们在类型系统不能帮助我们的情况下解决问题。直到它变得比以前更好,通过允许我们在类型空间中进一步移动。

9.5 过滤 Nullish 值

问题

你希望使用 Boolean 构造函数从数组中过滤掉 nullish 值,但 TypeScript 仍然生成相同的类型,包括 nullundefined

解决方案

使用声明合并重载 Arrayfilter 方法。

讨论

有时你有可能包含 nullish 值(undefinednull)的集合:

// const array: (number | null | undefined)[]
const array = [1, 2, 3, undefined, 4, null];

继续工作时,你希望从集合中删除这些 nullish 值。通常可以使用 Arrayfilter 方法来完成,也许通过检查值的 真实性nullundefined假值,因此它们被过滤掉:

const filtered = array.filter((val) => !!val);

检查值的真实性的一种便捷方法是将其传递给 Boolean 构造函数。这是简短、直接和非常优雅的阅读方式:

// const array: (number | null | undefined)[]
const filtered = array.filter(Boolean);

但遗憾的是,它并没有改变我们的类型。我们仍然有 nullundefined 作为过滤后数组的可能类型。

通过打开 Array 接口并为 filter 添加另一个声明,我们可以将这种特殊情况作为一种重载添加进去:

interface Array<T> {
  filter(predicate: BooleanConstructor): NonNullable<T>[]
}

interface ReadonlyArray<T> {
  filter(predicate: BooleanConstructor): NonNullable<T>[]
}

通过这种方式,我们摆脱了 nullish 类型,并更清楚地了解了数组内容的类型:

// const array: number[]
const filtered = array.filter(Boolean);

不错!有什么警告?字面元组和数组。BooleanConstructor不仅过滤 nullish 值,还过滤假值。为了获取正确的元素,我们不仅需要返回NonNullable<T>,还要引入一种检查真值的类型:

type Truthy<T> = T extends "" | false | 0 | 0n ? never : T;

interface Array<T> {
  filter(predicate: BooleanConstructor): Truthy<NonNullable<T>>[];
}

interface ReadonlyArray<T> {
  filter(predicate: BooleanConstructor): Truthy<NonNullable<T>>[];
}

// as const creates a readonly tuple
const array = [0, 1, 2, 3, ``, -0, 0n, false, undefined, null] as const;

// const filtered: (1 | 2 | 3)[]
const filtered = array.filter(Boolean);

const nullOrOne: Array<0 | 1> = [0, 1, 0, 1];

// const onlyOnes: 1[]
const onlyOnes = nullOrOne.filter(Boolean);
注意

示例包括0n,这在BigInt类型中是 0。这种类型仅从 ECMAScript 2020 开始提供。

这给了我们关于预期类型的正确想法,但由于ReadonlyArray<T>使用元组的元素类型而不是元组类型本身,我们失去了元组内部类型顺序的信息。

与所有现有 TypeScript 类型扩展一样,请注意这可能会引起副作用。在本地范围内使用它们,并小心使用它们。

9.6 扩展模块

问题

您使用提供自己视图的库与 HTML 元素,如 Preact 或 React 一起工作。但有时它们的类型定义不包括最新功能。您希望对它们进行修补。

解决方案

在模块和接口级别使用声明合并。

讨论

JSX是 JavaScript 的语法扩展,引入了一种类似 XML 的方式来描述和嵌套组件。基本上,可以将任何可以描述为元素树的东西表达为 JSX。JSX 由流行的 React 框架的创建者引入,使得可以在 JavaScript 中以 HTML 样式编写和嵌套组件,实际上它被转译为一系列函数调用:

<button onClick={() => alert('YES')}>Click me</button>

// Transpiles to:

React.createElement("button", { onClick: () => alert('YES') }, 'Click me');

JSX 已经被许多框架采纳,即使与 React 没有或几乎没有联系。在第十章中有更多关于 JSX 的内容。

TypeScript 中的 React 类型附带了所有可能的 HTML 元素的大量接口。但有时您的浏览器、框架或代码可能比可能性更大。

假设您希望在 Chrome 中使用最新的图像功能并懒加载图像。这是渐进增强,因此只有理解正在发生的事情的浏览器才知道如何解释这一点。其他浏览器足够强大,不需要关心:

<img src="/awesome.jpg" loading="lazy" alt="What an awesome image" />

但是你的 TypeScript JSX 代码呢?错误:

function Image({ src, alt }) {
  // Property 'loading' does not exist.
  return <img src={src} alt={alt} loading="lazy" />;
}

为了防止这种情况,我们可以用我们自己的属性扩展可用的接口。这个 TypeScript 特性被称为声明合并

创建一个@types文件夹,并在其中放置一个jsx.d.ts文件。更改您的 TypeScript 配置,以便您的编译器选项允许额外类型:

{
  "compilerOptions": {
    ...
    /* Type declaration files to be included in compilation. */
    "types": ["@types/**"],
  },
  ...
}

我们重新创建了确切的模块和接口结构:

  • 模块称为'react'

  • 接口是ImgHTMLAttributes<T>扩展自 HTMLAttributes

我们从原始类型定义中知道这一点。在这里,我们添加我们想要的属性:

import "react";

declare module "react" {
  interface ImgHTMLAttributes<T> extends HTMLAttributes<T> {
    loading?: "lazy" | "eager" | "auto";
  }
}

并且,让我们确保不要忘记 alt 文本:

import "react";

declare module "react" {
  interface ImgHTMLAttributes<T> extends HTMLAttributes<T> {
    loading?: "lazy" | "eager" | "auto";
    alt: string;
  }
}

非常好!TypeScript 将采用原始定义并合并您的声明。您的自动完成可以提供所有可用选项,并在忘记 alt 文本时显示错误。

当使用Preact时,情况变得有些复杂。原始的 HTML 类型定义非常宽泛,不像 React 的类型定义那样具体。这就是为什么在定义图像时我们必须更加明确的原因:

declare namespace JSX {
  interface IntrinsicElements {
    img: HTMLAttributes & {
      alt: string;
      src: string;
      loading?: "lazy" | "eager" | "auto";
    };
  }
}

这确保了altsrc都可用,并添加了一个名为loading的新属性。尽管技术是相同的:声明合并,它在命名空间、接口和模块级别上都有效。

9.7 扩展全局

问题

使用像ResizeObserver这样的浏览器特性,你会发现它在你当前的 TypeScript 配置中不可用。

解决方案

使用自定义类型定义扩展全局命名空间。

讨论

TypeScript 将所有 DOM API 的类型存储在lib.dom.d.ts中。这个文件是从 Web IDL 文件自动生成的。Web IDL代表Web 接口定义语言,是 W3C 和 WHATWG 用来定义 Web API 接口的格式。它大约在 2012 年发布,并且自 2016 年以来一直是一个标准。

当你阅读W3C的标准时,比如在Resize Observer上,你可以在规范的某个地方看到定义的部分或完整的定义。就像这样:

enum ResizeObserverBoxOptions {
  "border-box", "content-box", "device-pixel-content-box"
};

dictionary ResizeObserverOptions {
  ResizeObserverBoxOptions box = "content-box";
};

[Exposed=(Window)]
interface ResizeObserver {
  constructor(ResizeObserverCallback callback);
  void observe(Element target, optional ResizeObserverOptions options);
  void unobserve(Element target);
  void disconnect();
};

callback ResizeObserverCallback = void (
  sequence<ResizeObserverEntry> entries,
  ResizeObserver observer
);

[Exposed=Window]
interface ResizeObserverEntry {
  readonly attribute Element target;
  readonly attribute DOMRectReadOnly contentRect;
  readonly attribute FrozenArray<ResizeObserverSize> borderBoxSize;
  readonly attribute FrozenArray<ResizeObserverSize> contentBoxSize;
  readonly attribute FrozenArray<ResizeObserverSize> devicePixelContentBoxSize;
};

interface ResizeObserverSize {
  readonly attribute unrestricted double inlineSize;
  readonly attribute unrestricted double blockSize;
};

interface ResizeObservation {
  constructor(Element target);
  readonly attribute Element target;
  readonly attribute ResizeObserverBoxOptions observedBox;
  readonly attribute FrozenArray<ResizeObserverSize> lastReportedSizes;
};

浏览器使用这个作为实现相应 API 的指南。TypeScript 使用这些 IDL 文件来生成lib.dom.d.tsTypeScript 和 JavaScript 库生成器项目抓取 Web 标准并提取 IDL 信息。然后,IDL 到 TypeScript 生成器解析 IDL 文件并生成正确的类型定义。

手动维护要抓取的页面。一旦规范足够成熟并且被所有主要浏览器支持,人们会添加一个新资源,并且看到他们的更改将在即将发布的 TypeScript 版本中发布。所以只是时间问题,直到我们在lib.dom.d.ts中得到ResizeObserver

如果我们等不及,我们可以自己为当前正在工作的项目添加类型定义。

假设我们生成了ResizeObserver的类型。我们将把输出存储在一个名为resize-observer.d.ts的文件中。以下是内容:

type ResizeObserverBoxOptions =
  "border-box" |
  "content-box" |
  "device-pixel-content-box";

interface ResizeObserverOptions {
  box?: ResizeObserverBoxOptions;
}

interface ResizeObservation {
  readonly lastReportedSizes: ReadonlyArray<ResizeObserverSize>;
  readonly observedBox: ResizeObserverBoxOptions;
  readonly target: Element;
}

declare var ResizeObservation: {
  prototype: ResizeObservation;
  new(target: Element): ResizeObservation;
};

interface ResizeObserver {
  disconnect(): void;
  observe(target: Element, options?: ResizeObserverOptions): void;
  unobserve(target: Element): void;
}

export declare var ResizeObserver: {
  prototype: ResizeObserver;
  new(callback: ResizeObserverCallback): ResizeObserver;
};

interface ResizeObserverEntry {
  readonly borderBoxSize: ReadonlyArray<ResizeObserverSize>;
  readonly contentBoxSize: ReadonlyArray<ResizeObserverSize>;
  readonly contentRect: DOMRectReadOnly;
  readonly devicePixelContentBoxSize: ReadonlyArray<ResizeObserverSize>;
  readonly target: Element;
}

declare var ResizeObserverEntry: {
  prototype: ResizeObserverEntry;
  new(): ResizeObserverEntry;
};

interface ResizeObserverSize {
  readonly blockSize: number;
  readonly inlineSize: number;
}

declare var ResizeObserverSize: {
  prototype: ResizeObserverSize;
  new(): ResizeObserverSize;
};

interface ResizeObserverCallback {
  (entries: ResizeObserverEntry[], observer: ResizeObserver): void;
}

我们声明了大量接口和一些实现我们接口的变量,比如declare var ResizeObserver,它是定义原型和构造函数的对象:

declare var ResizeObserver: {
  prototype: ResizeObserver;
  new(callback: ResizeObserverCallback): ResizeObserver;
};

这已经帮了很多忙。我们可以使用(可以说是)冗长的类型声明,并将它们直接放入我们需要它们的文件中。找到了ResizeObserver!虽然我们希望在任何地方都能使用它。

由于 TypeScript 的声明合并功能,我们可以根据需要扩展命名空间接口。这一次,我们正在扩展全局命名空间

全局命名空间包含所有全局可用的对象和接口。就像window对象(和Window接口)以及应该作为我们 JavaScript 执行上下文一部分的其他所有内容。我们扩展全局命名空间并将ResizeObserver对象添加到其中:

declare global { // opening up the namespace
  var ResizeObserver: { // merging ResizeObserver with it
    prototype: ResizeObserver;
    new(callback: ResizeObserverCallback): ResizeObserver;
  }
}

让我们将resize-observer.d.ts放在名为@types的文件夹中。不要忘记将该文件夹添加到 TypeScript 将解析的源列表以及tsconfig.json中的类型声明文件夹列表中:

{
  "compilerOptions": {
    //...
    "typeRoots": ["@types", "./node_modules/@types"],
    //...
  },
  "include": ["src", "@types"]
}

由于在目标浏览器中可能尚未提供ResizeObserver,请确保将ResizeObserver对象设置为undefined。这促使您检查对象是否可用:

declare global {
  var ResizeObserver: {
    prototype: ResizeObserver;
    new(callback: ResizeObserverCallback): ResizeObserver;
  } | undefined
}

在您的应用程序中:

if (typeof ResizeObserver !== 'undefined') {
  const x = new ResizeObserver((entries) => {});
}

这使得与ResizeObserver一起工作尽可能安全!

可能是 TypeScript 未捕捉到您的环境声明文件和全局扩展。如果发生这种情况,请确保:

  • 通过tsconfig.json中的include属性解析@types文件夹。

  • 通过将它们添加到tsconfig.json编译器选项中的typestypeRoots,您的环境类型声明文件将被识别为此类文件。

  • 在环境声明文件的末尾添加export {},以便 TypeScript 将此文件识别为模块。

9.8 将非 JS 模块添加到模块图中

问题

您可以使用类似 Webpack 的打包工具从 JavaScript 中加载.css或图像文件,但 TypeScript 不会识别这些文件。

解决方案

基于文件扩展名全局声明模块。

讨论

在 Web 开发中有一种趋势,即将 JavaScript 作为一切的默认入口点,并通过import语句处理所有相关资产。为此,您需要一个构建工具,即一个捆绑工具,分析您的代码并创建正确的构件。这方面的一个流行工具是Webpack,一个 JavaScript 捆绑工具,允许您捆绑所有内容——CSS、Markdown、SVG、JPEG 等等:

// like this
import "./Button.css";

// or this
import styles from "./Button.css";

Webpack 使用称为loaders的概念,它查看文件结尾并激活某些捆绑概念。在 JavaScript 中导入.css文件不是本地操作,这是 Webpack(或您使用的任何捆绑工具)的一部分。但是,我们可以教会 TypeScript 理解这类文件。

注意

ECMAScript 标准委员会中有一个提案,允许导入除 JavaScript 以外的文件,并断言对此类特定内置格式的支持。这将最终影响到 TypeScript。您可以在此处阅读详细信息。

TypeScript 支持环境模块声明,即使是对于在环境中或通过工具可达但“物理上”不存在的模块。一个例子是 Node 的主要内置模块,如urlhttppath,如 TypeScript 的文档中所述:

declare module "path" {
  export function normalize(p: string): string;
  export function join(...paths: any[]): string;
  export var sep: string;
}

这对于我们知道确切名称的模块非常有用。我们也可以对通配符模式使用相同的技术。让我们为所有.css文件声明一个通用环境模块:

declare module '*.css' {
  // to be done.
}

这个模式已经准备好了。这会监听我们想要导入的所有.css文件。我们期望的是一组可以添加到我们组件中的类名。由于我们不知道.css文件中定义了哪些类,让我们使用一个接受每个字符串键并返回字符串的对象:

declare module '*.css' {
  interface IClassNames {
    [className: string]: string
  }
  const classNames: IClassNames;
  export default classNames;
}

这就是我们需要做的一切来使我们的文件重新编译。唯一的缺点是我们无法使用确切的类名来获得自动完成和类似的好处。解决这个问题的一种方法是自动生成类型文件。有一些在 NPM 上处理这个问题的包。请随意选择您喜欢的包。

如果我们想要将 MDX 类似的东西导入到我们的模块中,那会稍微容易些。MDX 允许我们编写 Markdown,它会解析为常规的 React(或 JSX)组件(更多关于 React 的信息请参见 第十章)。

我们期望一个函数组件(我们可以传递属性给它),返回一个 JSX 元素:

declare module '*.mdx' {
  let MDXComponent: (props) => JSX.Element;
  export default MDXComponent;
}

Voilà!我们可以在 JavaScript 中加载 .mdx 文件并将它们用作组件:

import About from '../articles/about.mdx';

function App() {
  return <>
    <About/>
  </>
}

如果您不知道会发生什么,请简化自己的生活。您所需做的只是声明该模块。不提供任何类型。TypeScript 允许加载但不会提供任何类型安全性:

declare module '*.svg';

要让环境模块在您的应用程序中可用,建议在项目的某个地方(可能是根目录)创建一个 @types 文件夹。您可以在这里放置任意数量的 .d.ts 文件,其中包含您的模块定义。向您的 tsconfig.json 添加引用,TypeScript 就知道该如何处理了:

{
  ...
  "compilerOptions": {
    ...
    "typeRoots": [
      "./node_modules/@types",
      "./@types"
    ],
    ...
  }
}

TypeScript 的主要功能之一是适应所有 JavaScript 的变体。有些是内置的,而其他的则需要您进行额外的补丁。

^(1) 在创建 API 定义时,unknown 不存在。此外,TypeScript 强调开发人员的生产力,res.json() 是一个广泛使用的方法,如果这样做将会破坏无数应用程序。

^(2) 感谢丹·范德坎姆的 Effective TypeScript 博客 在这个主题上的灵感。

第十章:TypeScript 和 React

近年来,React 可以说是最受欢迎的 JavaScript 库之一。其简单的组件组合方法改变了我们编写前端(以及在某种程度上后端)应用程序的方式,允许你使用一种称为 JSX 的 JavaScript 语法扩展来声明性地编写 UI 代码。这个简单的原则不仅易于掌握和理解,而且还影响了其他数十个库。

JSX 无疑是 JavaScript 世界的一个重大变革,并且随着 TypeScript 致力于服务于所有 JavaScript 开发者的目标,JSX 也进入了 TypeScript 的领域。事实上,TypeScript 是一个功能齐全的 JSX 编译器。如果你不需要额外的捆绑或额外的工具,TypeScript 就足以启动你的 React 应用程序。在写作时,React 的类型定义每周在 NPM 上下载量达到 2000 万次。VS Code 提供的出色工具和优秀的类型使得 TypeScript 成为全球 React 开发者的首选。

尽管 TypeScript 在 React 开发者中的流行度依然不减,但有一个情况使得与 React 一起使用 TypeScript 略显困难:TypeScript 并不是 React 团队的首选。尽管现在其他基于 JSX 的库大多都是用 TypeScript 编写的,因此提供了出色的类型支持,但 React 团队使用他们自己的静态类型检查器Flow,与 TypeScript 有些类似,但最终不兼容。这意味着数百万开发者依赖的 React 类型是由社区贡献者组成的小组后续创建,并发布在 Definitely Typed 上。虽然@types/react被认为是非常优秀的,但它们仍然只是尽力去为像 React 这样复杂的库提供类型。这不可避免地导致一些缺陷。对于那些缺陷显现的地方,本章将为你提供指引。

在本章中,我们将探讨 React 在理论上应该很容易,但 TypeScript 通过抛出复杂的错误信息让你感到困扰的情况。我们将弄清楚这些消息的含义,如何绕过它们,以及哪些解决方案能够从长远来看帮助你。你还将了解各种开发模式及其好处,以及如何使用 TypeScript 内置的 JSX 支持。

你不会得到 React 和 TypeScript 的基本设置指南。生态系统如此广泛丰富,有很多条路可以通往罗马。选择你的框架文档页面,并寻找 TypeScript 相关内容。同时请注意,我假设你有一些 React 的使用经验。在本章中,我们主要讨论如何为 React 进行类型编写。

虽然本章倾向于使用 React,但你也能够将某些学习内容应用到其他基于 JSX 的框架和库中。

10.1 编写代理组件

问题

你编写了很多标准的 HTML 组件,但不想一直设置所有必要的属性。

解决方案

创建代理组件并应用一些模式,使它们适用于您的情况。

讨论

大多数 Web 应用程序使用按钮。按钮具有默认为submittype属性。这是表单的明智默认设置,在此类表单中,您通过 HTTP 执行操作,将内容 POST 到服务器端 API。但是当您只是想在您的网站上有交互元素时,按钮的正确类型是button。这不仅是一种美学选择,而且对于可访问性也非常重要:

<button type="button">Click me!</button>

当您编写 React 时,很少会将表单提交到具有submit类型的服务器,而是与许多button类型的按钮交互。处理这类情况的一种好方法是编写代理组件。它们模仿 HTML 元素,但预设了一些属性:

function Button(props) {
  return <button type="button" {...props} />;
}

思想是Button接受与 HTML button相同的属性,并将属性展开到 HTML 元素中。将属性展开到 HTML 元素是一个很好的功能,您可以确保能够设置所有 HTML 元素具有的属性,而无需预先知道要设置哪些属性。但是我们如何对它们进行类型化?

可以在 JSX 中使用的所有 HTML 元素都通过JSX命名空间中的内部元素定义。当您加载 React 时,JSX命名空间将出现为您文件中的全局命名空间,并且您可以通过索引访问所有元素。因此,Button的正确属性类型在JSX.IntrinsicElements中定义。

注意

替代JSX.IntrinsicElements的是React.ElementType,这是 React 包中的泛型类型,也包括类和函数组件。对于代理组件,JSX⁠.Int⁠rin⁠sic​Ele⁠ments已经足够,并且带来了额外的好处:您的组件与其他类似 React 的框架(如 Preact)保持兼容。

JSX.IntrinsicElements是全局JSX命名空间中的一种类型。一旦这个命名空间在作用域内,TypeScript 就能够捕捉与您的基于 JSX 的框架兼容的基本元素:

type ButtonProps = JSX.IntrinsicElements["button"];

function Button(props: ButtonProps) {
  return <button type="button" {...props} />;
}

这包括子元素:我们将它们展开!正如您所见,我们将按钮的类型设置为button。由于 props 只是 JavaScript 对象,因此可以通过将其设置为 props 中的属性来覆盖type。如果定义了两个同名的键,那么后者将覆盖前者。这可能是期望的行为,但您也可能希望阻止您和您的同事覆盖type。使用Omit<T, K>辅助类型,您可以从 JSX button中获取所有属性,但删除您不想覆盖的键:

type ButtonProps = Omit<JSX.IntrinsicElements["button"], "type">;

function Button(props: ButtonProps) {
  return <button type="button" {...props} />;
}

const aButton = <Button type="button">Hi</Button>;
//                      ^
// Type '{ children: string; type: string; }' is not
// assignable to type 'IntrinsicAttributes & ButtonProps'.
// Property 'type' does not exist on type
// 'IntrinsicAttributes & ButtonProps'.(2322)

如果您需要typesubmit,您可以创建另一个代理组件:

type SubmitButtonProps = Omit<JSX.IntrinsicElements["button"], "type">;

function SubmitButton(props: SubmitButtonProps) {
  return <button type="submit" {...props} />;
}

可以根据需要省略属性来扩展这个想法。也许您遵循设计系统,并不希望类名随意设置:

type StyledButton = Omit<
  JSX.IntrinsicElements["button"],
  "type" | "className" | "style"
> & {
  type: "primary" | "secondary";
};

function StyledButton({ type, ...allProps }: StyledButton) {
  return <Button type="button" className={`btn-${type}`} {...allProps}/>;
}

这甚至允许您重复使用type属性名。

我们从类型定义中删除了一些 props,并将它们预设为合理的默认值。现在我们希望确保用户不要忘记设置一些 props,比如图片的alt属性或src属性。

为此,我们创建一个MakeRequired辅助类型,移除可选标志:

type MakeRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>;

并构建我们自己的 props:

type ImgProps
  = MakeRequired<
    JSX.IntrinsicElements["img"],
    "alt" | "src"
  >;

export function Img(props: ImgProps) {
  return <img {...props} />;
}

const anImage = <Img />;
//               ^
// Type '{}' is missing the following properties from type
// 'Required<Pick<DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>,
//  HTMLImageElement>, "alt" | "src">>': alt, src (2739)

通过对原始内置元素类型和代理组件进行一些更改,我们可以确保我们的代码变得更加健壮、更易访问且错误更少。

10.2 编写受控组件

问题

像输入框这样的表单元素增加了另一种复杂性,因为我们需要决定在哪里管理状态:在浏览器中还是在 React 中。

解决方案

写一个代理组件,使用区分联合和可选永不技巧,以确保在运行时不会从未受控切换到受控。

讨论

React 区分表单元素之间的 受控组件未受控组件。当你使用像inputtextareaselect这样的常规表单元素时,需要记住底层的 HTML 元素控制它们自己的状态。而在 React 中,元素的状态也是通过 React 来定义的 *。

如果你设置了value属性,React 会认为该元素的值也由 React 的状态管理控制,这意味着除非你使用useState和相关的 setter 函数维护元素的状态,否则无法修改这个值。

有两种方法可以处理这一问题。首先,你可以选择defaultValue作为属性而不是value。这将只在第一次渲染时设置输入的value,随后将所有控制权交给浏览器:

function Input({
  value = "", ...allProps
}: Props) {
  return (
    <input
      defaultValue={value}
      {...allProps}
    />
  );
}

或者,你通过 React 的状态管理内部管理value。通常,只需将原始输入元素的 props 与我们自己的类型相交集就足够了。我们从内置元素中删除value,并将其添加为必需的string

type ControlledProps =
  Omit<JSX.IntrinsicElements["input"], "value"> & {
    value: string;
  };

然后,我们将input元素包装在一个代理组件中。将状态保存在代理组件内部并不是最佳实践;相反,你应该使用useState从外部管理它。我们还将从原始输入 props 传递的onChange处理器进行转发:

function Input({
  value = "", onChange, ...allProps
}: ControlledProps) {
  return (
    <input
      value={value}
      {...allProps}
      onChange={onChange}
    />
  );
}

function AComponentUsingInput() {
  const [val, setVal] = useState("");
  return <Input
    value={val}
    onChange={(e) => {
      setVal(e.target.value);
    }}
  />
}

React 在运行时处理从未受控到受控的切换时会发出一个有趣的警告:

一个组件正在将未受控输入转换为受控输入。这很可能是由于值从未定义变为已定义值,应该避免这种情况。决定在组件的生命周期内使用受控还是未受控输入元素。

我们可以通过在编译时确保我们要么总是提供一个定义了的字符串value,要么提供一个defaultValue来防止这个警告,但不能两者都提供。这可以通过使用可选 never 技术来使用歧视联合类型来解决(如 Recipe 3.8 中所示),并且使用OnlyRequired帮助类型从 Recipe 8.1 派生可能的属性来控制JSX.IntrinsicElements["input"]

import React, { useState } from "react";

// A helper type setting a few properties to be required
type OnlyRequired<T, K extends keyof T = keyof T> = Required<Pick<T, K>> &
  Partial<Omit<T, K>>;

// Branch 1: Make "value" and "onChange" required, drop `defaultValue`
type ControlledProps = OnlyRequired<
  JSX.IntrinsicElements["input"],
  "value" | "onChange"
> & {
  defaultValue?: never;
};

// Branch 2: Drop `value` and `onChange`, make `defaultValue` required
type UncontrolledProps = Omit<
  JSX.IntrinsicElements["input"],
  "value" | "onChange"
> & {
  defaultValue: string;
  value?: never;
  onChange?: never;
};

type InputProps = ControlledProps | UncontrolledProps;

function Input({ ...allProps }: InputProps) {
  return <input {...allProps} />;
}

function Controlled() {
  const [val, setVal] = useState("");
  return <Input value={val} onChange={(e) => setVal(e.target.value)} />;
}

function Uncontrolled() {
  return <Input defaultValue="Hello" />;
}

在所有其他情况下,具有可选的value或具有defaultValue并尝试控制值将被类型系统禁止。

10.3 类型化自定义钩子

问题

您希望定义自定义钩子并获得适当的类型。

解决方案

使用元组类型或const 上下文

讨论

让我们在 React 中创建一个自定义钩子,并遵循常规 React 钩子的命名约定:返回一个可以解构的数组(或元组)。例如,useState

const [state, setState] = useState(0);

为什么我们要使用数组?因为数组的字段没有名称,你可以设置自己的名称:

const [count, setCount] = useState(0);
const [darkMode, setDarkMode] = useState(true);

因此,如果您有类似的模式,您也想返回一个数组。一个自定义切换钩子可能看起来像这样:

export const useToggle = (initialValue: boolean) => {
  const [value, setValue] = useState(initialValue);
  const toggleValue = () => setValue(!value);
  return [value, toggleValue];
}

没有什么特别的。我们唯一需要设置的类型是输入参数的类型。让我们试试吧:

export const Body = () => {
  const [isVisible, toggleVisible] = useToggle(false)
  return (
    <>
      <button onClick={toggleVisible}></button>
    { /* Error. See below */ }
      {isVisible && <div>World</div>}>}
    </>
  )
}
// Error: Type 'boolean | (() => void)' is not assignable to
// type 'MouseEventHandler<HTMLButtonElement> | undefined'.
// Type 'boolean' is not assignable to type
// 'MouseEventHandler<HTMLButtonElement>'.(2322)

那么为什么会失败?错误消息可能会很神秘,但我们应该注意的是第一种类型,它声明为不兼容:boolean | (() => void)。这是因为返回一个数组:一个可以容纳尽可能多元素的任意长度列表。从useToggle的返回值中,TypeScript 推断出一个数组类型。由于value的类型是boolean(太好了!),而toggleValue的类型是(() => void)(一个预期返回空的函数),TypeScript 告诉我们在这个数组中可能存在这两种类型。

这就是破坏与onClick兼容性的地方。onClick期望一个函数。这没问题,但toggleValue(或toggleVisible)是一个函数。然而,根据 TypeScript 的说法,它也可以是一个布尔值!TypeScript 告诉你要明确,或至少要进行类型检查。

但我们不应该需要进行额外的类型检查。我们的代码非常清晰。问题在于类型不正确。因为我们不处理一个数组,让我们换一个名字:元组。虽然数组是一个可以有任意长度值的值列表,但我们在元组中确切地知道有多少个值。通常,我们还知道元组中每个元素的类型。

因此,我们不应该返回一个数组而是一个元组在useToggle。问题是:在 JavaScript 中,数组和元组是无法区分的。在 TypeScript 的类型系统中,我们可以区分它们。

第一个选择:让我们对返回类型有意识。由于 TypeScript——正确地!——推断出一个数组,我们必须告诉 TypeScript,我们期望一个元组:

// add a return type here
export const useToggle = (initialValue: boolean): [boolean, () => void] => {
  const [value, setValue] = useState(initialValue);
  const toggleValue = () => setValue(!value);
  return [value, toggleValue];
};

[boolean, () => void]作为返回类型,TypeScript 检查我们在此函数中返回一个元组。TypeScript 不会推断,而是确保您的预期返回类型与实际值匹配。这样一来,您的代码就不会再抛出错误了。

第二个选项:使用const context。通过元组,我们知道我们期望的元素数量,并且知道这些元素的类型。这听起来像是使用const断言冻结类型的工作:

export const useToggle = (initialValue: boolean) => {
  const [value, setValue] = useState(initialValue);
  const toggleValue = () => setValue(!value);
  // here, we freeze the array to a tuple
  return [value, toggleValue] as const;
}

现在返回类型是readonly [boolean, () => void],因为as const确保您的值是常量且不可更改。从语义上讲,这种类型略有不同,但实际上您无法在useToggle外部更改返回的值。因此,使用readonly会稍微更加正确。

10.4 Typing Generic forwardRef Components

问题

您为组件使用forwardRef,但需要它们是泛型的。

解决方案

对于这个问题有几种解决方案。

讨论

如果您在 React 中创建组件库和设计系统,可能已经将ref转发到组件内部的 DOM 元素。

特别是当您包装基本组件或叶子节点在代理组件(参见 Recipe 10.1)中时,这尤其有用,但您希望像往常一样使用ref属性:

const Button = React.forwardRef((props, ref) => (
  <button type="button" {...props} ref={ref}>
    {props.children}
  </button>
));

// Usage: You can use your proxy just like you use
// a regular button!
const reference = React.createRef();
<Button className="primary" ref={reference}>Hello</Button>

通常为React.forwardRef提供类型通常非常简单。@types/react中提供的类型具有可以在调用React.forwardRef时设置的泛型类型变量。在这种情况下,显式注释您的类型是正确的做法:

type ButtonProps = JSX.IntrinsicElements["button"];

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (props, ref) => (
    <button type="button" {...props} ref={ref}>
      {props.children}
    </button>
  )
);

// Usage
const reference = React.createRef<HTMLButtonElement>();
<Button className="primary" ref={reference}>Hello</Button>

到目前为止一切都很好。但是,如果您有一个接受泛型属性的组件,情况会变得有些复杂。以下组件生成了一个列表项的列表,您可以使用button元素选择每一行:

type ClickableListProps<T> = {
  items: T[];
  onSelect: (item: T) => void;
};

function ClickableList<T>(props: ClickableListProps<T>) {
  return (
    <ul>
      {props.items.map((item, idx) => (
        <li>
          <button key={idx} onClick={() => props.onSelect(item)}>
            Choose
          </button>
          {item}
        </li>
      ))}
    </ul>
  );
}

// Usage
const items = [1, 2, 3, 4];
<ClickableList items={items}
  onSelect={(item) => {
    // item is of type number
    console.log(item);
  } } />

您希望具有额外的类型安全性,以便在on​Sel⁠ect回调中使用类型安全的item。假设您想要创建一个指向内部ul元素的ref:该如何操作?让我们将ClickableList组件更改为一个接受ForwardRef的内部函数组件,并将其作为参数传递给React.forwardRef函数:

// The original component extended with a `ref`
function ClickableListInner<T>(
  props: ClickableListProps<T>,
  ref: React.ForwardedRef<HTMLUListElement>
) {
  return (
    <ul ref={ref}>
      {props.items.map((item, i) => (
        <li key={i}>
          <button onClick={(el) => props.onSelect(item)}>Select</button>
          {item}
        </li>
      ))}
    </ul>
  );
}

// As an argument in `React.forwardRef`
const ClickableList = React.forwardRef(ClickableListInner)

这样编译没有问题,但有一个缺点:我们无法为Cl⁠ick⁠ab⁠le​Li⁠st⁠Prop⁠s分配泛型类型变量。它默认变为unknown。与any相比这是好的,但也略微让人烦恼。当我们使用ClickableList时,我们知道要传递哪些项目,并且希望根据类型进行类型化!那么我们该如何实现呢?答案有些棘手……而且您有几个选项。

第一个选项是进行类型断言,恢复原始的函数签名:

const ClickableList = React.forwardRef(ClickableListInner) as <T>(
  props: ClickableListProps<T> & { ref?: React.ForwardedRef<HTMLUListElement> }
) => ReturnType<typeof ClickableListInner>;

如果您只有少数情况需要泛型forwardRef组件,类型断言效果很好,但当您处理大量这些组件时可能会显得笨拙。此外,您为应该是默认行为的事物引入了一个不安全的运算符。

第二个选择是使用包装组件创建自定义引用。虽然ref是 React 组件的保留字,但你可以使用自定义属性来模拟类似的行为。这同样有效:

type ClickableListProps<T> = {
  items: T[];
  onSelect: (item: T) => void;
  mRef?: React.Ref<HTMLUListElement> | null;
};

export function ClickableList<T>(
  props: ClickableListProps<T>
) {
  return (
    <ul ref={props.mRef}>
      {props.items.map((item, i) => (
        <li key={i}>
          <button onClick={(el) => props.onSelect(item)}>Select</button>
          {item}
        </li>
      ))}
    </ul>
  );
}

然而,你引入了一个新的 API。需要注意的是,还有使用包装组件的可能性,允许你在内部组件中使用forwardRef并向外部暴露自定义的ref属性:

function ClickableListInner<T>(
  props: ClickableListProps<T>,
  ref: React.ForwardedRef<HTMLUListElement>
) {
  return (
    <ul ref={ref}>
      {props.items.map((item, i) => (
        <li key={i}>
          <button onClick={(el) => props.onSelect(item)}>Select</button>
          {item}
        </li>
      ))}
    </ul>
  );
}

const ClickableListWithRef = forwardRef(ClickableListInner);

type ClickableListWithRefProps<T> = ClickableListProps<T> & {
  mRef?: React.Ref<HTMLUListElement>;
};

export function ClickableList<T>({
  mRef,
  ...props
}: ClickableListWithRefProps<T>) {
  return <ClickableListWithRef ref={mRef} {...props} />;
}

如果你只想要传递那个引用,两者都是有效的解决方案。如果你想要一个一致的 API,你可能会寻找其他的解决方案。

第三种选择是用你自己的类型定义增强forwardRef。TypeScript 提供了一种称为higher-order function type inference的功能,允许将自由类型参数传播到外部函数。

这听起来很像我们最初想要的forwardRef,但它与我们当前的类型定义不兼容。原因是高阶函数类型推断仅适用于普通函数类型。forwardRef内部的函数声明还会为defaultProps等添加属性。这些都是来自类组件时代的遗留物,也许你并不想使用它们。

因此,不需要额外的属性,应该可以使用高阶函数类型推断!

我们正在使用 TypeScript,因此可以重新声明和重新定义自己的全局modulenamespaceinterface声明。声明合并是一个强大的工具,我们将要使用它:

// Redecalare forwardRef
declare module "react" {
  function forwardRef<T, P = {}>(
    render: (props: P, ref: React.Ref<T>) => React.ReactElement | null
  ): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
}

// Just write your components like you're used to!

type ClickableListProps<T> = {
  items: T[];
  onSelect: (item: T) => void;
};
function ClickableListInner<T>(
  props: ClickableListProps<T>,
  ref: React.ForwardedRef<HTMLUListElement>
) {
  return (
    <ul ref={ref}>
      {props.items.map((item, i) => (
        <li key={i}>
          <button onClick={(el) => props.onSelect(item)}>Select</button>
          {item}
        </li>
      ))}
    </ul>
  );
}

export const ClickableList = React.forwardRef(ClickableListInner);

这个解决方案的好处是,你可以再次编写常规的 JavaScript,并且完全在类型层面上进行工作。另外,重新声明是模块作用域的:不会干扰来自其他模块的任何forwardRef调用!

10.5 为上下文 API 提供类型

问题

你希望在应用程序中使用上下文 API 进行全局管理,但你不知道如何处理类型定义的最佳方式。

解决方案

要么为上下文设置默认属性并让类型推断,要么创建你上下文属性的部分并显式实例化泛型类型参数。如果你不想提供默认值,但希望确保所有属性都被提供,可以创建一个辅助函数。

讨论

React 的上下文 API 允许你在全局级别共享数据。为了使用它,你需要两件事:

提供者

提供者将数据传递给子树。

消费者

消费者是在渲染属性内部消费传递的数据的组件。

使用 React 的类型定义,大多数情况下可以直接使用上下文。一切都是通过类型推断和泛型完成的。

首先,我们创建一个上下文。在这里,我们想要存储全局应用程序设置,如主题和应用程序的语言,以及全局状态。在创建 React 上下文时,我们希望传递默认属性:

import React from "react";

const AppContext = React.createContext({
  authenticated: true,
  lang: "en",
  theme: "dark",
});

因此,您所需的所有类型操作都已完成。我们有三个属性:authenticatedlangtheme;它们的类型分别是booleanstring。当您使用它们时,React 的类型信息将提供正确的类型。

接下来,组件树中高层次的组件需要提供上下文,例如应用程序的根组件。此提供程序将向下传播你设置的值到每个消费者:

function App() {
  return (
    <AppContext.Provider
      value={{
        authenticated: true,
        lang: "de",
        theme: "light",
      }}
    >
      <Header />
    </AppContext.Provider>
  );
}

现在,此树中的每个组件都可以消费此上下文。当您忘记一个属性或使用错误类型时,您将立即收到类型错误:

function App() {
// Property 'theme' is missing in type '{ lang: string; }' but required
// in type '{ lang: string; theme: string; authenticated: boolean }'.(2741)
  return (
    <AppContext.Provider
      value={{
        lang: "de",
      }}
    >
      <Header />
    </AppContext.Provider>
  );
}

现在,让我们消费我们的全局状态。可以通过渲染道具来消费上下文。您可以深度解构您的渲染道具,以获取您想要处理的属性:

function Header() {
  return (
    <AppContext.Consumer>
      {({ authenticated }) => {
        if (authenticated) {
          return <h1>Logged in!</h1>;
        }
        return <h1>You need to sign in</h1>;
      }}
    </AppContext.Consumer>
  );
}

使用上下文的另一种方法是通过相应的useContext钩子:

function Header() {
  const { authenticated } = useContext(AppContext);
  if (authenticated) {
    return <h1>Logged in!</h1>;
  }
  return <h1>You need to sign in</h1>;
}

因为我们早些时候定义了正确类型的属性,此时authenticated是布尔类型。再次强调,我们不必采取任何措施来获得此额外类型安全性。

前面的整个示例在我们拥有默认属性和值时效果最佳。有时您没有默认值或需要更灵活地设置属性。

与从默认值推断一切不同,我们显式地注释泛型类型参数,而不是使用完整类型,而是使用Partial

我们为上下文的 props 创建了一个类型:

type ContextProps = {
  authenticated: boolean;
  lang: string;
  theme: string;
};

并初始化新的上下文:

const AppContext = React.createContext<Partial<ContextProps>>({});

更改上下文默认属性的语义也会对您的组件产生一些副作用。现在您不需要提供每个值;空上下文对象也可以达到相同效果!您的所有属性都是可选的:

function App() {
  return (
    <AppContext.Provider
      value={{
        authenticated: true,
      }}
    >
      <Header />
    </AppContext.Provider>
  );
}

这也意味着您需要检查每个属性是否已定义。这不会更改您依赖boolean值的代码,但是其他每个属性都需要另一个undefined检查:

function Header() {
  const { authenticated, lang } = useContext(AppContext);
  if (authenticated && lang) {
    return <>
      <h1>Logged in!</h1>
      <p>Your language setting is set to {lang}</p>
    </> ;
  }
  return <h1>You need to sign in (or don't you have a language setting?)</h1>;
}

如果无法提供默认值并希望确保所有属性由上下文提供者提供,则可以使用辅助函数来帮助自己。在这里,我们希望显式泛型实例化以提供类型,但提供正确的类型保护,以便在消费上下文时正确设置所有可能的未定义值。

function createContext<Props extends {}>() { ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/1.png)
  const ctx = React.createContext<Props | undefined>(undefined); ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/2.png)
  function useInnerCtx() { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/3.png)
    const c = useContext(ctx);
    if (c === undefined) ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/4.png)
      throw new Error("Context must be consumed within a Provider");
    return c; ![5](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/5.png)
  }
  return [useInnerCtx, ctx.Provider as React.Provider<Props>] as const; ![6](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/6.png)
}

createContext中发生了什么?

1

我们创建了一个没有函数参数但有泛型类型参数的函数。如果没有与函数参数的连接,我们无法通过推断实例化Props。这意味着为了createContext提供正确的类型,我们需要显式实例化它。

2

我们创建了一个允许Propsundefined的上下文。添加了undefined类型后,我们可以将undefined作为值传递。不设默认值!

3

createContext内部,我们创建了一个自定义钩子。此钩子使用新创建的上下文ctx包装useContext

4

然后我们进行类型守卫,检查返回的 Props 是否包含 undefined。记住,调用 createContext 时,我们用 Props | undefined 实例化泛型类型参数。此行再次从联合类型中移除 undefined

5

这意味着在这里,c 就是 Props

6

我们断言 ctx.Provider 不接受 undefined 值。我们使用 as const 来返回 [useInnerContext, ctx.Provider] 作为元组类型。

使用类似于 React.createContextcreateContext

const [useAppContext, AppContextProvider] = createContext<ContextProps>();

当使用 AppContextProvider 时,我们需要提供所有值:

function App() {
  return (
    <AppContextProvider
      value={{ lang: "en", theme: "dark", authenticated: true }}
    >
      <Header />
    </AppContextProvider>
  );
}

function Header() {
  // consuming Context doesn't change much
  const { authenticated } = useAppContext();
  if (authenticated) {
    return <h1>Logged in!</h1>;
  }
  return <h1>You need to sign in</h1>;
}

根据您的用例,您可以在没有太多开销的情况下获得精确的类型。

10.6 类型化高阶组件

问题

您正在编写高阶组件以预设其他组件的某些属性,但不知道如何对其进行类型化。

解决方案

使用 @types/react 中的 React.ComponentType<P> 类型来定义一个组件,该组件扩展了您的预设属性。

讨论

React 受函数式编程的影响,这在组件设计(通过函数)、组合(通过组合)和更新(无状态,单向数据流)方式中可以看出。函数式编程技术和范式迅速在 React 开发中找到了应用。其中一种技术是高阶组件,它从高阶函数中汲取灵感。

高阶函数接受一个或多个参数来返回一个新的函数。有时这些参数用于预填充其他参数,例如我们在第七章中看到的柯里化示例。高阶组件类似:它们接受一个或多个组件,并返回另一个组件。通常情况下,您创建它们来预填充某些属性,以确保稍后不会更改它们。

想象一个通用的 Card 组件,它以字符串形式接受 titlecontent

type CardProps = {
  title: string;
  content: string;
};

function Card({ title, content }: CardProps) {
  return (
    <>
      <h2>{title}</h2>
      <div>{content}</div>
    </>
  );
}

您可以使用此卡来显示某些事件,如警告、信息气泡和错误消息。最基本的信息卡将其标题设置为 "Info"

<Card title="Info" content="Your task has been processed" />;

您可以对 Card 的属性进行子集化处理,以允许仅设置 title 的某个特定子集字符串,但另一方面,您希望尽可能地重用 Card。因此,您创建了一个新组件,它已将 title 设置为 "Info",并且只允许设置其他属性:

const Info = withInjectedProps({ title: "Info" }, Card);

// This should work
<Info content="Your task has been processed" />;

// This should throw an error
<Info content="Your task has been processed" title="Warning" />;

换句话说,您注入了一部分属性,并使用新创建的组件设置了剩余的属性。函数 withInjectedProps 可以轻松编写:

function withInjectedProps(injected, Component) {
  return function (props) {
    const newProps = { ...injected, ...props };
    return <Component {...newProps} />;
  };
}

它接受 injected 属性和 Component 作为参数,返回一个新的函数组件,该函数组件接受剩余的属性作为参数,并使用合并的属性实例化原始组件。

那么我们如何对 withInjectedProps 进行类型化呢?让我们看看结果,并了解其中的内容:

function withInjectedProps<T extends {}, U extends T>( ![1](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/1.png)
  injected: T,
  Component: React.ComponentType<U> ![2](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/2.png)
) {
  return function (props: Omit<U, keyof T>) { ![3](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/3.png)
    const newProps = { ...injected, ...props } as U; ![4](https://github.com/OpenDocCN/ibooker-html-css-javascript-zh/raw/master/docs/ts-cb/img/4.png)
    return <Component {...newProps} />;
  };
}

下面是发生的情况:

1

我们需要定义两个通用类型参数。T 是我们已经注入的 props,它扩展自 {} 以确保我们只传递对象。UComponent 的所有 props 的通用类型参数。U 扩展 T,这意味着 UT 的一个子集。这表示 U 拥有比 T 更多的属性,但需要包括 T 已经定义的内容。

2

我们将 Component 定义为类型 React.ComponentType<U>。这包括类组件和函数组件,并表示 props 将设置为 U。通过 TU 的关系以及我们定义 withInjectedProps 的参数方式,我们确保传递给 Component 的所有内容都定义了带有 injectedComponent 属性的子集。如果我们打字错误,我们很快就会收到第一个错误信息!

3

返回的函数组件将接受剩余的 props。通过 Omit<U, keyof T>,我们确保不允许再次设置预填充的属性。

4

合并 TOmit<U, keyof T> 应该再次得到 U,但由于通用类型参数可以显式实例化为不同的内容,它们可能不再适合 Component。类型断言有助于确保 props 实际上是我们想要的。

就是这样!有了这些新类型,我们可以得到适当的自动完成和错误提示:

const Info = withInjectedProps({ title: "Info" }, Card);

<Info content="Your task has been processed" />;
<Info content="Your task has been processed" title="Warning" />;
//                                           ^
// Type '{ content: string; title: string; }' is not assignable
// to type 'IntrinsicAttributes & Omit<CardProps, "title">'.
// Property 'title' does not exist on type
// 'IntrinsicAttributes & Omit<CardProps, "title">'.(2322)

withInjectedProps 如此灵活,我们可以推导出创建各种情况下的高阶组件的高阶函数,比如 withTitle,它在这里用于预填充类型为 stringtitle 属性:

function withTitle<U extends { title: string }>(
  title: string,
  Component: React.ComponentType<U>
) {
  return withInjectedProps({ title }, Component);
}

您的函数式编程技能无限制。

10.7 React 合成事件系统中的回调函数类型化

问题

您希望为 React 中所有浏览器事件获得最佳可能的类型,并使用类型系统将您的回调限制在兼容的元素上。

解决方案

使用 @types/react 的事件类型,并使用通用类型参数专门化组件。

讨论

Web 应用程序通过用户交互变得生动起来。每次用户交互都会触发一个事件。事件至关重要,而 TypeScript 的 React 类型支持很好,但要求您不要使用 lib.dom.d.ts 中的原生事件。如果使用,React 会抛出错误:

type WithChildren<T = {}> = T & { children?: React.ReactNode };

type ButtonProps = {
  onClick: (event: MouseEvent) => void;
} & WithChildren;

function Button({ onClick, children }: ButtonProps) {
  return <button onClick={onClick}>{children}</button>;
//               ^
// Type '(event: MouseEvent) => void' is not assignable to
// type 'MouseEventHandler<HTMLButtonElement>'.
// Types of parameters 'event' and 'event' are incompatible.
// Type 'MouseEvent<HTMLButtonElement, MouseEvent>' is missing the following
// properties from type 'MouseEvent': offsetX, offsetY, x, y,
// and 14 more.(2322)
}

React 使用自己的事件系统,我们称之为合成事件。合成事件是浏览器原生事件的跨浏览器包装器,具有与其原生对应物相同的接口,但为了兼容性进行了调整。从 @types/react 的类型更改可以使您的回调函数再次兼容:

import React from "react";

type WithChildren<T = {}> = T & { children?: React.ReactNode };

type ButtonProps = {
  onClick: (event: React.MouseEvent) => void;
} & WithChildren;

function Button({ onClick, children }: ButtonProps) {
  return <button onClick={onClick}>{children}</button>;
}

浏览器的 MouseEventReact.MouseEvent 在 TypeScript 的结构类型系统中有足够的差异,这意味着合成的对应物中缺少一些属性。您可以在前面的错误消息中看到,原始的 MouseEventReact.MouseEvent 多了 18 个属性,其中一些可能很重要,比如坐标和偏移量,例如,如果您想在画布上绘图。

如果您想要访问原始事件的属性,可以使用 nativeEvent 属性:

function handleClick(event: React.MouseEvent) {
  console.log(event.nativeEvent.offsetX, event.nativeEvent.offsetY);
}

const btn = <Button onClick={handleClick}>Hello</Button>};

支持的事件有:AnimationEventChangeEventClipboardEventCom⁠pos⁠iti⁠on​Ev⁠entDragEventFocusEventFormEventKeyboardEventMouseEventPoi⁠nt⁠er​Ev⁠entTouchEventTransitionEventWheelEvent,以及对所有其他事件使用 SyntheticEvent

到目前为止,我们应用了正确的类型以确保没有任何编译器错误。足够简单。但我们使用 TypeScript 不仅仅是为了执行类型应用的仪式以防止编译器投诉,而且是为了防止可能会有问题的情况发生。

让我们再次考虑一个按钮。或者一个链接(a 元素)。这些元素被设计为可点击;这是它们的目的。但在浏览器中,点击事件可以被每个元素接收。没有任何东西阻止您向 div 元素添加 onClick,这是所有元素中语义含义最少的元素,也没有辅助技术会告诉您 div 可以接收 MouseEvent,除非您向其添加大量属性。

如果我们能阻止我们的同事(和我们自己)在错误的元素上使用定义的事件处理程序,那不是很棒吗?React.MouseEvent 是一个泛型类型,它将兼容的元素作为其第一个类型。这被设置为 Element,这是浏览器中所有元素的基本类型。但您可以通过子类型化此泛型参数来定义一个更小的兼容元素集合:

type WithChildren<T = {}> = T & { children?: React.ReactNode };

// Button maps to an HTMLButtonElement
type ButtonProps = {
  onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
} & WithChildren;

function Button({ onClick, children }: ButtonProps) {
  return <button onClick={onClick}>{children}</button>;
}

// handleClick accepts events from HTMLButtonElement or HTMLAnchorElement
function handleClick(
  event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>
) {
  console.log(event.currentTarget.tagName);
}

let button = <Button onClick={handleClick}>Works</Button>;
let link = <a href="/" onClick={handleClick}>Works</a>;

let broken = <div onClick={handleClick}>Does not work</div>;
//                ^
// Type '(event: MouseEvent<HTMLButtonElement | HTMLAnchorElement,
// MouseEvent>) => void' is not assignable to type
//'MouseEventHandler<HTMLDivElement>'.
// Types of parameters 'event' and 'event' are incompatible.
// Type 'MouseEvent<HTMLDivElement, MouseEvent>' is not assignable to
// type 'MouseEvent<HTMLButtonElement | HTMLAnchorElement, MouseEvent>'.
// Type 'HTMLDivElement' is not assignable to type #
// 'HTMLButtonElement | HTMLAnchorElement'.

尽管 React 的类型在某些领域给您更多的灵活性,但在其他方面缺少一些功能。例如,浏览器原生的 InputEvent@types/react 中不受支持。合成事件系统旨在成为跨浏览器的解决方案,而一些 React 兼容的浏览器仍然缺少对 InputEvent 的实现。在它们赶上之前,您可以安全地使用基础事件 SyntheticEvent

function onInput(event: React.SyntheticEvent) {
  event.preventDefault();
  // do something
}

const inp = <input type="text" onInput={onInput} />;

现在,您至少获得了某种类型安全性。

10.8 类型化多态组件

问题

您创建了一个代理组件(参见 Recipe 10.1),它需要作为许多不同 HTML 元素之一的行为。很难获得正确的类型。

解决方案

断言转发的属性为 any 或直接使用 JSX 工厂 React.createElement

讨论

React 中的一个常见模式是定义多态(或as)组件,它们预定义行为,但可以作为不同的元素操作。想象一下调用到行动按钮或 CTA,它可以是指向网站的链接或实际的 HTML 按钮。如果你想要将它们类似地样式化,它们应该行为相似,但根据上下文它们应该有适合正确操作的正确 HTML 元素。

注意

选择正确的元素是一个重要的辅助功能因素。abutton元素表示用户可以单击的东西,但a的语义与button的语义基本不同。a是锚点的缩写,需要有一个指向目标的引用(href)。button可以被点击,但通常通过 JavaScript 脚本化。这两个元素可能看起来相同,但它们的操作方式不同。它们不仅操作方式不同,而且在使用辅助技术(如屏幕阅读器)时也会以不同的方式进行通告。考虑你的用户,为合适的目的选择正确的元素。

这个想法是你在组件中有一个as属性,选择元素类型。根据as的元素类型,你可以转发适合该元素类型的属性。当然,你可以将这种模式与我们在Recipe 10.1中看到的所有内容结合起来。

<Cta as="a" href="https://typescript-cookbook.com">
  Hey hey
</Cta>

<Cta as="button" type="button" onClick={(e) => { /* do something */ }}>
  My my
</Cta>

在混合使用 TypeScript 时,你希望确保你获得了正确的属性自动补全以及错误的属性报错。如果你向button添加了href,TypeScript 应该给你正确的波浪线:

// Type '{ children: string; as: "button"; type: "button"; href: string; }'
// is not assignable to type 'IntrinsicAttributes & { as: "button"; } &
// ClassAttributes<HTMLButtonElement> &
// ButtonHTMLAttributes<HTMLButtonElement> & { ...; }'.
// Property 'href' does not exist on type ... (2322)
//                             v
<Cta as="button" type="button" href="" ref={(el) => el?.id}>
  My my
</Cta>

让我们试着输入Cta。首先,我们在完全没有类型的情况下开发组件。在 JavaScript 中,事情看起来并不复杂:

function Cta({ as: Component, ...props }) {
  return <Component {...props} />;
}

我们提取as属性并将其重命名为Component。这是 JavaScript 中的解构机制,语法与 TypeScript 注解类似,但适用于解构属性而不是对象本身(在对象本身上,你需要类型注解)。我们将其重命名为大写组件,以便我们可以通过 JSX 实例化它。其余的属性将被收集到...props中,并在创建组件时展开。请注意,你也可以用...props展开子元素,这是 JSX 的一个不错的副作用。

当我们想要为Cta添加类型时,我们创建一个CtaProps类型,它适用于"a"元素或"button"元素,并从JSX.IntrinsicElements中获取剩余的属性,与我们在Recipe 10.1中看到的类似。

type CtaElements = "a" | "button";

type CtaProps<T extends CtaElements> = {
  as: T;
} & JSX.IntrinsicElements[T];

当我们将类型与Cta连接起来时,我们看到函数签名仅需少量额外的注解即可很好地工作。但是在实例化组件时,我们会得到一个非常复杂的错误,告诉我们出了多少问题:

function Cta<T extends CtaElements>({
  as: Component,
  ...props
}: CtaProps<T>) {
  return <Component {...props} />;
//        ^
// Type 'Omit<CtaProps<T>, "as" | "children"> & { children: ReactNode; }'
// is not assignable to type 'IntrinsicAttributes &
// LibraryManagedAttributes<T, ClassAttributes<HTMLAnchorElement> &
// AnchorHTMLAttributes<HTMLAnchorElement> & ClassAttributes<...> &
// ButtonHTMLAttributes<...>>'.
// Type 'Omit<CtaProps<T>, "as" | "children"> & { children: ReactNode; }' is not
//  assignable to type
//  'LibraryManagedAttributes<T, ClassAttributes<HTMLAnchorElement>
//  & AnchorHTMLAttributes<HTMLAnchorElement> & ClassAttributes<...>
//  & ButtonHTMLAttributes<...>>'.(2322)
}

那么这个消息是从哪里来的呢?为了 TypeScript 正确地处理 JSX,我们需要在名为 JSX 的全局命名空间中使用类型定义。如果此命名空间在作用域内,TypeScript 就知道哪些不是组件的元素可以被实例化,以及它们可以接受哪些属性。这些是我们在本例中使用的 JS⁠X.I⁠ntr⁠ins⁠ic​Ele⁠men⁠ts,以及在第 10.1 节中使用的内容。

还需要定义的一个类型是 LibraryManagedAttributes。这种类型用于提供由框架本身定义的属性(如 key)或通过诸如 defaultProps 的方式定义的属性:

export interface Props {
  name: string;
}

function Greet({ name }: Props) {
  return <div>Hello {name.toUpperCase()}!</div>;
}
// Goes into LibraryManagedAttributes
Greet.defaultProps = { name: "world" };

// Type-checks! No type assertions needed!
let el = <Greet key={1} />;

React 的类型定义通过使用条件类型解决了 LibraryManagedAttributes。正如我们在第 12.7 节中看到的那样,条件类型在被评估时不会扩展所有可能的联合类型变体。这意味着 TypeScript 将无法检查你的类型定义是否适合组件,因为它无法评估 Lib⁠rary​Man⁠age⁠dAt⁠trib⁠utes

解决此问题的一种方法是将 props 断言为 any

function Cta<T extends CtaElements>({
  as: Component,
  ...props
}: CtaProps<T>) {
  return <Component {...(props as any)} />;
}

那样虽然可行,但它是一个不安全操作的标志,不应该是不安全的。另一种方法是在这种情况下不使用 JSX,而是使用 JSX 工厂函数 React.createElement

每个 JSX 调用都是对 JSX 工厂调用的语法糖:

<h1 className="headline">Hello World</h1>

// will be transformed to
React.createElement("h1", { className: "headline" }, ["Hello World"]);

如果您使用嵌套组件,createElement 的第三个参数将包含嵌套的工厂函数调用。与 JSX 相比,React.createElement 调用起来更加简单,当创建新元素时 TypeScript 不会使用全局的 JSX 命名空间。这听起来像是我们需要的一个完美的解决方案。

React.createElement 需要三个参数:组件、props 和 children。现在,我们已经通过 props 携带了所有的子组件,但是对于 React.createElement,我们需要更加明确。这也意味着我们需要明确地定义 children

为此,我们创建了一个 WithChildren<T> 辅助类型。它接受一个现有的类型,并以 React.ReactNode 的形式添加可选的子组件:

type WithChildren<T = {}> = T & { children?: React.ReactNode };

WithChildren 非常灵活。我们可以用它包装 props 的类型:

type CtaProps<T extends CtaElements> = WithChildren<{
  as: T;
} & JSX.IntrinsicElements[T]>;

或者我们可以创建一个联合体:

type CtaProps<T extends CtaElements> = {
  as: T;
} & JSX.IntrinsicElements[T] & WithChildren;

由于 T 默认设置为 {},该类型变得通用可用。这使得您在需要时更容易附加 children。作为下一步,我们从 props 中解构 children 并将所有参数传递给 React.createElement

function Cta<T extends CtaElements>({
  as: Component,
  children,
  ...props
}: CtaProps<T>) {
  return React.createElement(Component, props, children);
}

有了这个,你的多态组件就可以接受正确的参数而没有任何错误。

第十一章:类

当 TypeScript 在 2012 年首次发布时,JavaScript 生态系统和 JavaScript 语言的特性与今天的情况无法相提并论。TypeScript 不仅引入了类型系统,还通过语法丰富了已有语言,使您能够在模块、命名空间和类型之间抽象代码的部分。

TypeScript 的一个特性是类(classes),在面向对象编程中是一个基本要素。TypeScript 的类最初受到了 C# 的很大影响,如果你了解这两种编程语言背后的人物,这一点并不奇怪。^(1) 但它们也是基于被废弃的 ECMAScript 4 提案中的概念设计的。

随着时间的推移,JavaScript 获得了 TypeScript 和其他语言先驱创造的许多语言特性;类似私有字段、静态块和装饰器的类现在已经成为 ECMAScript 标准的一部分,并已经被部署到浏览器和服务器的语言运行时中。

这使得 TypeScript 处于一个甜蜜点,既保留了它在语言早期引入的创新,又符合 TypeScript 团队对类型系统所有未来功能的基线标准。尽管原始设计接近 JavaScript 的最终形式,但也有一些值得一提的差异。

在本章中,我们将探讨 TypeScript 和 JavaScript 中类的行为方式,我们表达自己的可能性,以及标准设计和原始设计之间的差异。我们将研究关键字、类型和泛型,并着眼于 TypeScript 在 JavaScript 中添加的内容,以及 JavaScript 自身带来的内容。

11.1 选择正确的可见性修饰符

问题

TypeScript 中有两种属性可见性和访问的风格:一种是通过特殊的关键字语法 — publicprotectedprivate — 另一种是通过实际的 JavaScript 语法,当属性以井号字符开头时。你应该选择哪一种呢?

解决方案

最好选择 JavaScript 原生语法,因为它在运行时有一些影响,你不希望错过。如果你依赖于涉及可见性修饰符变体的复杂设置,请使用 TypeScript 的修饰符。它们不会消失。

讨论

TypeScript 的类已经存在了相当长的时间,虽然它们受到了随后几年的 ECMAScript 类的巨大启发,但 TypeScript 团队还决定引入当时传统基于类的面向对象编程中有用和流行的特性。

其中之一就是属性可见性修饰符,也称为访问修饰符。可见性修饰符是您可以放在成员(属性和方法)前面的特殊关键字,用来告诉编译器它们如何从软件的其他部分看到和访问。

注意

所有的可见性修饰符以及 JavaScript 的私有字段,都可以作用于方法和属性。

默认的可见性修饰符是 public,可以显式地写出来,也可以省略不写:

class Person {
  public name; // modifier public is optional
  constructor(name: string) {
    this.name = name;
  }
}

const myName = new Person("Stefan").name; // works

另一个修饰符是 protected,限制了对类和子类的可见性:

class Person {
  protected name;
  constructor(name: string) {
    this.name = name;
  }
  getName() {
    // access works
    return this.name;
  }
}

const myName = new Person("Stefan").name;
//                                   ^
// Property 'name' is private and only accessible within
// class 'Person'.(2341)

class Teacher extends Person {
  constructor(name: string) {
    super(name);
  }

  getFullName() {
    // access works
    return `Professor ${this.name}`;
  }
}

protected 访问可以在派生类中被重写为 publicprotected 访问还禁止从不同子类的类引用中访问成员。因此,尽管这样做是有效的:

class Player extends Person {
  constructor(name: string) {
    super(name);
  }

  pair(p: Player) {
    // works
    return `Pairing ${this.name} with ${p.name}`;
  }
}

使用基类或不同的子类是行不通的:

class Player extends Person {
  constructor(name: string) {
    super(name);
  }

  pair(p: Person) {
    return `Pairing ${this.name} with ${p.name}`;
    //                                    ^
    // Property 'name' is protected and only accessible through an
    // instance of class 'Player'. This is an instance of
    // class 'Person'.(2446)
  }
}

最后一个可见性修饰符是 private,它只允许在同一类内部访问:

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

const myName = new Person("Stefan").name;
//                                   ^
// Property 'name' is protected and only accessible within
// class 'Person' and its subclasses.(2445)

class Teacher extends Person {
  constructor(name: string) {
    super(name);
  }

  getFullName() {
    return `Professor ${this.name}`;
    //                        ^
    // Property 'name' is private and only accessible
    // within class 'Person'.(2341)
  }
}

在构造函数中也可以使用可见性修饰符作为定义属性并初始化它们的快捷方式:

class Category {
  constructor(
    public title: string,
    public id: number,
    private reference: bigint
  ) {}
}

// transpiles to

class Category {
  constructor(title, id, reference) {
    this.title = title;
    this.id = id;
    this.reference = reference;
  }
}

尽管这里描述的所有功能,应该注意的是 TypeScript 的可见性修饰符是编译时的注释,在编译步骤后会被擦除。通常,如果它们不是通过类描述而是在构造函数中初始化,整个属性声明可能会被移除,就像我们在最后一个示例中看到的那样。

它们在编译时检查期间也是有效的,这意味着 TypeScript 中的 private 属性在转换为 JavaScript 后将完全可访问;因此,您可以通过断言实例 as any 来绕过 private 访问检查,或者在编译后直接访问它们。它们也是可枚举的,这意味着它们的名称和值在通过 JSON.stringifyObject.getOwnPropertyNames 序列化时变得可见。简而言之:一旦它们离开类型系统的边界,它们就会像常规的 JavaScript 类成员一样行事。

注意

除了可见性修饰符,还可以将readonly修饰符添加到类属性中。

由于对属性的有限访问是一种不仅在类型系统内合理的特性,ECMAScript 还为常规的 JavaScript 类采用了类似的称为私有字段的概念。

而不是能见度修饰符,私有字段实际上通过在成员名称前面添加井号或哈希的形式引入了新的语法。

提示

引入私有字段的新语法在社区内引发了关于井号的美感和审美的激烈争论。一些参与者甚至称其为可恶的。如果这个增加也让你感到不快,也许将井号看作是一种小围栏,用来保护你不希望所有人都能访问的东西,会让井号语法变得更加令人愉悦。

井号成为属性名称的一部分,这意味着访问时也需要在前面加上这个符号:

class Person {
  #name: string;

  constructor(name: string) {
    this.#name = name;
  }

  // we can use getters!
  get name(): string {
    return this.#name.toUpperCase();
  }
}

const me = new Person("Stefan");
console.log(me.#name);
//              ^
// Property '#name' is not accessible outside
// class 'Person' because it has a private identifier.(18013)

console.log(me.name); // works

私有字段完全是 JavaScript;TypeScript 编译器不会删除任何内容,并且它们保持其功能——在类内部隐藏信息——即使在编译步骤之后。使用最新的 ECMAScript 版本作为目标进行转译后,其结果看起来几乎与 TypeScript 版本相同,只是没有类型注解:

class Person {
  #name;

  constructor(name) {
    this.#name = name;
  }

  get name() {
    return this.#name.toUpperCase();
  }
}

私有字段在运行时代码中无法访问,它们也不可枚举,这意味着它们的内容不会以任何方式泄漏。

现在的问题是 TypeScript 中存在私有可见性修饰符和私有字段。可见性修饰符一直存在,并且与 protected 成员结合使用具有更多的多样性。另一方面,私有字段尽可能接近 JavaScript,并且在 TypeScript 的目标是“JavaScript 语法类型化”时基本命中该标记。那么你应该选择哪一个?

首先,无论你选择哪种修饰符,它们都完成了在编译时告知属性访问是否不应该的目标。这是你得到的第一个反馈,告诉你可能有问题,这也是我们在使用 TypeScript 时的目标。因此,如果需要隐藏外部信息,每个工具都会发挥其作用。

但是当你进一步查看时,这又取决于你的设置。如果你已经设置了具有精细可见性规则的项目,可能无法立即将它们迁移到原生 JavaScript 版本中。此外,JavaScript 中缺少 protected 可见性可能对你的目标造成问题。如果已有的东西运作正常,就没必要进行更改。

如果在运行时可见性出现问题,显示了你想要隐藏的细节:如果你依赖于其他人使用你的代码作为库,他们不应能够访问所有内部信息,则私有字段是正确的选择。它们在浏览器和其他语言运行时中得到了很好的支持,并且 TypeScript 针对较旧的平台提供了 polyfill。

11.2 显式定义方法重写

问题

在你的类层次结构中,你从基类扩展并在子类中重写特定方法。当你重构基类时,你可能会携带旧的、未使用的方法,因为没有任何东西告诉你基类已经改变。

解决方案

打开 noImplicitOverride 标志,并使用 override 关键字来标志重写。

讨论

你希望在画布上绘制形状。你的软件能够接收具有 xy 坐标的点集合,并根据特定的渲染函数,在 HTML 画布上绘制多边形、矩形或其他元素。

你决定使用一个类层次结构,其中基类 Shape 接受一个任意列表的 Point 元素,并在它们之间绘制线条。这个类通过设置器和获取器来处理内务管理,同时也实现了 render 函数本身:

type Point = {
  x: number;
  y: number;
};

class Shape {
  points: Point[];
  fillStyle: string = "white";
  lineWidth: number = 10;

  constructor(points: Point[]) {
    this.points = points;
  }

  set fill(style: string) {
    this.fillStyle = style;
  }

  set width(width: number) {
    this.lineWidth = width;
  }

  render(ctx: CanvasRenderingContext2D) {
    if (this.points.length) {
      ctx.fillStyle = this.fillStyle;
      ctx.lineWidth = this.lineWidth;
      ctx.beginPath();
      let point = this.points[0];
      ctx.moveTo(point.x, point.y);
      for (let i = 1; i < this.points.length; i++) {
        point = this.points[i];
        ctx.lineTo(point.x, point.y);
      }
      ctx.closePath();
      ctx.stroke();
    }
  }
}

要使用它,从 HTML 画布元素创建一个二维上下文,创建一个 Shape 的新实例,并将上下文传递给 render 函数:

const canvas = document.getElementsByTagName("canvas")[0];
const ctx = canvas?.getContext("2d");

const shape = new Shape([
  { x: 50, y: 140 },
  { x: 150, y: 60 },
  { x: 250, y: 140 },
]);
shape.fill = "red";
shape.width = 20;

shape.render(ctx);

现在我们想要使用已建立的基类,并为特定形状(如矩形)派生子类。我们保留内务管理方法,并特别重写 constructorrender 方法:

class Rectangle extends Shape {
  constructor(points: Point[]) {
    if (points.length !== 2) {
      throw Error(`Wrong number of points, expected 2, got ${points.length}`);
    }
    super(points);
  }

  render(ctx: CanvasRenderingContext2D) {
    ctx.fillStyle = this.fillStyle;
    ctx.lineWidth = this.lineWidth;
    let a = this.points[0];
    let b = this.points[1];
    ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
  }
}

使用Rectangle的方式几乎相同:

const rectangle = new Rectangle([
  {x: 130, y: 190},
  {x: 170, y: 250}
]);
rectangle.render(ctx);

随着软件的演变,我们不可避免地会改变类、方法和函数,我们代码库中的某人会将render方法重命名为draw

class Shape {
  // see above

  draw(ctx: CanvasRenderingContext2D) {
    if (this.points.length) {
      ctx.fillStyle = this.fillStyle;
      ctx.lineWidth = this.lineWidth;
      ctx.beginPath();
      let point = this.points[0];
      ctx.moveTo(point.x, point.y);
      for (let i = 1; i < this.points.length; i++) {
        point = this.points[i];
        ctx.lineTo(point.x, point.y);
      }
      ctx.closePath();
      ctx.stroke();
    }
  }
}

事实上,这并不是一个问题,但如果我们在代码中没有使用Rectanglerender方法,也许是因为我们将这个软件发布为库,并没有在我们的测试中使用它,没有任何东西告诉我们Rectangle中的render方法仍然存在,并且与原始类没有任何连接。

这就是为什么 TypeScript 允许你用override关键字注释你想要覆盖的方法。这是一种来自 TypeScript 的语法扩展,将在 TypeScript 将你的代码转译为 JavaScript 时被移除。

当一个方法标记为override关键字时,TypeScript 会确保基类中存在相同名称和签名的方法。如果你将render重命名为draw,TypeScript 会告诉你在基类Shape中没有声明render方法:

class Rectangle extends Shape {
  // see above

  override render(ctx: CanvasRenderingContext2D) {
//         ^
// This member cannot have an 'override' modifier because it
// is not declared in the base class 'Shape'.(4113)
    ctx.fillStyle = this.fillStyle;
    ctx.lineWidth = this.lineWidth;
    let a = this.points[0];
    let b = this.points[1];
    ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
  }
}

这个错误是一个很好的保护措施,确保重命名和重构不会破坏你现有的合约。

注意

即使constructor可以被视为一个被覆盖的方法,它的语义是不同的,并且通过其他规则处理(例如,在实例化子类时确保调用super)。

通过在你的tsconfig.json中打开noImplicitOverrides标志,你可以进一步确保需要用override关键字标记函数。否则,TypeScript 会抛出另一个错误:

class Rectangle extends Shape {
  // see above

  draw(ctx: CanvasRenderingContext2D) {
// ^
// This member must have an 'override' modifier because it
// overrides a member in the base class 'Shape'.(4114)
    ctx.fillStyle = this.fillStyle;
    ctx.lineWidth = this.lineWidth;
    let a = this.points[0];
    let b = this.points[1];
    ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
  }
}
注意

像实现定义类基本形状的接口这样的技术已经提供了一个坚实的基线,可以防止你遇到这类问题。因此,在创建类层次结构时,将override关键字和noImplictOverrides视为额外的保护措施是很好的。

当你的软件需要依赖类层次结构工作时,使用overridenoImplicitAny一起是确保你不会忘记任何事情的好方法。类层次结构,像任何层次结构一样,随着时间的推移往往变得复杂,因此尽可能采取任何保障措施。

11.3 描述构造函数和原型

问题

你想动态实例化特定抽象类的子类,但 TypeScript 不允许你实例化抽象类。

解决方案

使用constructor interface模式描述你的类。

讨论

如果你在 TypeScript 中使用类层次结构,TypeScript 的结构特性有时会妨碍你。例如,看看下面的类层次结构,我们想要根据不同的规则过滤一组元素:

abstract class FilterItem {
  constructor(private property: string) {};
  someFunction() { /* ... */ };
  abstract filter(): void;
}

class AFilter extends FilterItem {
  filter() { /* ... */ }
}

class BFilter extends FilterItem {
  filter() { /* ... */ }
}

FilterItem抽象类需要被其他类实现。在这个例子中,AFilterBFilter,都是FilterItem的具体化,作为过滤器的基线:

const some: FilterItem = new AFilter('afilter'); // ok

当我们不是直接使用实例时,情况变得有趣。假设我们希望根据从 AJAX 调用获取的令牌实例化新的过滤器。为了方便我们选择过滤器,我们将所有可能的过滤器存储在映射中:

declare const filterMap: Map<string, typeof FilterItem>;

filterMap.set('number', AFilter);
filterMap.set('stuff', BFilter);

映射的泛型被设置为一个string(用于从后端获取的令牌)以及与FilterItem类型签名相补充的一切内容。我们在这里使用typeof关键字,以便能够将类添加到映射中,而不是对象。毕竟,我们之后要对它们进行实例化。

到目前为止,一切都按预期进行。问题出现在当你想从映射中获取一个类并创建一个新对象时:

let obj: FilterItem;
// get the constructor
const ctor = filterMap.get('number');

if(typeof ctor !== 'undefined') {
  obj = new ctor();
//          ^
// cannot create an object of an abstract class
}

这是一个问题!在这一点上,TypeScript 只知道我们从filterMap获取了一个FilterItem,我们无法实例化FilterItem。抽象类混合了类型信息(类型命名空间)和实际实现(值命名空间)。首先,让我们看看类型:我们期望从filterMap获取什么?让我们创建一个接口(或类型别名),定义FilterItem形状应该是什么:

interface IFilter {
  new(property: string): IFilter;
  someFunction(): void;
  filter(): void;
}

declare const filterMap: Map<string, IFilter>;

注意new关键字。这是 TypeScript 定义构造函数类型签名的一种方式。如果我们用实际接口替换抽象类,将会出现大量错误。无论将implements IFilter命令放在哪里,似乎都没有任何实现能够满足我们的合同:

abstract class FilterItem implements IFilter { /* ... */ }
// ^
// Class 'FilterItem' incorrectly implements interface 'IFilter'.
// Type 'FilterItem' provides no match for the signature
// 'new (property: string): IFilter'.

filterMap.set('number', AFilter);
//                      ^
// Argument of type 'typeof AFilter' is not assignable
// to parameter of type 'IFilter'. Type 'typeof AFilter' is missing
// the following properties from type 'IFilter': someFunction, filter

这里发生了什么?看起来既不是实现也不是类本身能够获取我们在接口声明中定义的所有属性和函数。为什么?

JavaScript 类很特殊;它们不仅有我们可以轻松定义的一种类型,而是两种:静态侧的类型和实例侧的类型。如果我们将我们的类转译到 ES6 之前的形式,一个构造函数和一个原型,这可能会更清晰:

function AFilter(property) { // this is part of the static side
  this.property = property;  // this is part of the instance side
}

// a function of the instance side
AFilter.prototype.filter = function() {/* ... */}

// not part of our example, but on the static side
Afilter.something = function () { /* ... */ }

一个用来创建对象的类型。一个用来描述对象本身的类型。因此,让我们拆分它,并为它创建两个类型声明:

interface FilterConstructor {
  new (property: string): IFilter;
}

interface IFilter {
  someFunction(): void;
  filter(): void;
}

第一个类型,FilterConstructor,是构造函数接口。这里列出了所有静态属性和构造函数本身。构造函数返回一个实例:IFilterIFilter包含了实例侧的类型信息。我们声明的所有函数。

通过拆分这些内容,我们的后续类型定义也变得更加清晰:

declare const filterMap: Map<string, FilterConstructor>;  /* 1 */

filterMap.set('number', AFilter);
filterMap.set('stuff', BFilter);

let obj: IFilter;  /* 2 */
const ctor = filterMap.get('number');
if(typeof ctor !== 'undefined') {
  obj = new ctor('a');
}
  1. 我们将FilterConstructor类型的实例添加到映射中。这意味着我们只能添加能够产生所需对象的类。

  2. 最终我们想要的是一个IFilter的实例。当使用new调用构造函数时,这就是构造函数返回的内容。

我们的代码再次编译通过,并且我们得到了所有的自动完成和工具支持。更好的是,我们不能将抽象类添加到映射中,因为它们不会产生有效的实例:

filterMap.set('notworking', FilterItem);
//                          ^
// Cannot assign an abstract constructor type to a
// non-abstract constructor type.

构造函数接口模式在整个 TypeScript 和标准库中广泛使用。要有一个概念,请查看lib.es5.d.ts中的ObjectContructor接口。

11.4 在类中使用泛型

问题

TypeScript 的泛型通常设计用于大量推断,但在类中,这并不总是有效。

解决方案

如果无法从参数中推断出泛型类型,请在实例化时显式注释泛型类型;否则,它们默认为 unknown 并接受广泛的值。使用泛型约束和默认参数可以提供额外的安全性。

讨论

类还允许使用泛型。我们不仅可以将泛型类型参数添加到函数中,还可以将泛型类型参数添加到类中。虽然类方法中的泛型类型参数仅在函数范围内有效,但类的泛型类型参数则在整个类中有效。

创建一个集合,这是一个简单的数组包装器,带有一组有限的便利函数。我们可以在 Collection 类定义中添加 T,并在整个类中重复使用这个类型参数:

class Collection<T> {
  items: T[];
  constructor() {
    this.items = [];
  }

  add(item: T) {
    this.items.push(item);
  }

  contains(item: T): boolean {
    return this.items.includes(item);
  }
}

有了这个,我们可以明确地用泛型类型注释替换 T,例如,允许一个仅包含数字或仅包含字符串的集合:

const numbers = new Collection<number>();
numbers.add(1);
numbers.add(2);

const strings = new Collection<string>();
strings.add("Hello");
strings.add("World");

作为开发者,我们并不需要显式地注释泛型类型参数。TypeScript 通常尝试从使用中推断泛型类型。如果我们忘记添加泛型类型参数,TypeScript 会回退到 unknown,允许我们添加任何内容:

const unknowns = new Collection();
unknowns.add(1);
unknowns.add("World");

让我们暂且停留在这一点上。TypeScript 对我们非常诚实。我们构造一个新的 Collection 实例时,我们不知道项目的类型是什么。unknown 是对集合状态最准确的描述。它伴随着所有的缺点:我们可以添加任何内容,并且每次检索值时都需要进行类型检查。尽管 TypeScript 在这一点上只能做出唯一可能的事情,但我们可能希望做得更好。为 Collection 指定一个具体的 T 类型对其正确运行是必不可少的。

让我们看看是否可以依赖推断。TypeScript 对类的推断与对函数的推断完全相同。如果有某种类型的参数,TypeScript 将采用这种类型并替换泛型类型参数。类被设计用于保持状态,并且状态在其使用过程中发生变化。状态还定义了我们的泛型类型参数 T。为了正确推断 T,我们需要在构造时要求一个参数,也许是一个初始值:

class Collection<T> {
  items: T[];
  constructor(initial: T) {
    this.items = [initial];
  }

  add(item: T) {
    this.items.push(item);
  }

  contains(item: T): boolean {
    return this.items.includes(item);
  }
}

// T is number!
const numbersInf = new Collection(0);
numbersInf.add(1);

这样做虽然可行,但在 API 设计方面还有很多不足之处。如果我们没有初始值会怎么样?虽然其他类可能有可以用于推断的参数,但对于包含各种项目的集合来说,这可能并没有太多意义。

对于 Collection,通过注释提供类型是非常重要的。唯一剩下的方法是确保我们不要忘记添加注释。为此,我们可以利用 TypeScript 的泛型默认参数和底部类型 never

class Collection<T = never> {
  items: T[];
  constructor() {
    this.items = [];
  }

  add(item: T) {
    this.items.push(item);
  }

  contains(item: T): boolean {
    return this.items.includes(item);
  }
}

我们将通用类型参数 T 默认设置为 never,这为我们的类添加了一些非常有趣的行为。T 仍然可以通过注释显式地替换为每一种类型,正如以前一样,但一旦我们忘记注释,类型就不是 unknown,而是 never。这意味着我们的集合不兼容任何值,一旦我们尝试添加某些内容就会出现许多错误:

const nevers = new Collection();
nevers.add(1);
//     ^
// Argument of type 'number' is not assignable
// to parameter of type 'never'.(2345)
nevers.add("World");
//     ^
// Argument of type 'string' is not assignable
// to parameter of type 'never'.(2345)

这种后备方法使我们的通用类的使用更加安全。

11.5 决定何时使用类或命名空间

问题

TypeScript 提供了许多面向对象概念的语法,比如命名空间、静态类和抽象类。这些特性在 JavaScript 中并不存在,那么你该怎么办呢?

解决方案

坚持使用命名空间声明进行额外类型声明,尽可能避免抽象类,并优先使用 ECMAScript 模块而不是静态类。

讨论

我们从那些在传统面向对象编程语言(如 Java 或 C#)中工作过的人们那里看到的一件事是,他们倾向于将所有东西包装在一个类中。在 Java 中,因为类是结构化代码的唯一方式,你别无选择。在 JavaScript(因此也是 TypeScript)中,有很多其他可能性可以做到你想要的,而无需任何额外步骤。其中之一是静态类或具有静态方法的类:

// Environment.ts

export default class Environment {
  private static variableList: string[] = []
  static variables(): string[] { /* ... */ }
  static setVariable(key: string, value: any): void  { /* ... */ }
  static getValue(key: string): unknown  { /* ... */ }
}

// Usage in another file
import * as Environment from "./Environment";

console.log(Environment.variables());

虽然这样做有效且(去掉类型注解的话)是有效的 JavaScript,但这对于可以轻松地只是简单、无聊的函数的事情来说太过仪式化了:

// Environment.ts
const variableList: string = []

export function variables(): string[] { /* ... */ }
export function setVariable(key: string, value: any): void  { /* ... */ }
export function getValue(key: string): unknown  { /* ... */ }

// Usage in another file
import * as Environment from "./Environment";

console.log(Environment.variables());

对于用户的接口来说完全一样。您可以访问模块作用域变量,就像您访问类中的静态属性一样,但它们会自动在模块作用域内。您决定导出什么内容和使什么内容可见,而不是一些 TypeScript 字段修饰符。此外,您不会创建一个无用的 Environment 实例。

即使实现变得更简单了。查看 variables() 的类版本:

export default class Environment {
  private static variableList: string[] = [];
  static variables(): string[] {
    return this.variableList;
  }
}

与模块版本相比:

const variableList: string = []

export function variables(): string[] {
  return variableList;
}

没有 this 意味着少考虑。作为附加好处,您的捆绑器在做树摇时更容易,因此最终只会保留您实际使用的东西:

// Only the variables function and variableList
// end up in the bundle
import { variables } from "./Environment";

console.log(variables());

这就是为什么始终优先选择正确的模块而不是具有静态字段和方法的类。那只是增加了一些样板,没有额外的好处。

就像静态类一样,具有 Java 或 C# 背景的人们依恋命名空间,这是 TypeScript 在 ECMAScript 模块标准化之前引入的一个特性,用于组织代码。它们允许你在文件之间分割东西,并用参考标记器将它们合并:

// file users/models.ts
namespace Users {
  export interface Person {
    name: string;
    age: number;
  }
}

// file users/controller.ts

/// <reference path="./models.ts" />
namespace Users {
  export function updateUser(p: Person) {
    // do the rest
  }
}

当时,TypeScript 甚至有一个捆绑功能。它应该仍然有效。但正如注意到的那样,这是在 ECMAScript 引入模块之前。现在有了模块,我们有了一种方法来组织和结构化代码,与 JavaScript 生态系统的其他部分兼容。这是一个优点。

那么我们为什么需要命名空间呢?如果你想要扩展来自第三方依赖项的定义,例如存在于 node 模块中的定义,命名空间仍然是有效的。比如说,你想要扩展全局的JSX命名空间,并确保img元素包含 alt 文本:

declare namespace JSX {
  interface IntrinsicElements {
    "img": HTMLAttributes & {
      alt: string;
      src: string;
      loading?: 'lazy' | 'eager' | 'auto';
    }
  }
}

或者你想要在环境模块中编写复杂的类型定义。但除此之外呢?它的用处就不多了。

命名空间将你的定义封装到一个对象中,写起来像这样:

export namespace Users {
  type User = {
    name: string;
    age: number;
  };

  export function createUser(name: string, age: number): User {
    return { name, age };
  }
}

这会生成一些非常复杂的内容:

export var Users;
(function (Users) {
    function createUser(name, age) {
        return {
            name, age
        };
    }
    Users.createUser = createUser;
})(Users || (Users = {}));

这不仅会增加冗余代码,还会妨碍你的捆绑器正常摇树!使用它们也会变得有些啰嗦:

import * as Users from "./users";

Users.Users.createUser("Stefan", "39");

放弃它们会使事情变得简单得多。坚持使用 JavaScript 提供的功能。在声明文件之外不使用命名空间使你的代码清晰、简洁和整洁。

最后但并非最不重要的,还有抽象类。抽象类是结构化更复杂的类层次结构的一种方式,你可以预定义一些行为,但将一些特性的实际实现留给继承自你的抽象类的类:

abstract class Lifeform {
  age: number;
  constructor(age: number) {
    this.age = age;
  }

  abstract move(): string;
}

class Human extends Lifeform {
  move() {
    return "Walking, mostly...";
  }
}

所有Lifeform的子类都要实现move方法。这是基本上每种基于类的编程语言中都存在的概念。问题在于,JavaScript 并非传统的基于类的语言。例如,像下面这样的抽象类生成了一个有效的 JavaScript 类,但在 TypeScript 中却不允许实例化:

abstract class Lifeform {
  age: number;
  constructor(age: number) {
    this.age = age;
  }
}

const lifeform = new Lifeform(20);
//               ^
// Cannot create an instance of an abstract class.(2511)

如果你写普通的 JavaScript 但依赖于 TypeScript 来提供隐式文档形式的信息,比如函数定义看起来像这样,那么这可能会导致一些不需要的情况:

declare function moveLifeform(lifeform: Lifeform);
  • 你或者你的用户可能会将其视为将Lifeform对象传递给moveLifeform的邀请。在内部,它调用lifeform.move()

  • Lifeform可以在 JavaScript 中被实例化,因为它是一个有效的类。

  • 方法moveLifeform中不存在,因此破坏了你的应用程序!

这是由于一种错误的安全感。实际上你想要的是将一些预定义的实现放在原型链中,并且有一个合同告诉你应该期望什么:

interface Lifeform {
  move(): string;
}

class BasicLifeForm {
  age: number;
  constructor(age: number) {
    this.age = age;
  }
}

class Human extends BasicLifeForm implements Lifeform {
  move() {
    return "Walking";
  }
}

当你查找Lifeform时,你可以看到它的接口和它所期望的一切,但你很少会遇到意外实例化错误类的情况。

说了这么多关于何时使用类和命名空间,那么何时应该使用它们呢?每当你需要同一个对象的多个实例,其中内部状态对对象功能至关重要时。

11.6 编写静态类

问题

基于类的面向对象编程教导你使用静态类来实现某些功能,但你想知道这些原则在 TypeScript 中是如何支持的。

解决方案

在 TypeScript 中传统的静态类不存在,但 TypeScript 对类成员有多种目的的静态修饰符。

讨论

静态类是不能实例化为具体对象的类。它们的目的是包含方法和其他成员,在代码的各个点访问时是相同的。静态类在只有类作为抽象手段的编程语言中是必需的,例如 Java 或 C#。在 JavaScript 以及随后的 TypeScript 中,有更多表达自己的方式。

在 TypeScript 中,我们不能声明类为 static,但可以在类上定义 static 成员。行为如预期:方法或属性不是对象的一部分,但可以从类本身访问。

正如我们在 Recipe 11.5 中看到的,只有静态成员的类在 TypeScript 中是一个反模式。函数存在;您可以每个模块保持状态。导出函数和模块范围的条目的组合通常是最佳选择:

// Anti-Pattern
export default class Environment {
  private static variableList: string[] = []
  static variables(): string[] { /* ... */ }
  static setVariable(key: string, value: any): void  { /* ... */ }
  static getValue(key: string): unknown  { /* ... */ }
}

// Better: Module-scoped functions and variables
const variableList: string = []

export function variables(): string[] { /* ... */ }
export function setVariable(key: string, value: any): void  { /* ... */ }
export function getValue(key: string): unknown  { /* ... */ }

但类的 static 部分仍然有用。我们在 Recipe 11.3 中建立了一个类由静态成员和动态成员组成的观点。

constructor 是类的静态特征的一部分,而属性和方法是类的动态特征的一部分。通过 static 关键字,我们可以添加这些静态特征。

让我们想象一个名为 Point 的类,描述二维空间中的一个点。它有 xy 坐标,并且我们创建一个方法来计算此点与另一个点之间的距离:

class Point {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }

  distanceTo(point: Point): number {
    const dx = this.x - point.x;
    const dy = this.y - point.y;
    return Math.sqrt(dx * dx + dy * dy);
  }
}

const a = new Point(0, 0);
const b = new Point(1, 5);

const distance = a.distanceTo(b);

这是一个良好的行为,但是如果我们选择一个起点和终点可能会感觉有点奇怪,尤其是因为距离无论哪一个先都是相同的。Point 上的静态方法消除了顺序问题,我们有一个漂亮的 distance 方法,接受两个参数:

class Point {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }

  distanceTo(point: Point): number {
    const dx = this.x - point.x;
    const dy = this.y - point.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  static distance(p1: Point, p2: Point): number {
    return p1.distanceTo(p2);
  }
}

const a = new Point(0, 0);
const b = new Point(1, 5);

const distance = Point.distance(a, b);

在 JavaScript 的 ECMAScript 类之前使用构造函数/原型模式的类似版本如下所示:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

Point.prototype.distanceTo = function(p) {
  const dx = this.x - p.x;
  const dy = this.y - p.y;
  return Math.sqrt(dx * dx + dy * dy);
}

Point.distance = function(a, b) {
  return a.distanceTo(b);
}

就像在 Recipe 11.3 中一样,我们可以轻松看到哪些部分是静态的,哪些部分是动态的。所有在 原型 中的东西属于动态部分。其他一切都是 静态 的。

但类不仅仅是构造函数/原型模式的语法糖。通过包含私有字段(在常规对象中不存在),我们可以做一些实际与类及其实例相关的事情。

如果我们希望隐藏 distanceTo 方法,因为它可能会令人困惑,我们更希望用户使用静态方法,只需在 distanceTo 前加上简单的私有修饰符,即可使其从外部无法访问,但仍然可以从静态成员中访问:

class Point {
  x: number;
  y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }

  #distanceTo(point: Point): number {
    const dx = this.x - point.x;
    const dy = this.y - point.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  static distance(p1: Point, p2: Point): number {
    return p1.#distanceTo(p2);
  }
}

可见性也是反过来的。假设您有一个表示系统中某个 Task 的类,并且希望限制现有任务的数量。

我们使用一个名为nextId的静态私有字段,我们从0开始,并且我们增加这个私有字段以每个构造的实例Task。如果达到100,我们会抛出一个错误:

class Task {
  static #nextId = 0;
  #id: number;

  constructor() {
    if (Task.#nextId > 99) {
      throw "Max number of tasks reached";
    }
    this.#id = Task.#nextId++;
  }
}

如果我们希望通过后端的动态值来限制实例的数量,我们可以使用一个static实例化块来获取这些数据,并相应地更新静态私有字段:

type Config = {
  instances: number;
};

class Task {
  static #nextId = 0;
  static #maxInstances: number;
  #id: number;

  static {
    fetch("/available-slots")
      .then((res) => res.json())
      .then((result: Config) => {
        Task.#maxInstances = result.instances;
      });
    }

  constructor() {
    if (Task.#nextId > Task.#maxInstances) {
      throw "Max number of tasks reached";
    }
    this.#id = Task.#nextId++;
  }
}

与实例中的字段不同,在写作时,TypeScript 不会检查静态字段是否已被实例化。例如,如果我们从后端异步加载可用槽的数量,则在此期间我们可以构造实例,但不能检查是否达到了最大值。

因此,即使在 TypeScript 中没有静态类的构造,并且静态类仅被认为是反模式,但在许多情况下静态成员可能会有良好的用途。

11.7 使用严格属性初始化

问题

类保留状态,但没有任何信息告诉你这个状态是否已被初始化。

解决方案

通过在tsconfig中将strictPropertyInitialization设置为true来激活严格属性初始化。

讨论

类可以被视为用于创建对象的代码模板。您定义属性和方法,只有通过实例化才会分配实际的值。TypeScript 类将基本的 JavaScript 类与更多用于定义类型的语法相结合。例如,TypeScript 允许您以类似类型或接口的方式定义实例的属性:

type State = "active" | "inactive";

class Account {
  id: number;
  userName: string;
  state: State;
  orders: number[];
}

然而,这种标注只是定义了结构:它并没有设置任何具体的值。当被转译成常规 JavaScript 时,所有这些属性都会被擦除;它们只存在于类型命名空间中。

这种标注显然非常易读,并给开发者一个很好的想法,可以期望哪些属性。但不能保证这些属性实际存在。如果我们不初始化它们,一切都是缺失的或undefined

TypeScript 对此有保护措施。通过在tsconfig.json中将strictPropertyInitialization标志设置为true,TypeScript 将确保在从类创建新对象时,所有你期望的属性都被初始化。

注意

strictPropertyInitialization是 TypeScript 的strict模式的一部分。如果你在tsconfig中将strict设置为true——这是推荐的做法——你也会激活严格属性初始化。

一旦激活了这个选项,TypeScript 将在许多地方用红色波浪线向你打招呼:

class Account {
  id: number;
// ^ Property 'id' has no initializer and is
// not definitely assigned in the constructor.(2564)
  userName: string;
// ^ Property 'userName' has no initializer and is
// not definitely assigned in the constructor.(2564)
  state: State;
// ^ Property 'state' has no initializer and is
// not definitely assigned in the constructor.(2564)
  orders: number[];
// ^ Property 'orders' has no initializer and is
// not definitely assigned in the constructor.(2564)
}

太棒了!现在我们要确保每个属性都能收到一个值。有多种方法可以做到这一点。如果我们看一下Account示例,我们可以定义一些约束或规则,如果我们的应用程序域允许的话:

  • 需要设置iduserName;它们控制与我们的后端通信并且在显示时是必需的。

  • state也需要设置,但其默认值为active。通常情况下,我们软件中的账户是活跃的,除非有意将其设置为inactive

  • orders是一个包含订单 ID 的数组,但如果我们什么都没订购怎么办?一个空数组同样有效,或者也许我们将orders设置为尚未定义。

鉴于这些限制,我们已经可以排除两个错误。我们将state默认设置为active,并且我们使orders变为可选。还有可能将orders设置为number[] | undefined类型,这与可选相同:

class Account {
  id: number; // still errors
  userName: string; // still errors
  state: State = "active"; // ok
  orders?: number[]; // ok
}

另外两个属性仍然会报错。通过添加一个constructor并初始化这些属性,我们也排除了其他错误:

class Account {
  id: number;
  userName: string;
  state: State = "active";
  orders?: number[];

  constructor(userName: string, id: number) {
    this.userName = userName;
    this.id = id;
  }
}

这就是一个合适的 TypeScript 类!TypeScript 还允许使用构造函数的简写形式,通过添加publicprivateprotected等可见性修饰符,你可以将构造函数参数转换为具有相同名称和值的类属性。这是一个方便的功能,可以消除大量样板代码。重要的是不要在类形状中重新定义相同的属性:

class Account {
  state: State = "active";
  orders?: number[];

  constructor(public userName: string, public id: number) {}
}

如果你现在查看这个类,你会发现我们仅依赖于 TypeScript 的特性。转译后的类,即 JavaScript 等效物,看起来大不相同:

class Account {
  constructor(userName, id) {
    this.userName = userName;
    this.id = id;
    this.state = "active";
  }
}

一切都在constructor里面,因为constructor定义了一个实例。

警告

尽管 TypeScript 的类快捷方式和语法看起来不错,但要小心不要过度依赖它们。最近几年,TypeScript 转变成了主要是在常规 JavaScript 上的类型语法扩展,但它们多年来存在的类特性仍然可用,并为您的代码添加了与您期望的不同的语义。如果你倾向于你的代码是“带有类型的 JavaScript”,那么当你进入 TypeScript 类特性的深处时要小心。

严格的属性初始化还理解复杂的情况,比如在通过constructor调用的函数内部设置属性。它还理解,异步类可能会使你的类处于潜在的未初始化状态。

假设你只需通过一个id属性初始化你的类,并从后端获取userName。如果你在构造函数内部进行异步调用,并在fetch调用完成后设置userName,你仍然会得到严格的属性初始化错误:

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

class Account {
  userName: string;
// ^ Property 'userName' has no initializer and is
// not definitely assigned in the constructor.(2564)
  state: State = "active";
  orders?: number[];

  constructor(public id: number) {
    fetch(`/api/getName?id=${id}`)
      .then((res) => res.json())
      .then((data: User) => (this.userName = data.userName ?? "not-found"));
  }
}

而这是真的!没有什么能告诉你fetch调用会成功,即使你捕获错误并确保属性会被初始化为一个备用值,你的对象在未初始化userName状态时也有一定时间的未初始化状态。

你可以做一些事情来解决这个问题。一个好的模式是有一个静态工厂函数,它异步工作,首先获取数据,然后调用期望两个属性的构造函数:

class Account {
  state: State = "active";
  orders?: number[];

  constructor(public id: number, public userName: string) {}

  static async create(id: number) {
    const user: User = await fetch(`/api/getName?id=${id}`).then((res) =>
      res.json()
    );
    return new Account(id, user.userName);
  }
}

这允许在非异步上下文中实例化两个对象,如果你可以访问这两个属性,或者在异步上下文中如果只有id可用。我们转换职责并完全从构造函数中删除async

另一种技术是简单地忽略未初始化的状态。如果userName的状态对你的应用程序完全不相关,并且你只在需要时访问它,使用definite assignment assertion(感叹号)告诉 TypeScript 你将把这个属性视为已初始化:

class Account {
  userName!: string;
  state: State = "active";
  orders?: number[];

  constructor(public id: number) {
    fetch(`/api/getName?id=${id}`)
      .then((res) => res.json())
      .then((data: User) => (this.userName = data.userName));
  }
}

现在责任在你手上,有了感叹号你可以把 TypeScript 特定的语法称为不安全操作,包括运行时错误。

11.8 在类中使用这些类型

问题

你从基类扩展以重用功能,并且你的方法具有引用相同类实例的签名。你希望确保没有其他子类混入你的接口,但是你不想重写方法只是为了改变类型。

解决方案

使用this作为类型而不是实际的类类型。

讨论

在这个例子中,我们想使用类来模拟公告板软件中不同用户角色。我们从一个通用的User类开始,它通过用户 ID 进行标识,并具有打开主题的能力:

class User {
  #id: number;
  static #nextThreadId: number;

  constructor(id: number) {
    this.#id = id;
  }

  equals(user: User): boolean {
    return this.#id === user.#id;
  }

  async openThread(title: string, content: string): Promise<number> {
    const threadId = User.#nextThreadId++;
    await fetch("/createThread", {
      method: "POST",
      body: JSON.stringify({
        content,
        title,
        threadId,
      }),
    });
    return threadId;
  }
}

这个类还包含一个equals方法。在我们的代码库中的某个地方,我们需要确保两个用户引用是相同的,由于我们通过他们的 ID 来标识用户,我们可以轻松地比较数字。

User是所有用户的基类,因此如果我们添加具有更多特权的角色,我们可以轻松地继承基础User类。例如,Admin具有关闭主题的能力,并且它存储了一组其他我们可能在其他方法中使用的权限。

注意

编程社区对继承是否是一种更好的技术存在很多争论,因为它的好处很少超过其缺点。尽管如此,JavaScript 的某些部分依赖于继承,比如 Web 组件。

由于我们从User继承,我们无需编写另一个openThread方法,我们可以重用相同的equals方法,因为所有管理员也是用户:

class Admin extends User {
  #privileges: string[];
  constructor(id: number, privileges: string[] = []) {
    super(id);
    this.#privileges = privileges;
  }

  async closeThread(threadId: number) {
    await fetch("/closeThread", {
      method: "POST",
      body: "" + threadId,
    });
  }
}

设置完我们的类之后,我们可以通过实例化正确的类来创建UserAdmin类型的新对象。我们还可以调用equals方法来比较两个用户是否可能相同:

const user = new User(1);
const admin = new Admin(2);

console.log(user.equals(admin));
console.log(admin.equals(user));

有一件事令人困扰:比较的方向。当然,比较两个数字是可交换的;如果我们比较一个user和一个admin,这应该没问题,但是如果我们考虑周围的类和子类型,还是有改进的空间:

  • 如果我们检查一个user是否等于一个admin,这是可以的,因为它可能获得特权。

  • 如果我们希望一个admin等于一个user,这是令人怀疑的,因为更广泛的超类型具有更少的信息。

  • 如果我们有Admin相邻的另一个Moderator子类,我们绝对不希望能够将它们作为它们在基类之外不共享属性的比较。

仍然,在当前equals的开发方式下,所有比较都可以工作。我们可以通过改变我们想要比较的类型来解决这个问题。我们首先用User标注了输入参数,但实际上我们想要比较同类型的另一个实例。有一个类型可以做到这一点,那就是this

class User {
  // ...

  equals(user: this): boolean {
    return this.#id === user.#id;
  }
}

这与我们从函数中了解到的可以擦除的this参数不同,我们在 Recipe 2.7 中学习过,因为this参数类型允许我们在函数范围内设置this全局变量的具体类型。this类型是对方法所在类的引用。并且随着实现的变化而变化。因此,如果我们在User中用this注释一个user,它在从User继承的类中变成一个Admin,或者一个Moderator,等等。因此,admin.equals期望与之比较的是另一个Admin类;否则,我们会得到一个错误:

console.log(admin.equals(user));
//                       ^
// Argument of type 'User' is not assignable to parameter of type 'Admin'.

反过来也可以工作。因为Admin包含了所有User的属性(毕竟它是一个子类),我们可以轻松地比较user.equals(admin)

this类型也可以用作返回类型。看看这个实现构建器模式OptionBuilder

class OptionBuilder<T = string | number | boolean> {
  #options: Map<string, T> = new Map();
  constructor() {}

  add(name: string, value: T): OptionBuilder<T> {
    this.#options.set(name, value);
    return this;
  }

  has(name: string) {
    return this.#options.has(name);
  }

  build() {
    return Object.fromEntries(this.#options);
  }
}

它是Map的一个轻量包装,允许我们设置键值对。它具有链式接口,这意味着在每个add调用之后,我们都会得到当前实例的返回,允许我们在add调用之后进行另一个add调用。注意我们用OptionBuilder<T>标注了返回类型:

const options = new OptionBuilder()
  .add("deflate", true)
  .add("compressionFactor", 10)
  .build();

现在我们正在创建一个继承自OptionBuilder并将可能元素类型设置为stringStringOptionBuilder。我们还添加了一个safeAdd方法,用于在写入之前检查是否已经设置了某个特定值,以避免覆盖先前的设置:

class StringOptionBuilder extends OptionBuilder<string> {
  safeAdd(name: string, value: string) {
    if (!this.has(name)) {
      this.add(name, value);
    }
    return this;
  }
}

当我们开始使用新的构建器时,我们发现如果作为第一步有add,我们就不能合理地使用safeAdd

const languages = new StringOptionBuilder()
  .add("en", "English")
  .safeAdd("de", "Deutsch")
// ^
// Property 'safeAdd' does not exist on type 'OptionBuilder<string>'.(2339)
  .safeAdd("de", "German")
  .build();

TypeScript 告诉我们,在类型为OptionBuilder<string>的情况下,safeAdd不存在。这个函数去哪了?问题在于add有一个非常宽泛的注释。当然,StringOptionBuilderOptionBuilder<string>的子类型,但是有了注释,我们失去了更窄类型的信息。解决方案?使用this作为返回类型:

class OptionBuilder<T = string | number | boolean> {
  // ...

  add(name: string, value: T): this {
    this.#options.set(name, value);
    return this;
  }
}

与前面的例子效果相同。在OptionBuilder<T>中,this变成了OptionBuilder<T>。在StringBuilder中,this变成了StringBuilder。如果你返回this并省略返回类型注释,this就成为了推断的返回类型。因此,明确使用this取决于你的偏好(参见 Recipe 2.1)。

11.9 编写装饰器

问题

你希望记录你的方法执行情况以进行遥测,但是对每个方法手动添加日志很麻烦。

解决方案

编写一个名为 log 的类方法装饰器来注解您的方法。

讨论

装饰器设计模式在埃里克·伽马等人(Addison-Wesley)的著名书籍《设计模式:可复用面向对象软件的元素》中有所描述,并描述了一种可以动态添加或覆盖某些行为的技术。

起初作为面向对象编程中自然出现的设计模式,如今变得如此流行,以至于支持面向对象特性的编程语言都添加了装饰器作为一种语言特性,并使用了特殊的语法。在 Java(称为注解)或 C#(称为属性)以及 JavaScript 中都可以看到其形式。

ECMAScript 关于装饰器的提案已经在提案阶段 3(准备实施阶段)中停留了相当长的时间,但在 2022 年达到了阶段 3,并且随着所有功能达到阶段 3,TypeScript 是第一个采纳新规范的工具之一。

警告

在 TypeScript 中,装饰器已经存在很长时间,使用 experimentalDecorators 编译器标志。随着 TypeScript 5.0 的推出,原生的 ECMAScript 装饰器提案完全实现并且无需标志。实际的 ECMAScript 实现与原始设计在根本上有所不同,如果您在 TypeScript 5.0 之前开发过装饰器,它们将无法与新规范一起使用。请注意,打开 experimentalDecorators 标志会关闭 ECMAScript 原生装饰器。此外,关于类型,lib.decorators.d.ts 包含 ECMAScript 原生装饰器的所有类型信息,而 lib.decorators.legacy.d.ts 中的类型包含旧的类型信息。确保您的设置正确,并且不要从错误的定义文件中使用类型。

装饰器允许我们在类中几乎任何地方进行装饰。对于这个例子,我们希望从一个方法装饰器开始,允许我们记录方法调用的执行。

装饰器被描述为具有 valuecontext 的函数,这两者都取决于您想装饰的类元素的类型。这些装饰器函数返回另一个函数,在您自己的方法之前执行(或在字段初始化之前,或在访问器调用之前等)。

方法的一个简单的 log 装饰器可能如下所示:

function log(value: Function, context: ClassMethodDecoratorContext) {
  return function (this: any, ...args: any[]) {
    console.log(`calling ${context.name.toString()}`);
    return value.call(this, ...args);
  };
}

class Toggler {
  #toggled = false;

  @log
  toggle() {
    this.#toggled = !this.#toggled;
  }
}

const toggler = new Toggler();
toggler.toggle();

log 函数遵循原始装饰器提案中定义的 ClassMethodDecorator 类型:

type ClassMethodDecorator = (value: Function, context: {
  kind: "method";
  name: string | symbol;
  access: { get(): unknown };
  static: boolean;
  private: boolean;
  addInitializer(initializer: () => void): void;
}) => Function | void;

许多装饰器上下文类型可用。lib.decorator.d.ts 定义了以下装饰器:

type ClassMemberDecoratorContext =
    | ClassMethodDecoratorContext
    | ClassGetterDecoratorContext
    | ClassSetterDecoratorContext
    | ClassFieldDecoratorContext
    | ClassAccessorDecoratorContext
    ;

/**
 * The decorator context types provided to any decorator.
 */
type DecoratorContext =
    | ClassDecoratorContext
    | ClassMemberDecoratorContext
    ;

您可以从名称中准确地看出它们目标类的哪个部分。

请注意,我们还没有编写详细的类型。我们大多数时候使用 any,主要是因为类型可能变得非常复杂。如果我们想为所有参数添加类型,我们需要大量使用泛型:

function log<This, Args extends any[], Return>(
  value: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext
): (this: This, ...args: Args) => Return {
  return function (this: This, ...args: Args) {
    console.log(`calling ${context.name.toString()}`);
    return value.call(this, ...args);
  };
}

泛型类型参数对于描述我们要传递的方法是必要的。我们想捕捉以下类型:

  • Thisthis参数类型的通用类型参数(参见 Recipe 2.7)。我们需要将this设置为在对象实例上下文中运行装饰器。

  • 然后我们有方法的参数作为Args。正如我们在 Recipe 2.4 中学到的那样,一个方法或函数的参数可以描述为一个元组。

  • 最后但同样重要的是Return类型参数。该方法需要返回特定类型的值,我们需要指定这一点。

有了这三者,我们能够以最通用的方式描述输入方法和输出方法,适用于所有类。我们可以使用泛型约束来确保我们的装饰器只在某些情况下工作,但对于log,我们希望能够记录每个方法调用。

注意

在撰写本文时,TypeScript 中的 ECMAScript 装饰器还比较新。随着时间的推移,类型信息会变得更好,因此您获取的类型信息可能已经好得多。

我们还想要在constructor方法被调用之前记录我们的类字段及其初始值:

class Toggler {
  @logField #toggled = false;

  @log
  toggle() {
    this.#toggled = !this.#toggled;
  }
}

为此,我们创建另一个名为logField的装饰器,它作用于ClassFieldDecoratorContext装饰器提案如下描述了用于类字段的装饰器:

type ClassFieldDecorator = (value: undefined, context: {
  kind: "field";
  name: string | symbol;
  access: { get(): unknown, set(value: unknown): void };
  static: boolean;
  private: boolean;
}) => (initialValue: unknown) => unknown | void;

注意valueundefined。初始值被传递给替换方法:

type FieldDecoratorFn = (val: any) => any;

function logField<Val>(
  value: undefined,
  context: ClassFieldDecoratorContext
): FieldDecoratorFn {
  return function (initialValue: Val): Val {
    console.log(`Initializing ${context.name.toString()} to ${initialValue}`);
    return initialValue;
  };
}

有一件事感觉不对劲。为什么我们需要为不同类型的成员使用不同的装饰器?我们的log装饰器难道不能处理所有情况吗?我们的装饰器在特定装饰器上下文中被调用,我们可以通过kind属性(我们在 Recipe 3.2 中看到的模式)来识别正确的上下文,因此编写一个根据上下文进行不同装饰器调用的log函数是非常简单的,对吧?

嗯,是的也不是。当然,有一个正确分支的包装器函数是正确的方法,但是正如我们所见,类型定义非常复杂。要找到能处理它们所有的一个函数签名几乎是不可能的,除非到处都用any。请记住:我们需要正确的函数签名类型;否则,装饰器将无法与类成员一起工作。

多个不同的函数签名只会引起函数重载。因此,我们不是为所有可能的装饰器找到一个函数签名,而是为字段装饰器方法装饰器等创建重载。在这里,我们可以像单个装饰器一样对它们进行类型化。实现的函数签名采用any作为value,并将所有必需的装饰器上下文类型汇总到一个联合中,以便我们之后进行适当的区分检查:

function log<This, Args extends any[], Return>(
  value: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext
): (this: This, ...args: Args) => Return;
function log<Val>(
  value: Val,
  context: ClassFieldDecoratorContext
): FieldDecoratorFn;
function log(
  value: any,
  context: ClassMethodDecoratorContext | ClassFieldDecoratorContext
) {
  if (context.kind === "method") {
    return logMethod(value, context);
  } else {
    return logField(value, context);
  }
}

而不是把所有实际代码都弄到if分支中,我们宁愿调用原始方法。如果您不想将logMethodlogField函数暴露出来,那么可以将它们放在一个模块中,只导出log

提示

有很多不同类型的装饰器,它们都有些许不同的各种字段。lib.decorators.d.ts中的类型定义非常好,但如果您需要更多信息,请查看TC39 提案中的原始装饰器提案。它不仅包含所有类型装饰器的广泛信息,还包含完整的 TypeScript 类型定义,从而完整地展示了整个画面。

我们还想做最后一件事:调整logMethod以在调用之前和之后都记录日志。对于普通方法来说,暂时存储返回值就很容易:

function log<This, Args extends any[], Return>(
  value: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext
) {
  return function (this: This, ...args: Args) {
    console.log(`calling ${context.name.toString()}`);
    const val = value.call(this, ...args);
    console.log(`called ${context.name.toString()}: ${val}`);
    return val;
  };
}

但对于异步方法,事情变得更加有趣。调用异步方法会产生一个PromisePromise本身可能已经执行完毕,或者执行被推迟到以后。这意味着如果我们继续使用之前的实现,called日志消息可能会在方法实际产生值之前出现。

作为一种解决方法,我们需要将日志消息链式化为Promise产生结果后的下一步。为此,我们需要检查方法是否实际上是一个Promise。JavaScript 的 Promises 很有趣,因为它们只需具有then方法就可以被等待。这是我们可以在辅助方法中检查的内容:

function isPromise(val: any): val is Promise<unknown> {
  return (
    typeof val === "object" &&
    val &&
    "then" in val &&
    typeof val.then === "function"
  );
}

有了这个,我们根据是否有Promise来决定是直接记录日志还是延迟记录:

function logMethod<This, Args extends any[], Return>(
  value: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext
): (this: This, ...args: Args) => Return {
  return function (this: This, ...args: Args) {
    console.log(`calling ${context.name.toString()}`);
    const val = value.call(this, ...args);
    if (isPromise(val)) {
      val.then((p: unknown) => {
        console.log(`called ${context.name.toString()}: ${p}`);
        return p;
      });
    } else {
      console.log(`called ${context.name.toString()}: ${val}`);
    }

    return val;
  };
}

修饰器可以变得非常复杂,但最终它们是使 JavaScript 和 TypeScript 中的类更具表现力的有用工具。

^(1) C#和 TypeScript 都由 Microsoft 制作,Anders Hejlsberg 在这两种编程语言中都有很大的参与。

第十二章:类型开发策略

到目前为止,所有的配方都处理了 TypeScript 编程语言及其类型系统的特定方面。您已经学习了如何在第二章和第三章中有效地使用基本类型,通过第 4 章中的泛型使您的代码更可重用,以及在第 5 章中使用条件类型和第 6 章中的字符串模板字面量类型,以及第 7 章中的可变元组类型,来为非常微妙的情况创建高级类型。

我们在第 8 章中建立了一组辅助类型,并在第 9 章中解决了标准库的限制。我们学习了如何在第 10 章中使用 JSX 作为语言扩展,并了解了何时以及如何使用类在第 11 章中。每个配方都详细讨论了每种方法的利弊,为您提供更好的工具,以便在每种情况下做出正确的决策,创建更好的类型,更健壮的程序和稳定的开发流程。

那太多了!然而,还有一件事缺失,这是将所有事情整合在一起的最后一部分:我们如何应对新的类型挑战?我们从哪里开始?我们需要注意什么?

这些问题的答案构成了本章的内容。在这里,您将了解低维护类型的概念。我们将探讨一个过程,您可以从简单的类型开始,逐渐变得更加精炼和强大。您将了解到TypeScript playground的秘密功能以及如何处理使验证更加容易的库。您将找到指南,帮助您做出艰难的决策,并了解如何处理在 TypeScript 旅程中肯定会遇到的最常见但难以解决的类型错误的解决方法。

如果本书的其余部分使您从新手成为学徒,那么接下来的教程将带领您成为专家。欢迎来到最后一章。

12.1 编写低维护类型

问题

每当模型发生变化时,您需要触及代码库中的十几种类型。这很烦人,而且很容易漏掉一些东西。

解决方案

从其他类型中派生类型,通过使用推断,创建低维护的类型。

讨论

在本书的整个过程中,我们花了大量时间从其他类型创建类型。一旦我们可以从已经存在的某物中派生出类型,这意味着我们花费更少的时间编写和适应类型信息,而是花更多的时间修复 JavaScript 中的错误和错误。

TypeScript 是建立在 JavaScript 之上的元信息层。我们的目标仍然是编写 JavaScript,但尽可能使其健壮且易于使用:工具帮助您保持高效,并且不会妨碍您。

这就是我一般写 TypeScript 的方式:我写常规的 JavaScript,而当 TypeScript 需要额外的信息时,我很乐意添加一些额外的注释。有一个条件:我不想被打扰去维护类型。我宁愿创建能够在其依赖项或周围发生变化时更新自己的类型。我称这种方法为创建低维护类型

创建低维护类型是一个由三个部分组成的过程:

  1. 根据您的数据模型或从现有模型中推断。

  2. 定义派生(映射类型,部分类型等)。

  3. 使用条件类型定义行为。

让我们来看看这个简短而不完整的copy函数。我想把文件从一个目录复制到另一个目录。为了让我的生活更轻松,我创建了一组默认选项,这样我就不必重复自己太多:

const defaultOptions = {
  from: "./src",
  to: "./dest",
};

function copy(options) {
  // Let's merge default options and options
  const allOptions = { ...defaultOptions, ...options};

  // todo: Implementation of the rest
}

这是你在 JavaScript 中经常看到的一种模式。你立即看到的是,TypeScript 缺少某些类型信息。特别是copy函数的options参数目前是any。所以让我们为其添加一个类型!

我可以显式地创建类型:

type Options = {
  from: string;
  to: string;
};

const defaultOptions: Options = {
  from: "./src",
  to: "./dest",
};

type PartialOptions = {
  from?: string;
  to?: string;
};

function copy(options: PartialOptions) {
  // Let's merge default options and options
  const allOptions = { ...defaultOptions, ...options};

  // todo: Implementation of the rest
}

这是一个合理的方法。您考虑类型,然后分配类型,然后获得所有您习惯的编辑器反馈和类型检查。但是如果有什么变化呢?假设我们向Options添加了另一个字段;我们将不得不三次调整我们的代码:

type Options = {
  from: string;
  to: string;
  overwrite: boolean; // added
};

const defaultOptions: Options = {
  from: "./src",
  to: "./dest",
  overwrite: true, // added
};

type PartialOptions = {
  from?: string;
  to?: string;
  overwrite?: boolean; // added
};

但是为什么呢?信息已经在那里!在defaultOptions中,我们告诉 TypeScript 确切地我们正在寻找什么。让我们优化:

  1. 放弃PartialOptions类型,使用实用类型Partial<T>可以达到同样效果。你可能已经猜到这一点了。

  2. 在 TypeScript 中使用typeof运算符即可即时创建新类型:

const defaultOptions = {
  from: "./src",
  to: "./dest",
  overwrite: true,
};

function copy(options: Partial<typeof defaultOptions>) {
  // Let's merge default options and options
  const allOptions = { ...defaultOptions, ...options};

  // todo: Implementation of the rest
}

就是这样。只需在需要告诉 TypeScript 我们正在寻找什么的地方添加注释:

  • 如果我们添加新字段,我们根本不需要维护任何内容。

  • 如果我们重命名一个字段,我们就会得到仅仅我们关心的信息:所有我们必须更改传递给函数的选项的copy的使用。

  • 我们只有一个真正的事实来源:实际的defaultOptions对象。这是唯一重要的对象,因为它是我们在运行时拥有的唯一信息。

我们的代码变得更加简洁。TypeScript 变得不那么侵入性,更符合我们编写 JavaScript 的方式。

另一个例子是从一开始就伴随我们的:在第 3.1 节的配方中开始的玩具店,以及在第 4.5 节和第 5.3 节的配方中继续。重新访问所有三个项目,并考虑如何仅通过更改模型来获取所有其他类型的更新。

12.2 逐步细化类型

问题

您的 API 需要精心设计的类型,使用泛型、条件类型和字符串模板字面量类型等高级功能。您不知道从哪里开始。

解决方案

逐步完善您的类型。从基本的原始类型和对象类型开始,逐步添加泛型,然后深入高级类型。本课程描述的过程将帮助您制作类型。这也是回顾您学到的所有内容的好方法。

讨论

看看下面的例子:

app.get("/api/users/:userID", function (req, res) {
  if (req.method === "POST") {
    res.status(20).send({
      message: "Got you, user " + req.params.userId,
    });
  }
});

我们有一个 Express 风格的服务器,它允许我们定义一个路由(或路径),并在请求该 URL 时执行回调。

回调函数接受两个参数:

请求对象

这里我们获取使用的 HTTP 方法的信息 — 例如 GETPOSTPUTDELETE — 以及传入的附加参数。在这个示例中,userID 应该映射到包含用户标识符的参数 userID

响应或回复对象

在这里,我们希望为客户端准备一份适当的服务器响应。我们希望发送正确的状态码(使用 status 方法)并通过网络发送 JSON 输出。

此示例中显示的内容大大简化,但很好地展示了我们要做的事情。前一个示例也充满了错误!看看:

app.get("/api/users/:userID", function (req, res) {
  if (req.method === "POST") { /* Error 1 */
    res.status(20).send({ /* Error 2 */
      message: "Welcome, user " + req.params.userId /* Error 3 */,
    });
  }
});

三行实现代码和三个错误?到底发生了什么?

  1. 第一个错误有些微妙。虽然我们告诉我们的应用程序我们想监听 GET 请求(因此使用 app.get),但我们只有在请求方法是 POST 时才做某事。在我们应用程序的这个特定点上,req.method 不可能是 POST。因此我们永远不会发送任何响应,这可能导致意外的超时。

  2. 显式发送状态码是个好习惯!20 并不是一个有效的状态码。客户端可能无法理解发生了什么。

  3. 这是我们想要发送的响应。我们访问解析的参数,但出现了拼写错误。应该是 userID,而不是 userId。我们所有的用户都会看到“欢迎,用户未定义!”这是您一定在现实中看到过的内容!

解决这类问题是 TypeScript 的主要目的。TypeScript 希望比您更好地理解您的 JavaScript 代码。而在 TypeScript 无法理解您的意图时,您可以通过提供额外的类型信息来帮助它。问题在于开始添加类型通常很困难。您可能心中有最棘手的边缘案例,但不知道如何入手。

我希望提出一个可能帮助您入门的过程,同时也向您展示了一个停下来的好时机。您可以逐步增强类型的能力。每次细化都会有所改善,并且您可以在较长时间内提高类型安全性。让我们开始吧!

步骤 1:基本类型化

我们从一些基本的类型信息开始。我们有一个指向 get 函数的 app 对象。get 函数接受一个字符串类型的 path 和一个回调函数:

const app = {
  get /* post, put, delete, ... to come! */,
};

function get(path: string, callback: CallbackFn) {
  // to be implemented --> not important right now
}

CallbackFn 是一个返回 void 并接受两个参数的函数类型:

  • req,类型为 ServerRequest

  • reply,类型为 ServerReply

type CallbackFn = (req: ServerRequest, reply: ServerReply) => void;

在大多数框架中,ServerRequest 是一个非常复杂的对象。我们为演示目的做了一个简化版本。我们传入一个 method 字符串,例如 "GET""POST""PUT""DELETE" 等等。它还有一个 params 记录。记录是将一组键与一组属性关联的对象。目前,我们希望允许每个 string 键映射到一个 string 属性。稍后我们会重构这个对象:

type ServerRequest = {
  method: string;
  params: Record<string, string>;
};

对于 ServerReply,我们列出了一些函数,知道一个真实的 ServerReply 对象有更多。一个 send 函数带有一个可选参数 obj,包含我们要发送的数据。我们可以使用流畅的接口设置状态码,使用 status 函数:

type ServerReply = {
  send: (obj?: any) => void;
  status: (statusCode: number) => ServerReply;
};

使用一些非常基本的复合类型和路径的简单原始类型,我们已经为我们的项目增加了很多类型安全性。我们可以排除一些错误:

app.get("/api/users/:userID", function(req, res) {
  if(req.method === 2) {
//   ^ This condition will always return 'false' since the types
//     'string' and 'number' have no overlap.(2367)

    res.status("200").send()
//             ^
// Argument of type 'string' is not assignable to
// parameter of type 'number'.(2345)
  }
});

这很棒,但还有很多事情要做。我们仍然可以发送错误的状态码(任何数字都可能),并且对于可能的 HTTP 方法一无所知(任何字符串都可能)。因此,让我们细化我们的类型。

第二步:子集原始类型

您可以将原始类型视为特定类别的所有可能值的集合。例如,string 包括 JavaScript 中可以表达的所有可能字符串,number 包括具有双浮点精度的所有可能数值,boolean 包括可能的布尔值,即 truefalse

TypeScript 允许您将这些集合细化为更小的子集。例如,我们可以创建一个类型 Methods,包括我们可以接收到的所有可能的字符串作为 HTTP 方法:

type Methods = "GET" | "POST" | "PUT" | "DELETE";

type ServerRequest = {
  method: Methods;
  params: Record<string, string>;
};

Methods 是更大的 string 集合的一个较小子集。Methods 也是字面类型的联合类型,是给定集合的最小单元。一个字面字符串。一个字面数值。没有歧义:它只是 "GET"。你可以将它们与其他字面类型组合成联合类型,创建一个给定较大类型的子集。你还可以通过字面类型的组合来创建 stringnumber 的子集,或者不同的复合对象类型。有很多可能性可以结合和放置字面类型到联合中。

这对我们的服务器回调产生了直接影响。突然之间,我们可以区分这四种方法(或更多,如果需要的话),并且可以在代码中穷尽所有可能性。TypeScript 将指导我们。

这减少了一类错误。现在我们确切地知道可用的 HTTP 方法是哪些。我们可以对 HTTP 状态码做同样的事情,通过定义 statusCode 可以取的有效数字的子集:

type StatusCode =
  100 | 101 | 102 | 200 | 201 | 202 | 203 | 204 | 205 |
  206 | 207 | 208 | 226 | 300 | 301 | 302 | 303 | 304 |
  305 | 306 | 307 | 308 | 400 | 401 | 402 | 403 | 404 |
  405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 |
  414 | 415 | 416 | 417 | 418 | 420 | 422 | 423 | 424 |
  425 | 426 | 428 | 429 | 431 | 444 | 449 | 450 | 451 |
  499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 |
  508 | 509 | 510 | 511 | 598 | 599;

type ServerReply = {
  send: (obj?: any) => void;
  status: (statusCode: StatusCode) => ServerReply;
};

类型 StatusCode 再次是一个联合类型。有了它,我们排除了另一类错误。突然之间,像这样的代码会失败:

app.get("/api/user/:userID", (req, res) => {
 if(req.method === "POS") {
//   ^ This condition will always return 'false' since
//     the types 'Methods' and '"POS"' have no overlap.(2367)
    res.status(20)
//             ^
//  Argument of type '20' is not assignable to parameter of
//  type 'StatusCode'.(2345)
 }
})

我们的软件变得更安全了。但我们可以做得更多!

第三步:添加泛型

当我们使用 app.get 定义路由时,我们隐含地知道唯一可能的 HTTP 方法是 "GET"。但是通过我们的类型定义,我们仍然需要检查联合类型的所有可能部分。

CallbackFn的类型是正确的,因为我们可以为所有可能的 HTTP 方法定义回调函数,但是如果我们显式调用app.get,那么保存一些额外步骤并且符合类型是很好的:

TypeScript 泛型可以帮助。我们希望以一种可以指定Methods的一部分而不是整个集合的方式定义ServerRequest。为此,我们使用泛型语法,就像我们在函数中定义参数一样:

type ServerRequest<Met extends Methods> = {
  method: Met;
  params: Record<string, string>;
};

下面是发生的事情:

  • ServerRequest变成了一个泛型类型,如尖括号所示。

  • 我们定义了一个名为Met的泛型参数,它是Methods类型的子集。

  • 我们使用这个泛型参数作为定义方法的泛型变量。

通过这种改变,我们可以指定不同的ServerRequest变体而不重复定义:

type OnlyGET = ServerRequest<"GET">;
type OnlyPOST = ServerRequest<"POST">;
type POSTorPUT = ServerRquest<"POST" | "PUT">;

由于我们改变了ServerRequest的接口,我们必须改变所有使用ServerRequest的其他类型,如CallbackFnget函数:

type CallbackFn<Met extends Methods> = (
  req: ServerRequest<Met>,
  reply: ServerReply
) => void;

function get(path: string, callback: CallbackFn<"GET">) {
  // to be implemented
}

通过get函数,我们向我们的泛型类型传递了一个实际的参数。我们知道这不仅仅是Methods的一个子集;我们确切知道我们正在处理的是哪个子集。

现在,当我们使用app.get时,req.method只有一种可能的值:

app.get("/api/users/:userID", function (req, res) {
  req.method; // can only be GET
});

这确保了我们在创建app.get回调时不会假设像"POST"或类似的 HTTP 方法是可用的。此时我们确切知道我们正在处理的内容,所以让我们在类型中反映这一点。

我们已经做了很多工作,以确保request.method合理地类型化并表示实际情况。通过对Methods联合类型进行子集处理的一个好处是,我们可以创建一个在app.get之外的通用回调函数,它是类型安全的:

const handler: CallbackFn<"PUT" | "POST"> = function(res, req) {
  res.method // can be "POST" or "PUT"
};

const handlerForAllMethods: CallbackFn<Methods> = function(res, req) {
  res.method // can be all methods
};

app.get("/api", handler);
//              ^
// Argument of type 'CallbackFn<"POST" | "PUT">' is not
// assignable to parameter of type 'CallbackFn<"GET">'.

app.get("/api", handlerForAllMethods); // This works

步骤 4:高级类型进行类型检查

我们尚未涉及的是对params对象进行类型化。到目前为止,我们得到了一个允许访问每个string键的记录。现在我们的任务是使其更加具体!

我们通过添加另一个泛型变量来实现,一个用于方法,一个用于我们Record中可能的键:

type ServerRequest<Met extends Methods, Par extends string = string> = {
  method: Met;
  params: Record<Par, string>;
};

通用类型变量Par可以是string类型的子集,默认值是每个字符串。通过这样做,我们可以告诉ServerRequest我们期望的键:

// request.method = "GET"
// request.params = {
//   userID: string
// }
type WithUserID = ServerRequest<"GET", "userID">;

让我们向我们的get函数和CallbackFn类型中添加新参数,以便设置请求的参数:

function get<Par extends string = string>(
  path: string,
  callback: CallbackFn<"GET", Par>
) {
  // to be implemented
}

const app = {
  get /* post, put, delete, ... to come! */,
};

type CallbackFn<Met extends Methods, Par extends string> = (
  req: ServerRequest<Met, Par>,
  reply: ServerReply
) => void;

如果我们没有显式设置Par,类型会像我们习惯的那样工作,因为Par默认为string。但是,如果我们设置了,我们就突然有了对req.params对象的正确定义:

app.get<"userID">("/api/users/:userID", function (req, res) {
  req.params.userID; // Works!!
  req.params.anythingElse; // doesn't work!!
});

这太棒了!不过有一件小事可以改进。尽管如此,我们仍然可以将任何字符串传递给app.getpath参数。如果我们也能在那里反映Par,岂不是更好?我们可以!这就是字符串模板文字类型(见第六章)发挥作用的地方。

让我们创建一个名为IncludesRouteParams的类型,以确保在参数名称前面添加冒号的 Express 风格中正确包含Par

type IncludesRouteParams<Par extends string> =
  | `${string}/:${Par}`
  | `${string}/:${Par}/${string}`;

泛型类型 IncludesRouteParams 接受一个 string 的子集作为参数。它创建了两个模板字面量的联合类型:

  • 第一个模板字面量以 任意 string 开头,然后包含 / 字符,后跟 : 字符,再后面是参数名。这确保我们捕捉到参数在路由字符串末尾的所有情况。

  • 第二个模板字面量以 任意 string 开头,然后是 /: 和参数名相同的模式。然后我们有另一个 / 字符,后面是 任意 字符串。联合类型的这一分支确保我们捕捉到参数在路由中的所有情况。

这就是带有参数名 userIDIncludesRouteParams 的行为在不同测试用例中的表现:

const a: IncludesRouteParams<"userID"> = "/api/user/:userID"; // works
const b: IncludesRouteParams<"userID"> = "/api/user/:userID/orders"; // works
const c: IncludesRouteParams<"userID"> = "/api/user/:userId"; // breaks
const d: IncludesRouteParams<"userID"> = "/api/user"; // breaks
const e: IncludesRouteParams<"userID"> = "/api/user/:userIDAndmore"; // breaks

让我们在 get 函数声明中包含我们的新实用类型:

function get<Par extends string = string>(
  path: IncludesRouteParams<Par>,
  callback: CallbackFn<"GET", Par>
) {
  // to be implemented
}

app.get<"userID">(
  "/api/users/:userID",
  function (req, res) {
    req.params.userID; // Yes!
  }
);

太棒了!我们获得了另一个安全机制,确保我们不会忽略向实际路由添加参数。这非常强大。

步骤 5:锁定文字类型

但猜猜:我仍然对此不满意。在你的路由变得稍微复杂时,这种方法的几个问题就显而易见了:

  • 第一个问题是,我们需要在泛型类型参数中明确声明我们的参数。即使我们在函数的 path 参数中也会指定它,我们仍然必须将 Par 绑定到 "userID"。这不符合 JavaScript 的风格!

  • 这种方法只处理一个路由参数。一旦我们添加一个联合类型,例如 "userID" | "orderId",只要有一个参数可用,故障安全检查就会满足。这就是集合的工作原理。可以是其中一个或另一个。

必须有更好的方法。确实有。否则,这个方案会以非常糟糕的方式结束。

让我们颠倒顺序!不再在泛型类型变量中定义路由参数,而是从作为 app.get 第一个参数传递的 path 中提取变量:

function get<Path extends string = string>(
  path: Path,
  callback: CallbackFn<"GET", ParseRouteParams<Path>>
) {
  // to be implemented
}

我们移除 Par 泛型类型并添加 Path,它可以是任意 string 的子集。当我们将 path 设置为这个泛型类型 Path 时,一旦将参数传递给 get,我们就捕捉到它的字符串文字类型。我们将 Path 传递给一个我们尚未创建的新泛型类型 ParseRouteParams

让我们来处理 ParseRouteParams。在这里,我们再次调整事件的顺序。不再将请求的路由参数传递给通用程序来确保路径正确,而是传递路由路径并提取可能的路由参数。为此,我们需要创建一个条件类型。

步骤 6:添加条件类型

条件类型在语法上类似于 JavaScript 中的三元运算符。您检查一个条件,如果条件满足,则返回分支 A;否则,返回分支 B。例如:

type ParseRouteParams<Route> =
  Route extends `${string}/:${infer P}`
  ? P
  : never;

在这里,我们检查 Route 是否是以 Express 风格结尾的每个路径的子集(具有前置 "/:"")。如果是这样,我们推断这个字符串,这意味着我们将其内容捕获到一个新变量中。如果条件满足,我们返回新提取的字符串;否则,我们返回 never,如:“没有路由参数。”

如果我们尝试一下,我们得到了这样的结果:

type Params = ParseRouteParams<"/api/user/:userID">; // Params is "userID"

type NoParams = ParseRouteParams<"/api/user">; // NoParams is never: no params!

这比我们之前做得要好得多了。现在,我们想要捕获所有其他可能的参数。为此,我们必须添加另一个条件:

type ParseRouteParams<Route> = Route extends `${string}/:${infer P}/${infer R}`
  ? P | ParseRouteParams<`/${R}`>
  : Route extends `${string}/:${infer P}`
  ? P
  : never;

我们的条件类型现在的工作方式如下:

  1. 在第一个条件中,我们检查路由中间是否有路由参数。如果是这样,我们提取路由参数和其后的所有内容。我们将新发现的路由参数 P 返回为一个联合,其中我们用剩余的 R 递归调用相同的泛型类型。例如,如果我们将路由 "/api/users/:userID/orders/:orderID" 传递给 ParseRouteParams,我们将 "userID" 推断为 P,将 "orders/:orderID" 推断为 R。我们使用 R 调用相同的类型。

  2. 这就是第二个条件发挥作用的地方。在这里,我们检查是否有类型在末尾。对于 "orders/:orderID",就是这种情况。我们提取 "orderID" 并返回这个文字类型。

  3. 如果没有更多的路由参数,我们返回 never

// Params is "userID"
type Params = ParseRouteParams<"/api/user/:userID">;

// MoreParams is "userID" | "orderID"
type MoreParams = ParseRouteParams<"/api/user/:userID/orders/:orderId">;

让我们应用这种新类型,看看我们对 app.get 的最终使用是什么样的:

app.get("/api/users/:userID/orders/:orderID", function (req, res) {
  req.params.userID; // Works
  req.params.orderID; // Also available
});

就是这样!让我们回顾一下。我们刚刚为一个函数 app.get 创建的类型确保了排除了大量可能的错误:

  • 我们只能将适当的数值状态码传递给 res.status()

  • req.method 是四个可能字符串之一,当我们使用 app.get 时,我们知道它只能是 "GET"

  • 我们可以解析路由参数并确保在回调参数中没有任何拼写错误。

如果我们看一下这个食谱开头的示例,我们得到了以下错误消息:

app.get("/api/users/:userID", function(req, res) {
  if (req.method === "POST") {
//   ^ This condition will always return 'false' since
//     the types 'Methods' and '"POST"' have no overlap.(2367)
    res.status(20).send({
//             ^
//  Argument of type '20' is not assignable to parameter of
//  type 'StatusCode'.(2345)
      message: "Welcome, user " + req.params.userId
//                                           ^
//    Property 'userId' does not exist on type
//    '{ userID: string; }'. Did you mean 'userID'?
    });
  }
});

所有这些都发生在我们实际运行代码之前!Express 风格的服务器是 JavaScript 动态性的完美例子。根据您调用的方法和传递的第一个参数字符串,回调内的行为发生了很多变化。再举一个例子,你的所有类型看起来都完全不同。

这种方法的伟大之处在于每一步都增加了更多的类型安全性:

  1. 您可以轻松停止在基本类型上,并且比根本没有类型要好。

  2. 子集帮助您通过减少有效值的数量来消除拼写错误。

  3. 泛型帮助您定制行为以使用案例。

  4. 像字符串模板文本类型这样的高级类型在字符串类型的世界中为您的应用程序增添了更多的含义。

  5. 在泛型中锁定允许您在 JavaScript 中处理文字并将其视为类型。

  6. 条件类型使得您的类型像您的 JavaScript 代码一样灵活。

最好的事情?一旦添加了类型,人们将只需编写普通的 JavaScript 代码,仍然可以获得所有的类型信息。这对每个人都是一种胜利。

12.3 使用 satisfies 检查合约

问题

您想使用字面类型,但需要一个注释类型检查以确保您履行合同。

解决方案

使用satisfies运算符执行类似注释的类型检查,同时保留字面类型。

讨论

映射类型非常棒,因为它们允许 JavaScript 中已知的对象结构的灵活性。但它们对类型系统有一些关键影响。来看一个来自通用消息库的示例,该库接受“通道定义”,在其中可以定义多个通道令牌:

type Messages =
  | "CHANNEL_OPEN"
  | "CHANNEL_CLOSE"
  | "CHANNEL_FAIL"
  | "MESSAGE_CHANNEL_OPEN"
  | "MESSAGE_CHANNEL_CLOSE"
  | "MESSAGE_CHANNEL_FAIL";

type ChannelDefinition = {
  [key: string]: {
    open: Messages;
    close: Messages;
    fail: Messages;
  };
};

此通道定义对象的键是用户希望它们是什么。因此这是一个有效的通道定义:

const impl: ChannelDefinition = {
  test: {
    open: 'CHANNEL_OPEN',
    close: 'CHANNEL_CLOSE',
    fail: 'CHANNEL_FAIL'
  },
  message: {
    open: 'MESSAGE_CHANNEL_OPEN',
    close: 'MESSAGE_CHANNEL_CLOSE',
    fail: 'MESSAGE_CHANNEL_FAIL'
  }
}

然而,我们有一个问题:当我们想要访问我们灵活定义的键时。假设我们有一个打开通道的函数。我们传递整个通道定义对象,以及我们想要打开的通道:

function openChannel(
  def: ChannelDefinition,
  channel: keyof ChannelDefinition
) {
  // to be implemented
}

那么ChannelDefinition的键是什么呢?嗯,它是每个键:[key: string]。因此,一旦我们指定了具体类型,TypeScript 会将impl视为这个特定类型,而忽略实际的实现。合同得到满足。继续。这允许传递错误的键:

// Passes, even though "massage" is not part of impl
openChannel(impl, "massage");

因此,我们更感兴趣的是实际的实现,而不是我们为常量分配的类型。这意味着我们必须摆脱ChannelDefinition类型,并确保我们关心对象的实际类型。

首先,openChannel函数应该接受任何作为Ch⁠ann⁠el​De⁠fi⁠ni⁠tion子类型的对象,但是与具体的子类型一起工作:

function openChannel<
  T extends ChannelDefinition
>(def: T, channel: keyof T) {
  // to be implemented
}

TypeScript 现在在两个层面上工作:

  • 它检查T是否实际上扩展了ChannelDefinition。如果是这样,我们就使用类型T

  • 所有我们的函数参数都用通用的T类型。这也意味着我们通过keyof T获取T真实键。

要从中受益,我们必须摆脱impl的类型定义。显式类型定义会覆盖所有实际类型。从我们明确指定类型的那一刻起,TypeScript 将其视为ChannelDefinition,而不是实际的底层子类型。我们还必须设置const context,这样我们可以将所有字符串转换为它们的单位类型(从而与Messages兼容):

const impl = {
  test: {
    open: "CHANNEL_OPEN",
    close: "CHANNEL_CLOSE",
    fail: "CHANNEL_FAIL",
  },
  message: {
    open: "MESSAGE_CHANNEL_OPEN",
    close: "MESSAGE_CHANNEL_CLOSE",
    fail: "MESSAGE_CHANNEL_FAIL",
  },
} as const;

没有const contextimpl的推断类型是:

/// typeof impl
{
  test: {
    open: string;
    close: string;
    fail: string;
  };
  message: {
    open: string;
    close: string;
    fail: string;
  };
}

有了const contextimpl的实际类型现在是:

/// typeof impl
{
  test: {
    readonly open: "CHANNEL_OPEN";
    readonly close: "CHANNEL_CLOSE";
    readonly fail: "CHANNEL_FAIL";
  };
  message: {
    readonly open: "MESSAGE_CHANNEL_OPEN";
    readonly close: "MESSAGE_CHANNEL_CLOSE";
    readonly fail: "MESSAGE_CHANNEL_FAIL";
  };
}

Const context允许我们满足ChannelDefinition所做的合同。现在openChannel可以正常工作:

openChannel(impl, "message"); // satisfies contract
openChannel(impl, "massage");
//                 ^
// Argument of type '"massage"' is not assignable to parameter
// of type '"test" | "message"'.(2345)

这个方法有效,但有一个注意事项。我们唯一可以检查impl是否实际上是ChannelDefinition的有效子类型的点是在我们使用它的时候。有时候我们想要提前注释以找出合同可能出现的问题。我们想要看看这个具体实现是否满足合同。

幸运的是,有一个关键字可以做到这一点。我们可以定义对象并进行类型检查,以查看这个实现是否满足类型,但是 TypeScript 会将其视为字面类型:

const impl = {
  test: {
    open: "CHANNEL_OPEN",
    close: "CHANNEL_CLOSE",
    fail: "CHANNEL_FAIL",
  },
  message: {
    open: "MESSAGE_CHANNEL_OPEN",
    close: "MESSAGE_CHANNEL_CLOSE",
    fail: "MESSAGE_CHANNEL_FAIL",
  },
} satisfies ChannelDefinition;

function openChannel<T extends ChannelDefinition>(
  def: T,
  channel: keyof T
) {
  // to be implemented
}

有了这个,我们可以确保我们履行了合同,但是与const 上下文一样具有相同的好处。唯一的区别是字段不被设置为readonly,但由于 TypeScript 获取了所有内容的文字类型,因此在满足类型检查后无法将字段设置为其他任何值:

impl.test.close = "CHANEL_CLOSE_MASSAGE";
//                 ^
// Type '"CHANEL_CLOSE_MASSAGE"' is not assignable
// to type '"CHANNEL_CLOSE"'.(2322)

有了这个,我们同时得到了两全其美的好处:在注解时进行正确的类型检查,以及在特定情况下使用狭窄类型的能力。

12.4 测试复杂类型

问题

你写了非常复杂和精细的类型,你想确保它们的行为是正确的。

解决方案

一些常见的辅助类型的工作方式就像一个测试框架。测试你的类型!

讨论

在动态类型的编程语言中,人们总是围绕着是否需要类型当你可以有一个合适的测试套件进行讨论。至少一个阵营会这么说;另一些人则认为,为什么我们要进行那么多测试,当我们可以有类型呢?答案可能处于中间某个地方。

类型确实可以解决很多测试案例。结果是一个数字吗?结果是一个具有某些特定类型属性的对象吗?这是我们可以通过类型轻松检查的事情。我的函数产生了正确的结果吗?值是否符合我的预期?这属于测试。

在本书中,我们学到了关于非常复杂的类型的很多知识。通过条件类型,我们打开了 TypeScript 的元编程能力,我们可以根据先前类型的某些特征制作新类型。强大、图灵完备且非常先进。这引出了一个问题:我们如何确保这些复杂类型确实做到了它们应该做的?也许我们应该测试我们的类型

实际上我们可以。社区内有一些已知的辅助类型可以充当某种测试框架。以下类型来自于优秀的Type Challenges 存储库,它允许你极限测试你的 TypeScript 类型系统技能。它们包括非常具有挑战性的任务:一些与现实用例相关,另一些则只是为了好玩。

他们的测试库从一些期望真值或假值的类型开始。它们非常直接了当。通过使用泛型和文字类型,我们可以检查这个布尔值是 true 还是 false:

export type Expect<T extends true> = T;
export type ExpectTrue<T extends true> = T;
export type ExpectFalse<T extends false> = T;
export type IsTrue<T extends true> = T;
export type IsFalse<T extends false> = T;

它们单独使用时并没有多大作用,但是当与Equal<X, Y>NotEqual<X, Y>一起使用时就非常棒,它们会返回true或者false

export type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false;
export type NotEqual<X, Y> = true extends Equal<X, Y> ? false : true;

Equal<X, Y>很有趣,因为它创建了通用函数并将它们与应该进行比较的两种类型进行检查。由于每个条件类型都没有解决方案,TypeScript 比较了两个条件类型并可以看到它们是否兼容。这是 TypeScript 条件类型逻辑中的一步,由Stack Overflow 上的 Alex Chashin 精彩解释

接下来的一批允许我们检查类型是否为any

export type IsAny<T> = 0 extends 1 & T ? true : false;
export type NotAny<T> = true extends IsAny<T> ? false : true;

这是一种简单的条件类型,将01 & T进行比较,应该始终缩小为1never,这总是会得出条件类型的false分支,除非我们与any相交。与any相交总是any,而0any的子集。

下一批是对我们在食谱 8.3 中看到的RemapDeepRemap的重新解释,以及Alike作为一种比较结构相同但构造不同的类型的方法:

export type Debug<T> = { [K in keyof T]: T[K] };
export type MergeInsertions<T> = T extends object
  ? { [K in keyof T]: MergeInsertions<T[K]> }
  : T;

export type Alike<X, Y> = Equal<MergeInsertions<X>, MergeInsertions<Y>>;

在之前的Equal检查理论上应该能够理解{ x : number, y: string }等同于{ x: number } & { y: string },但 TypeScript 类型检查器的实现细节并不认为它们相等。这就是Alike发挥作用的地方。

类型挑战测试文件的最后一批做了两件事:

  • 它使用 它使用简单的条件类型进行子集检查。

  • 它检查你构建的元组是否可以被视为函数的有效参数:

export type ExpectExtends<VALUE, EXPECTED> = EXPECTED extends VALUE
  ? true
  : false;
export type ExpectValidArgs<
  FUNC extends (...args: any[]) => any,
  ARGS extends any[]
> = ARGS extends Parameters<FUNC> ? true : false;

当你的类型变得更复杂时,拥有这样的一个小型辅助类型库用于类型测试和调试是非常有帮助的。将它们添加到你的全局类型定义文件中(参见食谱 9.7)并使用它们。

12.5 使用 Zod 在运行时验证数据类型

问题

你依赖于外部数据源,并不能信任它们是正确的。

解决方案

使用一个名为Zod的库定义模式,并使用它来验证外部数据。

讨论

恭喜你!我们几乎到达了终点。如果你从头到尾跟随,你一直被提醒 TypeScript 的类型系统遵循几个目标。首先也是最重要的是,它希望为你提供优秀的工具,使你在开发应用程序时能够提高生产力。它还希望能够支持所有 JavaScript 框架,并确保它们既有趣又易于使用。它将自己视为 JavaScript 的一个附加组件,作为静态类型的语法。还有一些非目标或权衡。它更倾向于生产力而非正确性,允许开发者根据需要弯曲规则,并且没有声称是可证明正确的。

在食谱 3.9 中,我们学到通过类型断言可以影响 TypeScript 的类型定义,认为类型应该是不同的;在食谱 9.2 中,我们学习了如何使不安全操作更加稳健且易于发现。由于 TypeScript 的类型系统仅在编译时有效,一旦在我们选择的运行时环境中运行 JavaScript,所有的安全措施都会消失。

通常,编译时类型检查就足够了。只要我们在编写自己类型的内部世界内,让 TypeScript 检查一切是否正常,我们的代码就可以正常运行。然而,在 JavaScript 应用程序中,我们还需要处理许多超出我们控制范围的事物:例如用户输入。我们需要访问和处理的第三方 API。无可避免地,我们在开发过程中会达到一个需要离开严格类型化应用程序边界并处理我们无法信任的数据的时刻。

在开发过程中,与外部来源或用户输入合作可能效果不错,但在生产环境中确保我们使用的数据保持一致需要额外的努力。您可能希望验证您的数据是否符合某种方案。

幸运的是,有一些库可以处理这种任务。近年来获得广泛认可的一个库是Zod。Zod 是以 TypeScript 为先导的,这意味着它不仅确保您消费的数据有效且符合您的期望,还能为您的整个程序提供可用的 TypeScript 类型。Zod 视自己为您无法控制的外部世界与一切严格类型化和类型检查的内部世界之间的卫士。

想象一下,一个 API 给了您这本书中看到的 Person 类型的数据。一个 Person 有一个名字和年龄,一个可选的职业,还有一个状态:在我们的系统中,他们可以是活跃的、不活跃的或仅注册等待确认的。

API 还在 Result 类型中的数组中打包了几个 Person 对象。简而言之,这是 HTTP 调用的经典响应类型的示例:

type Person = {
  name: string;
  age: number;
  profession?: string | undefined;
  status: "active" | "inactive" | "registered";
};

type Results = {
  entries: Person[]
};

您知道如何为这样的模型编写类型。到目前为止,您可以流利地识别和应用语法和模式。我们希望在运行时使用 Zod 处理我们无法控制的数据外,我们希望有相同的类型。并且在 JavaScript 中编写相同类型(值命名空间)看起来非常熟悉:

import { z } from "zod";

const Person = z.object({
  name: z.string(),
  age: z.number().min(0).max(150),
  profession: z.string().optional(),
  status: z.union([
    z.literal("active"),
    z.literal("inactive"),
    z.literal("registered"),
  ]),
});

const Results = z.object({
  entries: z.array(Person),
});

如您所见,我们使用 JavaScript,并向命名空间添加名称,而不是类型命名空间(参见 Recipe 2.9)。但是,Zod 流畅接口提供的工具对于我们 TypeScript 开发人员非常熟悉。我们可以定义对象、字符串、数字和数组。我们还可以定义联合类型和文字类型。所有定义模型的构建块都在这里,我们还可以嵌套类型,如我们首先定义 Person 并在 Results 中重用它所示。

流畅的接口还允许我们使某些属性可选。这些都是我们从 TypeScript 中了解的东西。此外,我们还可以设置验证规则。我们可以说年龄应该大于或等于 0 并且小于 100。这些在类型系统内部我们无法合理做到的事情。

那些对象并不是我们可以像使用 TypeScript 类型那样使用的类型。它们是模式,等待可以解析和验证的数据。由于 Zod 是以 TypeScript 为先导,我们有辅助类型,允许我们从值空间过渡到类型空间。通过 z.infer(一个类型,不是函数),我们可以提取我们通过 Zod 的模式函数定义的类型:

type PersonType = z.infer<typeof Person>;
type ResultType = z.infer<typeof Results>;

那么,我们如何应用 Zod 的验证技术呢?让我们谈谈一个名为 fetchData 的函数,该函数调用获取 ResultType 类型的条目的 API。我们不知道我们收到的值是否实际符合我们定义的类型。因此,在将数据作为 json 获取后,我们使用 Results 模式来解析我们收到的数据。如果此过程成功,我们将得到 ResultType 类型的数据:

type ResultType = z.infer<typeof Results>;

async function fetchData(): Promise<ResultType> {
  const data = await fetch("/api/persons").then((res) => res.json());
  return Results.parse(data);
}

请注意,我们在如何定义函数接口时已经有了我们的第一个保护措施。Promise<ResultType> 基于我们从 z.infer 获取的内容。

Results.parse(data) 是推断类型但没有名称。结构类型系统确保我们返回正确的东西。可能会有错误,我们可以使用相应的 Promise.catch 方法或 try-catch 块来捕获它们。

使用 try-catch

fetchData()
  .then((res) => {
    // do something with results
  })
  .catch((e) => {
    // a potential zod error!
  });

// or

try {
  const res = await fetchData();
  // do something with results
} catch (e) {
  // a potential zod error!
}

只有在我们拥有正确的数据时,我们才能确保继续进行,我们并没有被迫进行错误检查。如果我们想确保在继续我们的程序之前先查看解析结果,safeParse 是最好的选择:

async function fetchData(): Promise<ResultType> {
  const data = await fetch("/api/persons").then((res) => res.json());
  const results = Results.safeParse(data);
  if (results.success) {
    return results.data;
  } else {
    // Depending on your application, you might want to have a
    // more sophisticated way of error handling than returning
    // an empty result.
    return { entries: [] };
  }
}

如果您需要依赖外部数据,这已经使得 Zod 成为一个有价值的资产。此外,它允许您适应 API 的变化。假设您的程序仅能处理 Person 的活动和非活动状态,而不知道如何处理 registered。您可以轻松应用一个转换,根据您接收到的数据,将 "registered" 状态修改为实际的 "active"

const Person = z.object({
  name: z.string(),
  age: z.number().min(0).max(150),
  profession: z.string().optional(),
  status: z
    .union([
      z.literal("active"),
      z.literal("inactive"),
      z.literal("registered"),
    ])
    .transform((val) => {
      if (val === "registered") {
        return "active";
      }
      return val;
    }),
});

然后,您将使用两种不同的类型:输入 类型代表 API 给您的内容,输出 类型是解析后的数据。幸运的是,我们可以从相应的 Zod 辅助类型 z.inputz.output 获取这两种类型:

type PersonTypeIn = z.input<typeof Person>;
/*
type PersonTypeIn = {
 name: string;
 age: number;
 profession?: string | undefined;
 status: "active" | "inactive" | "registered";
};
*/

type PersonTypeOut = z.output<typeof Person>;
/*
type PersonTypeOut = {
 name: string;
 age: number;
 profession?: string | undefined;
 status: "active" | "inactive";
};
*/

Zod 的类型推断足够聪明,可以理解您从 status 中移除了三个文字之一。因此没有任何意外,您实际上处理了您预期的数据。

Zod 的 API 非常优雅、易于使用,并且与 TypeScript 的特性紧密对齐。对于您无法控制的边界数据,需要依赖第三方提供预期数据形状的情况,Zod 是一个救命稻草,而您几乎不需要做太多工作。然而,这是有代价的:运行时验证需要时间。数据集越大,验证时间越长。此外,它的大小为 12KB。请确保您需要在边界数据上进行此类验证。

如果你请求的数据来自公司内的其他团队,也许就坐在你旁边的人,没有任何库,甚至是 Zod,能比互相交流和朝着相同目标合作更胜一筹。类型是引导协作的一种方式,而不是摆脱它的手段。

12.6 解决索引访问限制

问题

当使用索引访问对象属性时,TypeScript 抱怨要分配的类型不能赋给never

解决方案

TypeScript 寻找可能值的最低公共分母。使用泛型类型锁定特定键,以便 TypeScript 不会假定该规则适用于所有情况。

讨论

有时,在编写 TypeScript 时,你通常在 JavaScript 中执行的操作会有所不同,引起一些奇怪和令人费解的情况。有时你只想通过索引访问操作将值分配给对象属性,并获得像“类型'string | number'不可分配给类型'never'。类型'string'不可分配给类型'never'。(2322).”这样的错误。

这并不是什么特别的事情;这只是“意外的交集类型”让你更多地思考类型系统。

让我们看一个例子。我们创建一个函数,允许我们通过提供一个键从一个对象anotherPerson更新到对象personpersonan⁠oth⁠er​Pe⁠rs⁠on都是类型为Person的相同类型,但是 TypeScript 报错了:

let person = {
  name: "Stefan",
  age: 39,
};

type Person = typeof person;

let anotherPerson: Person = {
  name: "Not Stefan",
  age: 20,
};

function update(key: keyof Person) {
  person[key] = anotherPerson[key];
//^ Type 'string | number' is not assignable to type 'never'.
//  Type 'string' is not assignable to type 'never'.(2322)
}

update("age");

通过索引访问操作符进行属性赋值对 TypeScript 来说是困难的。即使你通过keyof Person缩小了所有可能访问键的范围,也可以赋予的可能值是stringnumber(分别用于名称和年龄)。如果在语句的右侧进行索引访问(读取)是没问题的,但如果在语句的左侧进行索引访问(写入),情况就会有些有趣。

TypeScript 不能保证你传递的值实际上是正确的。看看这个函数签名:

function updateAmbiguous(key: keyof Person, value: Person[keyof Person]) {
  //...
}

updateAmbiguous("age", "Stefan");

除了 TypeScript 会报错外,没有什么能阻止我在每个键上添加错误类型的值。但为什么 TypeScript 告诉我们类型是never呢?

为了允许某些赋值,TypeScript 做出了妥协。与其完全不允许右侧的任何赋值,TypeScript 寻找可能值的最低公共分母,例如:

type Switch = {
  address: number,
  on: 0 | 1
};

declare const switcher: Switch;
declare const key: keyof Switch;

在这里,两个键都是number的子集。address是所有数字的集合;而on则是01。完全可以将01设置给这两个字段!这也是你在使用 TypeScript 时会得到的结果:

switcher[key] = 1; // This works
switcher[key] = 2; // Error
// ^ Type '2' is not assignable to type '0 | 1'.(2322)

TypeScript 通过对所有属性类型进行交集类型来获取可能的可赋值值。对于Switch,它是number & (0 | 1),简化为0 | 1。对于所有Person属性,它是string & number,它们没有重叠;因此是never。哈!问题就在这里!

绕过这种严格性(这是为了你好的)的一种方法是使用泛型。我们不允许所有 keyof Person 值访问,而是将 keyof Person 的特定子集绑定到一个泛型变量:

function update<K extends keyof Person>(key: K) {
  person[key] = anotherPerson[key]; // works
}

update("age");

当我 update("age") 时,K 绑定到字面类型 "age"。没有任何歧义!

有一个理论上的漏洞,因为我们可以使用一个更广泛的泛型值来实例化 update

update<"age" | "name">("age");

这是 TypeScript 团队目前允许的。另请参阅 这条评论 由 Anders Hejlsberg。请注意,他要求看到此类情景的用例,这完美地说明了 TypeScript 团队的工作方式。在右手边的索引访问上的原始分配具有很高的错误潜力,因此他们为您提供了足够的保障,直到您非常明确地表达您想要做的事情。这排除了整个类别的错误,而不会给您带来太多阻碍。

12.7 决定是使用函数重载还是条件类型

问题

使用条件类型,您比以前有更多可能定义函数签名的方式。您是否仍然需要函数重载或者它们已经过时了呢?

解决方案

函数重载提供了比条件更好的可读性,并且更容易定义您的类型的期望。在情况需要时使用它们。

讨论

使用诸如条件类型或变长元组类型之类的类型系统功能,描述函数接口的技术已经淡出背景:函数重载。而且理由充分。这两个特性都已实施以解决常规函数重载的缺陷。

直接查看 TypeScript 4.0 发布说明中的此连接示例。这是一个数组 concat 函数:

function concat(arr1, arr2) {
  return [...arr1, ...arr2];
}

要正确地对这样一个函数进行类型化,以便考虑到所有可能的边缘情况,我们将陷入一片重载的海洋中:

// 7 overloads for an empty second array
function concat(arr1: [], arr2: []): [];
function concat<A>(arr1: [A], arr2: []): [A];
function concat<A, B>(arr1: [A, B], arr2: []): [A, B];
function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C];
function concat<A, B, C, D>(arr1: [A, B, C, D], arr2: []): [A, B, C, D];
function concat<A, B, C, D, E>(
  arr1: [A, B, C, D, E],
  arr2: []
): [A, B, C, D, E];
function concat<A, B, C, D, E, F>(
  arr1: [A, B, C, D, E, F],
  arr2: []
): [A, B, C, D, E, F];
// 7 more for arr2 having one element
function concat<A2>(arr1: [], arr2: [A2]): [A2];
function concat<A1, A2>(arr1: [A1], arr2: [A2]): [A1, A2];
function concat<A1, B1, A2>(arr1: [A1, B1], arr2: [A2]): [A1, B1, A2];
function concat<A1, B1, C1, A2>(
  arr1: [A1, B1, C1],
  arr2: [A2]
): [A1, B1, C1, A2];
function concat<A1, B1, C1, D1, A2>(
  arr1: [A1, B1, C1, D1],
  arr2: [A2]
): [A1, B1, C1, D1, A2];
function concat<A1, B1, C1, D1, E1, A2>(
  arr1: [A1, B1, C1, D1, E1],
  arr2: [A2]
): [A1, B1, C1, D1, E1, A2];
function concat<A1, B1, C1, D1, E1, F1, A2>(
  arr1: [A1, B1, C1, D1, E1, F1],
  arr2: [A2]
): [A1, B1, C1, D1, E1, F1, A2];
// and so on, and so forth

这仅考虑了最多有六个元素的数组。变长元组类型在这些情况下非常有帮助:

type Arr = readonly any[];

function concat<T extends Arr, U extends Arr>(arr1: T, arr2: U): [...T, ...U] {
  return [...arr1, ...arr2];
}

新的函数签名需要更少的解析工作,并且非常清楚它期望获取哪些类型作为参数,并返回什么。返回值也映射到返回类型。没有额外的断言:TypeScript 可以确保您返回正确的值。

条件类型也有类似的情况。这个例子与 Recipe 5.1 非常相似。考虑基于客户、文章或订单 ID 检索订单的软件。您可能想创建类似这样的东西:

function fetchOrder(customer: Customer): Order[]
function fetchOrder(product: Product): Order[]
function fetchOrder(orderId: number): Order
// the implementation
function fetchOrder(param: any): Order | Order[] {
  //...
}

但这只是事实的一半。如果您最终面临模糊的类型,您不确定是否仅获得仅仅一个 Customer 或仅仅一个 Product?您需要考虑所有可能的组合:

function fetchOrder(customer: Customer): Order[]
function fetchOrder(product: Product): Order[]
function fetchOrder(orderId: number): Order
function fetchOrder(param: Customer | Product): Order[]
function fetchOrder(param: Customer | number): Order | Order[]
function fetchOrder(param: number | Product): Order | Order[]
// the implementation
function fetchOrder(param: any): Order | Order[] {
  //...
}

添加更多可能性,您最终会得到更多的组合。在这里,条件类型可以大大减少您的函数签名:

type FetchParams = number | Customer | Product;

type FetchReturn<T> = T extends Customer
  ? Order[]
  : T extends Product
  ? Order[]
  : T extends number
  ? Order
  : never;

function fetchOrder<T extends FetchParams>(params: T): FetchReturn<T> {
  //...
}

由于条件类型分布一个联合,FetchReturn 返回一个返回类型的联合。

因此,有充分的理由使用这些技术,而不是陷入太多的函数重载中。所以,回到问题:我们仍然需要函数重载吗?

是的,我们需要。

不同的函数形状

函数重载仍然很方便的一个场景是,如果你的函数变体有不同的参数列表。这意味着不仅参数本身可以有一些变化(这就是条件和可变元组的地方很棒),而且参数的数量和位置也可以有所不同。

想象一个搜索函数,有两种不同的调用方式:

  • 用搜索查询调用它。它返回一个Promise,你可以等待。

  • 用搜索查询和回调函数来调用。在这种情况下,函数不返回任何内容。

这可以用条件类型完成,但非常笨重:

// => (1)
type SearchArguments =
  // Argument list one: a query and a callback
  | [query: string, callback: (results: unknown[]) => void]
  // Argument list two:: just a query
  | [query: string];

// A conditional type picking either void or a Promise depending
// on the input => (2)
type ReturnSearch<T> = T extends [query: string]
  ? Promise<Array<unknown>>
  : void;

// the actual function => (3)
declare function search<T extends SearchArguments>(...args: T): ReturnSearch<T>;

// z is void
const z = search("omikron", (res) => {});

// y is Promise<unknown>
const y = search("omikron");

这是我们所做的:

  1. 我们使用元组类型定义我们的参数列表。自 TypeScript 4.0 以来,我们可以像对象一样为元组字段命名。我们创建一个联合类型,因为我们的函数签名有两种不同的变体。

  2. ReturnSearch 类型根据参数列表的变体选择返回类型。如果只是一个字符串,返回一个Promise。否则返回void

  3. 我们通过将一个泛型变量限制为SearchArguments来添加我们的类型,以便我们可以正确选择返回类型。

那是很多啊!而且它展示了 TypeScript 功能列表中许多复杂功能:条件类型、泛型、泛型约束、元组类型、联合类型!我们得到了一些不错的自动完成,但与简单的函数重载相比,它远没有那么清晰:

function search(query: string): Promise<unknown[]>;
function search(query: string, callback: (result: unknown[]) => void): void;
// This is the implementation, it only concerns you
function search(
  query: string,
  callback?: (result: unknown[]) => void
): void | Promise<unknown> {
  // Implement
}

我们仅在实现部分使用联合类型。其余部分非常明确和清晰。我们知道我们的参数,也知道预期的返回值。没有繁文缛节,只有简单的类型。函数重载最好的部分是实际实现不会污染类型空间。你可以使用any一圈,完全不用担心。

确切的参数

另一个函数重载可以简化事情的情况是,当您需要确切的参数及其映射时。让我们看一个应用事件到事件处理程序的函数。例如,我们有一个MouseEvent并希望用它调用一个MouseEvent​Handler。键盘事件等等也一样。如果我们使用条件和联合类型来映射事件和处理程序,我们可能会得到这样的东西:

// All the possible event handlers
type Handler =
  | MouseEventHandler<HTMLButtonElement>
  | KeyboardEventHandler<HTMLButtonElement>;

// Map Handler to Event
type Ev<T> = T extends MouseEventHandler<infer R>
  ? MouseEvent<R>
  : T extends KeyboardEventHandler<infer R>
  ? KeyboardEvent<R>
  : never;

// Create a
function apply<T extends Handler>(handler: T, ev: Ev<T>): void {
  handler(ev as any); // We need the assertion here
}

乍一看,这看起来还不错。但是如果你考虑到所有你需要跟踪的变体,可能会有点累赘。

但还有一个更大的问题。 TypeScript 处理事件的所有可能变体的方式导致了一个意外的交集,正如我们在 Recipe 12.6 中看到的那样。这意味着,在函数体中,TypeScript 无法确定您正在传递哪种类型的处理程序。因此,它也无法确定我们得到的是哪种类型的事件。因此,TypeScript 表示事件可以同时是鼠标事件和键盘事件。您需要传递能够处理这两种事件的处理程序,这不符合我们函数的预期工作方式。

实际的错误消息是:“TS 2345:类型参数*Ke⁠ybo⁠ard⁠Eve⁠nt<H⁠TML⁠Bu⁠tt⁠on​El⁠em⁠en⁠t> | MouseEvent<HTMLButtonElement, MouseEvent>*不能赋给类型参数*Mo⁠us⁠eEv⁠ent⁠<HT⁠MLB⁠utt⁠onE⁠lem⁠ent⁠, Mo⁠use⁠Eve⁠nt> ⁠& Key⁠boa⁠rd ​Eve⁠nt<H⁠TML⁠But⁠ton⁠Ele⁠me⁠nt>*。”

这就是为什么我们需要一个as any类型断言,以确保实际上可以用事件调用处理程序。

函数签名在很多场景下都适用:

declare const mouseHandler: MouseEventHandler<HTMLButtonElement>;
declare const mouseEv: MouseEvent<HTMLButtonElement>;
declare const keyboardHandler: KeyboardEventHandler<HTMLButtonElement>;
declare const keyboardEv: KeyboardEvent<HTMLButtonElement>;

apply(mouseHandler, mouseEv); // works
apply(keyboardHandler, keyboardEv); // woirks
apply(mouseHandler, keyboardEv); // breaks like it should!
//                  ^
// Argument of type 'KeyboardEvent<HTMLButtonElement>' is not assignable
// to parameter of type 'MouseEvent<HTMLButtonElement, MouseEvent>'

但一旦存在歧义,事情就不会按预期进行:

declare const mouseOrKeyboardHandler:
  MouseEventHandler<HTMLButtonElement> |
  KeyboardEventHandler<HTMLButtonElement>;;

// This is accepted but can cause problems!
apply(mouseOrKeyboardHandler, mouseEv);

mouseOrKeyboardHandler是键盘处理程序时,我们无法合理地传递鼠标事件。等等:这正是之前的 TS2345 错误尝试告诉我们的!我们只是把问题移到另一个地方,并通过as any断言使其无声。

明确、精确的函数签名使所有事情变得更容易。映射变得更清晰,类型签名更易于理解,而无需条件或联合:

// Overload 1: MouseEventHandler and MouseEvent
function apply(
  handler: MouseEventHandler<HTMLButtonElement>,
  ev: MouseEvent<HTMLButtonElement>
): void;
// Overload 2: KeyboardEventHandler and KeyboardEvent
function apply(
  handler: KeyboardEventHandler<HTMLButtonElement>,
  ev: KeyboardEvent<HTMLButtonElement>
): void;
// The implementation. Fall back to any. This is not a type!
// TypeScript won't check for this line nor
// will it show in the autocomplete.
// This is just for you to implement your stuff.
function apply(handler: any, ev: any): void {
  handler(ev);
}

函数重载帮助我们处理所有可能的情况。我们确保没有歧义的类型:

apply(mouseHandler, mouseEv); // works!
apply(keyboardHandler, keyboardEv); // works!
apply(mouseHandler, keyboardEv); // breaks like it should!
// ^ No overload matches this call.
apply(mouseOrKeyboardHandler, mouseEv); // breaks like it should
// ^ No overload matches this call.

对于实现,我们甚至可以使用any。由于您可以确保不会遇到暗示歧义的情况,您可以依赖于随心所欲的类型,而不必费心。

函数的全能处理体

最后但同样重要的是,条件类型函数重载的组合。记住来自 Recipe 5.1 的例子:我们看到条件类型使函数体难以将值映射到相应的泛型返回类型。将条件类型移动到函数重载中,并对实现使用非常广泛的函数签名,这对函数的使用者和实现者都有帮助:

function createLabel<T extends number | string | StringLabel | NumberLabel>(
  input: T
): GetLabel<T>;
function createLabel(
  input: number | string | StringLabel | NumberLabel
): NumberLabel | StringLabel {
  if (typeof input === "number") {
    return { id: input };
  } else if (typeof input === "string") {
    return { name: input };
  } else if ("id" in input) {
    return { id: input.id };
  } else {
    return { name: input.name };
  }
}

函数重载仍然非常有用,并且对于许多场景来说是一个好选择。它们更易读,更易写,并且在许多情况下比其他方法更精确。

但这不是非此即彼的情况。如果您的场景需要,您可以愉快地混合和匹配条件和函数重载。

12.8 命名泛型

问题

TU 并未告诉您关于泛型类型参数的任何信息。

解决方案

遵循命名模式。

讨论

TypeScript 的泛型可以说是该语言最强大的特性之一。它们打开了通往 TypeScript 自身元编程语言的大门,允许非常灵活和动态地生成类型。它几乎成为自己的函数式编程语言。

特别是在最新的 TypeScript 版本中引入了字符串字面类型递归条件类型之后,我们可以创建做出惊人事情的类型。来自 Recipe 12.2 的这种类型解析 Express 风格的路由信息,并检索出所有参数的对象:

type ParseRouteParameters<T> =
  T extends `${string}/:${infer U}/${infer R}` ?
    { [P in U | keyof ParseRouteParameters<`/${R}`>]: string } :
  T extends `${string}/:${infer U}` ?
    { [P in U]: string } : {}

type X = ParseRouteParameters<"/api/:what/:is/notyou/:happening">
// type X = {
//   what: string,
//   is: string,
//   happening: string,
// }

当我们定义泛型类型时,我们也定义了泛型类型参数。它们可以是某种类型(或更正确地说,是某种子类型):

type Foo<T extends string> = ...

它们可以有默认值:

type Foo<T extends string = "hello"> = ...

在使用默认值时,顺序很重要。这只是与常规 JavaScript 函数的许多相似之处之一!所以既然我们几乎在讨论函数,为什么我们要为通用类型参数使用单字母名称呢?

大多数通用类型参数以字母T开头。后续参数按字母表(UVW)或缩写如K表示key的方式进行。然而,这可能导致类型非常难读。例如看Extract<T, U>,很难确定我们是从U中提取T,还是反过来。

更加精心设计有所帮助:

type Extract<From, Union> = ...

现在我们知道我们要从第一个参数中提取可赋值给Union的所有内容。此外,我们理解我们想要一个联合类型。

类型是文档,我们的类型参数可以有口语化的名称,就像处理常规函数一样。采用像这样的命名方案:

  • 所有类型参数都以大写字母开头,就像您命名所有其他类型一样!

  • 只有在使用完全清晰时才使用单个字母。例如,ParseRouteParams只能有一个参数,即路由。

  • 不要缩写为T(那太……通用了!),而是用能够明确我们在处理什么的东西来命名。例如,ParseRouteParams<R>,其中R代表Route

  • 很少使用单个字母;坚持使用短词或缩写:Elem表示ElementRoute可以保持原样。

  • 使用前缀来区别于内置类型。例如,Element已被占用,因此使用GElement(或坚持使用Elem)。

  • 使用前缀使通用名称更清晰:URLObjObj更清晰。

  • 同样的模式适用于通用类型中的推断类型。

再次看看ParseRouteParams,并对我们的名称更加明确:

type ParseRouteParams<Route> =
  Route extends `${string}/:${infer Param}/${infer Rest}` ?
    { [Entry in Param | keyof ParseRouteParameters<`/${Rest}`>]: string } :
  Route extends `${string}/:${infer Param}` ?
    { [Entry in Param]: string } : {}

每种类型意图明确多了。我们还看到,即使Param只是一个类型的集合,我们也需要迭代所有Entry

可以说,这比以前要可读性强得多!

还有一个注意事项:几乎不可能区分类型参数和实际类型。还有另一种方案由Matt Pocock广泛推广:使用T前缀:

type ParseRouteParameters<TRoute> =
  Route extends `${string}/:${infer TParam}/${infer TRest}` ?
    { [TEntry in TParam | keyof ParseRouteParameters<`/${TRest}`>]: string } :
  Route extends `${string}/:${infer TParam}` ?
    { [TEntry in TParam]: string } : {}

这与 匈牙利命名法 类似,用于类型。

无论您使用何种变体,确保泛型类型对您和您的同事易读,并且它们的参数清晰明了,与其他编程语言一样重要。

12.9 在 TypeScript Playground 上进行原型设计

问题

由于项目过大,难以正确修复类型错误。

解决方案

将您的类型移至 TypeScript Playground 并在隔离环境中开发它们。

讨论

TypeScript Playground 如 图 12-1 所示,这是一个自 TypeScript 首次发布以来一直存在的 Web 应用程序,展示了 TypeScript 语法如何编译为 JavaScript。它最初的能力有限,专注于帮助新开发者入门,但近年来已成为在线开发的强大工具,功能丰富且对 TypeScript 开发至关重要。TypeScript 团队要求人们提交问题,并使用 Playground 重现 Bug。他们还通过允许加载夜间版本来测试新功能。简而言之,TypeScript Playground 对于 TypeScript 开发至关重要。

tscb 1201

图 12-1. TypeScript Playground 展示的一个内置示例

对于您的常规开发实践,TypeScript Playground 是在与当前项目独立无关的环境中开发类型的绝佳方式。随着 TypeScript 配置的增长,它们变得混乱,很难理解哪些类型对您的实际项目有贡献。如果在类型中遇到奇怪或意外的行为,请尝试在 Playground 中隔离地重新创建它们。

Playground 并不包含完整的 tsconfig.json,但您可以通过用户界面定义配置的重要部分,如 图 12-2 中所示。或者,您可以直接在源代码中使用注解来设置编译器标志:

// @strictPropertyInitialization: false
// @target: esnext
// @module: nodenext
// @lib: es2015,dom

尽管不太方便,但高度符合人体工程学,因为它可以更轻松地共享编译器标志。

tscb 1202

图 12-2. 而不是编写一个实际的 tsconfig.json 文件,你可以使用 TSConfig 面板来设置编译器标志。

您还可以编译 TypeScript,获取提取的类型信息,运行小段代码以查看其行为,并将所有内容导出到各种目的地,包括其他流行的在线编辑器和 IDE。

您可以选择各种版本来确保您的错误不依赖于版本更新,并且您可以运行各种详细记录的示例,以便在尝试实际源代码时学习 TypeScript 的基础知识。

如 Recipe 12.10 中所述,在 TypeScript playground 中,无法离开依赖项的支持开发 JavaScript。在 TypeScript playground 中,可以直接从 NPM 获取依赖项的类型信息。例如,在 TypeScript playground 中导入 React,该 playground 将尝试获取类型:

  1. 首先,它将查看 NPM 上的相应包,并检查其内容中是否定义了类型或 .d.ts 文件。

  2. 如果没有,则会在 NPM 上检查是否存在 Definitely Typed 的类型信息,并将下载相应的 @types 包。

这是递归的,意味着如果某些类型需要来自其他包的类型,类型获取也将通过类型依赖进行。对于某些包,甚至可以定义加载哪个版本:

import { render } from "preact"; // types: legacy

这里,types 设置为 legacy,加载来自 NPM 的相应旧版本。

生态系统还有更多。TypeScript playground 的重要工具是 Twoslash。Twoslash 是 TypeScript 文件的标记格式,允许您突出显示代码、处理多个文件,并显示 TypeScript 编译器创建的文件。它非常适合博客和网站——基本上,您拥有了内联的 TypeScript 编译器用于代码示例——但如果您需要创建复杂的调试场景,它也非常棒。

Twoslash 处理编译器标志注解,但您还可以通过在变量名直接下方的注释中添加标记来获取当前类型的内联提示:

// @jsxFactory: h
import { render, h } from "preact";

function Heading() {
    return <h1>Hello</h1>
}

const elem = <Heading/>
//    ^?
// This line above triggers inline hints

您可以在 图 12-3 中看到结果。

tscb 1203

图 12-3. Twoslash 的应用:通过注释设置编译器标志

Twoslash 还是 bug workbench 的一部分,这是 playground 的一个分支,重点是创建和显示 bug 的复杂重现。在这里,您还可以定义多个文件,以查看导入和导出的工作方式:

export const a = 2;

// @filename: a.ts

import { a } from "./input.js"
console.log(a);

多文件支持由第一个 @filename 注解触发。此行之前的所有内容都成为一个名为 input.tsx 的文件,基本上是您的主入口点。

最后但同样重要的是,playground 可以作为工作坊和培训的整个演示套件。使用 Twoslash,您可以在 GitHub Gist 存储库中创建多个文件,并加载 TypeScript 文件以及作为 Gist 文档集的一部分的文档,如 图 12-4 所示。

对于沉浸式学习来说,这非常强大。从简单的重现到完整的演示套件,TypeScript playground 是 TypeScript 开发人员的一站式资源——无论您是需要报告 bug、尝试新功能还是独立工作于类型之上。这是一个很好的起点资源,从那里您可以轻松迁移到“真正”的 IDE 和工具。

tscb 1204

图 12-4. 在 playground 中的 Gist 文档集

12.10 提供多个库版本

问题

您为库编写外部类型,并希望相对于库版本更新保持类型更新。

解决方案

使用三斜杠引用指令,以及模块、命名空间和接口用于声明合并。

讨论

编程如果没有处理很多工作的外部库会变得很困难。在第三方依赖方面,JavaScript 生态系统可以说是最丰富之一,主要通过NPM实现。大多数库都支持 TypeScript,要么通过内置类型,要么通过 Definitely Typed 提供的类型。根据 TypeScript 团队的说法,几乎80% 的 NPM 包是有类型定义的。然而,仍然有一些例外情况:例如,某些库不是用 TypeScript 编写的,或者是你公司的遗留代码,你仍然需要使其兼容今天的软件。

想象一个名为 "lib" 的库,它公开了一个 Connector 类,你可以用它来访问内部系统。这个库存在多个版本,并且功能不断添加:

import { Connector } from "lib";

// This exists in version 1
const connector = new Connector();
const connection = connector.connect("127.0.0.1:4000");

connection.send("Hi!");

// This exists in version 2
connection.close();

值得注意的是,这个库可以被你组织内的多个项目使用,并且版本可能各不相同。你的任务是编写类型,以便你的团队能够得到正确的自动补全和类型信息。

在 TypeScript 中,你可以通过为每个库版本创建一个环境模块声明来提供多个版本的类型定义。环境模块声明是一个带有 .d.ts 扩展名的文件,为 TypeScript 提供了非 TypeScript 编写的库的类型。

默认情况下,TypeScript 是贪婪的:它包括类型定义并且 全面包含 所有它能找到的内容。如果要限制 TypeScript 的文件访问,请确保在 tsconfig.json 中使用 "exclude""include" 属性:

{
  "compilerOptions": {
    // ...
    "typeRoots": [
      "@types"
    ],
    "rootDir": "./src",
    "outDir": "dist",
  },
  "include": ["./src", "./@types"]
}

我们在 tsconfig.json 包含的文件夹旁边创建一个名为 lib.v1.d.ts 的文件,在这里,我们存储有关如何创建对象的基本信息:

declare module "lib" {
  export interface ConnectorConstructor {
    new (): Connector;
  }
  var Connector: ConnectorConstructor;

  export interface Connector {
    connect(stream: string): Connection;
  }

  export interface Connection {
    send(msg: string): Connection;
  }
}

注意我们使用模块来定义模块的名称,并且我们也使用接口来定义大多数类型。模块和接口都可以进行声明合并,这意味着我们可以在不同文件中添加新的类型,并且 TypeScript 会将它们合并在一起。如果我们想定义多个版本,这一点至关重要。

还要注意,我们使用构造函数接口模式(参见 Recipe 11.3)来定义 Connector

export interface ConnectorConstructor {
  new (): Connector;
}
var Connector: ConnectorConstructor;

通过这样做,我们可以改变构造函数的签名,并确保 TypeScript 能识别出可实例化的类。

在名为 lib.v2.d.ts 的另一个文件中,旁边是 lib.v1.d.ts,我们重新声明 "lib" 并向 Connection 添加更多方法。通过声明合并,close 方法被添加到 Connection 接口中:

/// <reference path="lib.v1.d.ts" />

declare module "lib" {
  export interface Connection {
    close(): void;
  }
}

使用三斜杠引用指令,我们从 lib.v2.d.ts 引用到 lib.v1.d.ts,表示版本 2 中包含版本 1 的所有内容。

所有这些文件存在于一个名为 @lib 的文件夹中。根据我们之前声明的配置,TypeScript 不会将它们拾取起来。然而,我们可以编写一个新文件 lib.d.ts 并将其放在 @types 中,并从那里引用我们想要包含的版本:

/// <reference path="../@lib/lib.v2.d.ts" />

declare module "lib" {}

简单地从 “../@lib/lib.v2.d.ts” 改为 “../@lib/lib.v1.d.ts” 将会改变我们目标的版本,同时仍然独立地维护所有库版本。

如果您感兴趣,可以尝试查看 TypeScript 的包含库文件。它们是外部类型定义的宝库,有很多可以学习的地方。例如,如果您使用编辑器查找对 Object.keys 的引用,您将看到此函数存在于多个位置,并且根据您的 TypeScript 配置,将包含适当的文件。图 12-5 展示了 Visual Studio Code 如何显示 Object.keys 的各个文件位置。TypeScript 如此灵活,您可以为您的项目使用相同的技术,甚至扩展 TypeScript 的内置类型本身(参见食谱 9.7)。

tscb 1205

图 12-5. 在 Visual Studio Code 中查找内置类型的引用显示了 TypeScript 如何管理多个 ECMAScript 和 DOM 的版本

总之,在 TypeScript 中为库的多个版本提供多个类型可以通过为每个库版本创建环境模块声明并在 TypeScript 代码中引用适当的声明来完成。希望您能够在项目中使用包管理器来管理不同版本的库及其相应的类型,从而更轻松地处理依赖关系并避免冲突。

12.11 停止的时机

问题

编写复杂和复杂的类型是令人筋疲力尽的!

解决方案

不要编写复杂和复杂的类型。 TypeScript 是渐进式的;使用让您高效的内容。

讨论

我想以一些关于如何在正确的时间停止的一般建议结束本书。如果您已经阅读了整本书并且最终到达这里,您已经阅读了一百多个关于日常 TypeScript 问题的建议。无论是项目设置、需要找到正确类型的复杂情况,还是在 TypeScript 对其自身的好处过于严格时的变通方法,我们都已经覆盖了一切。

解决方案可能变得非常复杂,特别是当我们涉及条件类型及其周围的一切时,如辅助类型、可变元组类型和字符串模板字面类型。 TypeScript 的类型系统无疑非常强大,特别是如果您理解每一个决策、每一个特性都源于 JavaScript 潜在的事实。为一门如此内在动态的编程语言创建一个提供强大、静态类型的类型系统是一个了不起的成就。我对那些在 Redmond 做出这一切可能的聪明头脑深表敬意。

但是,不可否认,有时情况可能会变得非常复杂。类型可能难以阅读或创建,而类型系统本身又是一个图灵完备的元编程系统,需要测试库的支持也并不帮助。开发者们以理解他们工具和技能的每一个方面为荣,通常更倾向于复杂的类型解决方案,而不是简单的类型,尽管简单类型可能不提供相同的类型安全性,但更容易阅读和理解。

一个深入研究类型系统的项目被称为 Type Challenges。这是一个展示类型系统可能性的精彩项目。我喜欢尝试一些更具挑战性的谜题,从中获得如何更好地解释类型系统的好主意。虽然这些谜题对培养开发者的思维非常有帮助,但大多数情况下它们缺乏对真实世界日常情况的重要把握。

而这些情况下,我们经常忽视 TypeScript 的一个了不起的功能,这在主流编程语言中并不常见:它对类型的渐进采纳。像 any、泛型类型参数和类型断言以及你可以用几个注释编写简单的 JavaScript 的事实,降低了进入门槛。TypeScript 团队和 TC39 的最新努力是通过在 JavaScript 中添加 类型注解 来进一步降低这个门槛,这个提案目前正在讨论中。该提案的目标不是使 JavaScript 类型安全,而是在我们想要简单、易于理解的类型注解时,移除编译步骤。JavaScript 引擎可以将其视为注释,并且类型检查器可以获取程序语义的真实信息。

作为开发者、项目负责人、工程师和架构师,我们应该使用这个特性。简单的类型总是更好的类型:更容易理解,也更容易使用。

TypeScript 网站 将其宣称从 “JavaScript that scales” 更改为 “JavaScript with syntax for types”,这应该让你了解如何在项目中使用 TypeScript:编写 JavaScript,在必要时注释,编写简单但全面的类型,并将 TypeScript 作为记录、理解和传达软件的一种方式。

我认为 TypeScript 遵循 帕累托法则:80% 的类型安全性来自其 20% 的特性。这并不意味着其余部分是不好或不必要的。我们只是花了一百个场景来理解在哪些情况下我们实际上需要 TypeScript 的更高级特性。这应该只是一个指导方向。不要在每个场合都使用高级的 TypeScript 技巧。监控一下是否 loser types 是一个问题。估计一下在你的程序中更改类型的工作量,并做出明智的决策。另外,请注意,在一个细化的过程中(见 Recipe 12.2),多个步骤的原因在于可以轻松地停止。

^(1) 流畅接口通过在每次方法调用时返回实例来实现可链接操作。

posted @ 2025-11-19 09:22  绝不原创的飞龙  阅读(18)  评论(0)    收藏  举报