TypeScript-编程指南-全-
TypeScript 编程指南(全)
原文:
zh.annas-archive.org/md5/6427c146f68073678c9f771156c3d4aa译者:飞龙
前言
这本书适合各种程序员:专业的 JavaScript 工程师、C# 开发者、Java 爱好者、Python 爱好者、Ruby 爱好者、Haskell 粉丝。无论你使用哪种语言,只要有一定的编程经验并了解函数、变量、类和错误的基础知识,这本书都适合你。对 JavaScript 有一定经验,包括对文档对象模型(DOM)和网络的基本了解,会帮助你更好地理解内容——虽然我们不深入讨论这些概念,但它们是出色示例的源泉,如果你不熟悉它们,可能会对示例的理解产生困难。
无论你之前使用过什么编程语言,我们都有一个共同的经历:追踪异常、逐行分析代码,找出问题所在以及如何修复它。TypeScript 可以自动检查你的代码并指出可能忽略的错误,从而帮助防止这种经历。
如果你以前没有使用过静态类型语言,也没关系。我将教你有关类型以及如何有效使用它们,以减少程序崩溃、改善代码文档化,并使你的应用程序在更多用户、工程师和服务器之间扩展。我会尽量避免使用专业术语,并以直观、记忆深刻和实用的方式解释思想,沿途使用大量示例来帮助保持具体性。
TypeScript 的一个特点是:与许多其他静态类型语言不同,它极其实用。它创造全新的概念,让你能更简洁、更准确地表达,使你以一种有趣、现代和安全的方式编写应用程序。
本书的组织结构
本书有两个目标:深入理解 TypeScript 语言的工作原理(理论)并提供大量实用建议,帮助编写生产级 TypeScript 代码(实践)。
因为 TypeScript 是一种非常实用的语言,理论很快就转化为实践,所以本书的大部分内容都是理论与实践的结合。头几章几乎完全是理论,而最后几章则几乎完全是实践。
我将从编译器、类型检查器以及类型的基础知识开始讲起。然后,我会广泛介绍 TypeScript 中不同的类型和类型操作符,它们的用途以及如何使用它们。利用所学,我将涵盖一些高级主题,如 TypeScript 最复杂的类型系统特性、错误处理和异步编程。最后,我将讲述如何将 TypeScript 与你喜爱的框架(前端和后端)结合使用,将现有的 JavaScript 项目迁移到 TypeScript,并将你的 TypeScript 应用投入生产环境。
大多数章节末尾都有一组练习题。试着自己完成这些练习——这比单纯阅读能更深入理解我们所讲内容。章节练习的答案可以在网上找到,网址是 https://github.com/bcherny/programming-typescript-answers。
风格
在整本书中,我尽量保持一致的代码风格。这种风格的某些方面非常个人化,例如:
-
只在必要时使用分号。
-
我用两个空格缩进。
-
在程序是一个快速片段或程序结构比细节更重要时,我使用诸如
a、f或 _ 的短变量名。
然而,代码风格的某些方面是我认为你也应该遵循的。其中一些是:
-
你应该使用最新的 JavaScript 语法和特性(最新的 JavaScript 版本通常称为“esnext”)。这将使你的代码符合最新的标准,提高互操作性和可搜索性,并有助于减少新员工的上手时间。它还可以让你利用强大的现代 JavaScript 特性,如箭头函数、Promise 和生成器。
-
你应该大多数时候使用扩展运算符 (
...) 使你的数据结构不可变。^(1) -
你应该确保每个东西都有一个类型,在可能的情况下推断类型。要小心不要滥用显式类型;这将有助于保持代码清晰简洁,并通过展示不正确的类型而不是简单覆盖它们来提高安全性。
-
你应该保持你的代码可重用和通用。多态性(见 “多态性”)是你的好朋友。
当然,这些想法并非新鲜事物。但 TypeScript 在遵循这些想法时表现特别出色。TypeScript 的内置降级编译器、对只读类型的支持、强大的类型推断、深度支持多态性以及完全结构化类型系统都鼓励良好的编码风格,同时语言保持极高的表达能力和对底层 JavaScript 的真实支持。
在我们开始之前,还有几点需要注意。
JavaScript 并没有暴露指针和引用;相反,它有值类型和引用类型。值是不可变的,包括字符串、数字和布尔值,而引用指向通常是可变的数据结构,如数组、对象和函数。在本书中,当我使用“值”一词时,通常是宽泛地指代 JavaScript 值或引用。
最后,在与 JavaScript 互操作、不正确类型化的第三方库、遗留代码或者时间紧迫时,你可能会发现自己在实际中写了不那么理想的 TypeScript 代码。本书大部分内容展示了你应该如何编写 TypeScript,并论述了为什么你应该尽力避免妥协。但实际上,你和你的团队的代码正确性取决于你们自己。
本书中使用的约定
本书使用以下排版约定:
Italic
指示新术语、网址、电子邮件地址、文件名和文件扩展名。
Constant width
用于程序清单,以及段落中引用程序元素,如变量或函数名、数据类型、环境变量、语句和关键字。
Constant width italic
显示应由用户提供值或由上下文确定的值替换的文本。
提示
此元素表示提示或建议。
注意
此元素表示一般注意事项。
警告
此元素表示警告或注意事项。
使用示例代码
可以下载补充材料(示例代码、练习等):https://github.com/bcherny/programming-typescript-answers
本书旨在帮助你完成工作。一般情况下,如果本书提供示例代码,你可以在你的程序和文档中使用它。除非你复制了代码的大部分内容,否则无需征求我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。售卖或分发包含奥莱利书籍示例的 CD-ROM 需要许可。通过引用本书和引用示例代码来回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。
我们感谢但不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Programming TypeScript by Boris Cherny (O’Reilly). Copyright 2019 Boris Cherny, 978-1-492-03765-1.”
如果您认为您对示例代码的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com联系我们。
奥莱利在线学习
注意
近 40 年来,奥莱利传媒 提供技术和商业培训、知识和见解,帮助公司取得成功。
我们独特的专家和创新者网络通过书籍、文章、会议以及我们的在线学习平台分享他们的知识和专业知识。奥莱利的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及奥莱利及其他 200 多家出版商的大量文本和视频。更多信息,请访问http://oreilly.com。
如何联系我们
请将关于本书的评论和问题发送给出版商:
-
奥莱利传媒公司
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938 (在美国或加拿大)
-
707-829-0515 (国际或当地)
-
707-829-0104 (传真)
我们为本书准备了一个网页,其中列出了勘误、示例和任何其他信息。您可以访问这个页面:https://oreil.ly/programming-typescript。
如果您对本书有评论或技术问题,请发送电子邮件至bookquestions@oreilly.com。
获取关于我们的书籍、课程、会议和新闻的更多信息,请访问我们的网站:http://www.oreilly.com
在 Facebook 上找到我们:http://facebook.com/oreilly
在 Twitter 上关注我们:http://twitter.com/oreillymedia
在 YouTube 上观看我们:http://www.youtube.com/oreillymedia
致谢
这本书是多年碎片和涂鸦的产物,紧随其后的是一年早晨、夜晚、周末和假期的时间,用于写作。
感谢 O’Rielly 为我提供参与本书编写的机会,感谢我的编辑 Angela Rufino 在整个过程中的支持。感谢 Nick Nance 在“Typesafe APIs”中的贡献,以及 Shyam Seshadri 在“Angular 6/7”中的贡献。感谢我的技术编辑们:TypeScript 团队的 Daniel Rosenwasser,在阅读这份手稿并指导我了解 TypeScript 类型系统的微妙之处时花费了大量的时间;Jonathan Creamer、Yakov Fain、Paul Buying 和 Rachel Head 为技术编辑和反馈所做的贡献。感谢我的家人——Liza 和 Ilya,Vadim,Roza 和 Alik,Faina 和 Yosif——鼓励我追求这个项目。
最重要的是,感谢我的搭档 Sara Gilford,在整个写作过程中支持我,即使这意味着取消周末计划、深夜写作和编码,以及关于类型系统的内外讨论太多。没有你的支持,我无法完成这个项目,我会永远感激你的支持。
^(1) 如果你不是来自 JavaScript,这里有个例子:如果你有一个对象o,并且想要向其添加属性k,并赋值为3,你可以直接修改o——o.k = 3——或者你可以对o应用你的更改,生成一个新对象作为结果——let p = {...o, k: 3}。
第一章:引言
所以,你决定买一本关于 TypeScript 的书。为什么呢?
或许是因为你受够了那些奇怪的cannot read property blah of undefined JavaScript 错误。或者你听说过 TypeScript 能帮助你的代码更好地扩展,并想看看到底有什么了不起的地方。或者你是 C#开发者,一直在考虑尝试一下这整个 JavaScript 的事情。或者你是函数式编程者,决定是时候把自己的技能提升到更高的水平。或者你的老板对你的代码在生产环境中引起问题感到非常厌烦,所以他们把这本书作为圣诞礼物送给了你(如果我说得不对,请告诉我)。
无论你的理由是什么,你听到的都是真的。TypeScript 是下一代 Web 应用程序、移动应用程序、NodeJS 项目和物联网(IoT)设备的核心语言。它通过检查常见的错误使你的程序更加安全,为你自己和未来的工程师提供文档,使重构变得轻松,并使得,像,你一半的单元测试变得不必要(“什么是单元测试?”)。TypeScript 将使你作为程序员的生产力翻倍,并让你和对面街上那个可爱的咖啡师有个约会。
但在你匆忙穿过马路之前,让我们详细分析一下,首先从这里开始:当我说“更安全”时,我到底是什么意思?当然,我说的是类型安全。
这里有一些无效的例子:
-
将一个数字和一个列表相乘
-
当实际需要一个对象列表时,却用一个字符串列表调用一个函数
-
当在一个对象上调用一个方法,而该方法实际上并不存在于该对象上时
-
导入最近移动的模块
有些编程语言试图充分利用这些错误。它们试图弄清楚当你做一些无效操作时,你实际上想做什么,因为嘿,你尽力而为,对吧?拿 JavaScript 来说:
3 + [] // Evaluates to the string "3"
let obj = {}
obj.foo // Evaluates to undefined
function a(b) {
return b/2
}
a("z") // Evaluates to NaN
注意,JavaScript 不会在尝试显然无效的操作时抛出异常,而是尽力做到最好,尽量避免异常。JavaScript 这样做是有帮助的吗?当然。它能让你快速捕捉到 bug 吗?可能不会。
现在想象一下,如果 JavaScript 抛出更多的异常,而不是悄悄地做到最好。我们可能会得到这样的反馈:
3 + [] // Error: Did you really mean to add a number and an array?
let obj = {}
obj.foo // Error: You forgot to define the property "foo" on obj.
function a(b) {
return b/2
}
a("z") // Error: The function "a" expects a number,
// but you gave it a string.
不要误会我的意思:为我们修复错误是一个很棒的编程语言功能(如果它可以用于更多的东西就好了!)。但对于 JavaScript 来说,这个特性会导致你在编写代码时犯了一个错误,而发现这个错误的时间有所延迟。通常情况下,你第一次听说自己的错误是从别人那里听到的。
所以这里有一个问题:JavaScript 究竟在什么时候告诉你你犯了一个错误呢?
Right:当你实际运行你的程序时。你的程序可能会在你在浏览器中测试它时运行,或者当用户访问你的网站时运行,或者当你运行单元测试时运行。如果你很有纪律性地编写大量的单元测试和端到端测试,在推送代码之前进行冒烟测试,并在向用户发布之前在内部进行测试,你有希望在用户之前发现你的错误。但如果你没有呢?
这就是 TypeScript 的作用。比 TypeScript 给出有用的错误消息更酷的是它给你的时间:TypeScript 在你输入时会在你的文本编辑器中给出错误消息。这意味着你不必依赖单元测试、冒烟测试或同事来捕捉这些问题:TypeScript 会在你编写程序时捕捉并警告你。让我们看看 TypeScript 对我们之前例子的看法:
3 + [] // Error TS2365: Operator '+' cannot be applied to types '3'
// and 'never[]'.
let obj = {}
obj.foo // Error TS2339: Property 'foo' does not exist on type '{}'.
function a(b: number) {
return b / 2
}
a("z") // Error TS2345: Argument of type '"z"' is not assignable to
// parameter of type 'number'.
除了消除整类与类型相关的错误之外,这实际上会改变你编写代码的方式。你会发现自己在填写值级别之前,在类型级别上勾勒出程序;^(2)在设计程序时,你会考虑边缘情况,而不是事后的想法;而且你会设计更简单、更快速、更易于理解和维护的程序。
你准备好开始这段旅程了吗?让我们开始吧!
^(1)根据你使用的静态类型语言,“无效”可能意味着一系列不同的情况,从运行时会崩溃的程序到明显荒谬但不会崩溃的事物。
^(2)如果你不确定这里的“类型级别”是什么意思,别担心。我们将在后面的章节中深入讨论它。
第二章:TypeScript:一个 10_000 英尺视角
在接下来的几章中,我将介绍 TypeScript 语言,向你概述 TypeScript 编译器(TSC)的工作原理,并带你游览 TypeScript 的特性及你可以开发的模式。我们将从编译器开始。
编译器
根据你过去使用的编程语言(也就是,在你决定购买这本书并致力于类型安全生活之前),你对程序如何工作有不同的理解。TypeScript 的工作方式与其他主流语言(如 JavaScript 或 Java)相比是不同寻常的,因此在我们继续深入之前,我们达成共识是很重要的。
让我们从广义上开始:程序是由你,程序员,编写的一堆文本组成的文件。这些文本被一个称为编译器的特殊程序解析,将其转换为抽象语法树(AST),这是一种数据结构,忽略像空格、注释以及你在制表符与空格之争中站在哪一边这样的事物。编译器然后将该 AST 转换为称为字节码的低级表示。你可以将该字节码提供给另一个称为运行时的程序来评估它并获得结果。因此,当你运行一个程序时,你实际上是告诉运行时评估由编译器从你的源代码解析的 AST 生成的字节码。细节有所不同,但对于大多数语言来说,这是一个准确的高层视图。
再次,这些步骤是:
-
程序被解析成一个 AST。
-
AST 被编译成字节码。
-
字节码由运行时评估。
TypeScript 的特殊之处在于,它不是直接编译成字节码,而是编译成…… JavaScript 代码!然后你像平常一样运行那些 JavaScript 代码——在你的浏览器里,或者用 NodeJS,或者用纸和笔手动输入(针对那些在机器起义后阅读本文的人)。
在这一点上,你可能会想:“等等!在上一章中你说 TypeScript 让我的代码更安全!那是什么时候发生的?”
很好的问题。我实际上跳过了一个关键步骤:在 TypeScript 编译器为你的程序生成 AST 之后——但在它发出代码之前——它会类型检查你的代码。
这种类型检查是 TypeScript 背后的魔力。这是 TypeScript 确保你的程序按照你的期望工作的方式,没有明显错误,并且当街对面可爱的咖啡师确实会在约定时间打电话给你的方式。(别担心,他们可能只是忙着。)
因此,如果我们包括类型检查和 JavaScript 生成,现在编译 TypeScript 的过程大致如 Figure 2-1 所示:

图 2-1. 编译和运行 TypeScript
步骤 1–3 由 TSC 完成,步骤 4–6 由你的浏览器、NodeJS,或者你使用的任何 JavaScript 引擎中的 JavaScript 运行时完成。
注意
JavaScript 的编译器和运行时通常被整合成一个称为引擎的单一程序;作为程序员,这是你通常会交互的东西。这是 V8(驱动 NodeJS、Chrome 和 Opera 的引擎)、SpiderMonkey(Firefox)、JSCore(Safari)和 Chakra(Edge)的工作方式,它也是 JavaScript 看起来像是解释语言的原因。
在这个过程中,步骤 1–2 使用了你程序的类型;步骤 3 则没有。这值得重申:当 TSC 将你的 TypeScript 代码编译成 JavaScript 时,它不会查看你的类型。这意味着你程序的类型永远不会影响到生成的输出,并且只用于类型检查。这个特性使得可以轻松地玩耍、更新和改进你的程序类型,而不会危及到你的应用程序。
类型系统
现代语言有各种不同的类型系统。
通常有两种类型系统:一种需要使用显式语法告诉编译器每个东西的类型,另一种则会自动为你推断东西的类型。这两种方法都有权衡之处。^(1)
TypeScript 同时受到这两种类型系统的启发:你可以显式注释你的类型,也可以让 TypeScript 大部分时间为你推断类型。
要明确告诉 TypeScript 你的类型是什么,请使用注释。注释的形式是值: 类型,并告诉类型检查器,“嘿!你看见这里的值吗?它的类型是类型。”让我们看几个例子(每行后面的注释是 TypeScript 推断的实际类型):
let a: number = 1 // a is a number
let b: string = 'hello' // b is a string
let c: boolean[] = [true, false] // c is an array of booleans
如果你希望 TypeScript 为你推断类型,只需省略它们,让 TypeScript 自动处理即可:
let a = 1 // a is a number
let b = 'hello' // b is a string
let c = [true, false] // c is an array of booleans
立即你就会注意到 TypeScript 在为你推断类型方面的出色表现。如果你省略了注释,类型是相同的!在本书中,我们将仅在必要时使用注释,并尽可能让 TypeScript 发挥其推断的魔力。
注意
一般来说,让 TypeScript 尽可能多地为你推断类型是一种良好的风格,将显式类型代码保持到最小。
TypeScript 与 JavaScript 比较
让我们深入了解 TypeScript 的类型系统,以及它与 JavaScript 的类型系统的比较。表 2-1 提供了一个概述。了解这些差异对于建立 TypeScript 工作方式的心理模型至关重要。
表 2-1. 比较 JavaScript 和 TypeScript 的类型系统
| 类型系统特性 | JavaScript | TypeScript |
|---|---|---|
| 类型如何绑定? | 动态地 | 静态地 |
| 类型是否自动转换? | 是 | 否(大多数情况下) |
| 类型何时检查? | 在运行时 | 在编译时 |
| 错误何时暴露? | 在运行时(大多数情况下) | 在编译时(大多数情况下) |
类型如何绑定?
动态类型绑定意味着 JavaScript 需要实际运行你的程序才能知道其中的各种类型。JavaScript 在运行你的程序之前并不知道你的类型。
TypeScript 是一种逐渐类型化的语言。这意味着在编译时,TypeScript 最擅长于了解程序中所有内容的类型,但并不需要了解所有类型就能编译你的程序。即使在未类型化的程序中,TypeScript 也可以为你推断一些类型并捕捉一些错误,但如果不了解程序中所有内容的类型,它将让许多错误传递给用户。
这种逐渐类型化对于将未类型化的 JavaScript 遗留代码库迁移到类型化的 TypeScript 中非常有用(详见“逐步从 JavaScript 迁移到 TypeScript”),但除非你正在迁移代码库,否则应该力求 100%的类型覆盖率。这本书的方法是如此,除非特别注明。
类型是否会自动转换?
JavaScript 是弱类型语言,这意味着如果你做了一些无效的事情,比如将一个数字和一个数组相加(就像我们在第一章中做的那样),它会应用一系列规则来弄清楚你的真实意图,以便尽可能有效地处理你给它的内容。让我们通过具体的例子来解释 JavaScript 如何评估3 + [1]:
-
JavaScript 注意到
3是一个数字,[1]是一个数组。 -
因为我们使用了
+,它假设我们想要连接这两个值。 -
它将
3隐式转换为字符串,得到"3"。 -
它将
[1]隐式转换为字符串,得到"1"。 -
它连接这些结果,得到
"31"。
我们也可以更加显式地做这件事情(这样 JavaScript 就避免了步骤 1、3 和 4):
3 + [1]; // evaluates to "31"
(3).toString() + [1].toString() // evaluates to "31"
尽管 JavaScript 试图通过为你进行巧妙的类型转换来帮助你,但 TypeScript 会在你做出无效操作时立即抱怨。当你通过 TSC 运行同样的 JavaScript 代码时,你会得到一个错误:
3 + [1]; // Error TS2365: Operator '+' cannot be applied to
// types '3' and 'number[]'.
(3).toString() + [1].toString() // evaluates to "31"
如果你做了什么看起来不对的事情,TypeScript 会抱怨;如果你对自己的意图表达明确,TypeScript 会离开你的道路。这种行为是有道理的:在正常情况下,除了 JavaScript 女巫 Bavmorda 之外(她在你创业公司的地下室里点蜡烛编程),谁会试图将一个数字和一个数组相加,期望结果是一个字符串呢?
JavaScript 进行的这种隐式转换可能是错误的一个难以追踪的来源,也是许多 JavaScript 程序员的噩梦。它让单个工程师难以完成工作,也让整个团队更难以扩展代码,因为每个工程师都需要理解你的代码所做的隐含假设。
简而言之,如果必须进行类型转换,请显式地进行。
什么时候检查类型?
在大多数情况下,JavaScript 并不关心你给它什么类型,而是尽量将你给它的内容转换为它期望的内容。
另一方面,TypeScript 在编译时检查你的代码(记住本章开头的步骤 2),因此你无需实际运行代码就能看到前面示例中的Error。TypeScript 静态分析 你的代码以检测此类错误,并在运行之前展示给你。如果你的代码无法编译,那很可能是你犯了错误,应该在运行代码之前修复它。
图 2-2 展示了当我将最后一个代码示例输入到 VSCode(我首选的代码编辑器)时会发生什么。

图 2-2. VSCode 报告的 TypeError
使用你喜欢的代码编辑器安装好 TypeScript 扩展后,当你输入代码时,错误将以红色的波浪线显示出来。这显著加快了编写代码、意识到错误并更新代码以修复错误之间的反馈循环。
错误会在何时显示?
当 JavaScript 抛出异常或执行隐式类型转换时,这是在运行时进行的。^(2) 这意味着你必须实际运行程序才能得到一个有用的信号,表明你做了一些无效操作。在最好的情况下,这意味着作为单元测试的一部分;在最坏的情况下,这意味着用户的一封愤怒的电子邮件。
TypeScript 在编译时抛出与语法相关的错误和类型相关的错误。在实际操作中,这意味着这些类型的错误将在你输入代码时立即在代码编辑器中显示出来——如果你以前从未使用过增量编译的静态类型语言,这将是一种令人惊叹的体验。^(3)
尽管如此,TypeScript 仍无法在编译时捕获许多错误,比如堆栈溢出、网络连接中断和用户输入格式错误等,在这些情况下仍会导致运行时异常。TypeScript 的作用是将在纯 JavaScript 环境中本来会是运行时错误的大多数错误变成编译时错误。
代码编辑器设置
现在你对 TypeScript 编译器和类型系统的工作有了一些直觉,让我们设置你的代码编辑器,开始深入到一些真实的代码中去。
首先下载一个代码编辑器来编写你的代码。我喜欢使用 VSCode,因为它提供了非常好的 TypeScript 编辑体验,但你也可以选择 Sublime Text、Atom、Vim、WebStorm 或者其他你喜欢的编辑器。工程师们对于集成开发环境非常挑剔,所以选择权留给你来决定。如果你选择使用 VSCode,请按照 网站 上的说明进行设置。
TSC 本身是一个用 TypeScript 编写的命令行应用程序,^(4) 这意味着你需要 NodeJS 来运行它。请访问官方 NodeJS 网站 按照指南安装 NodeJS 到你的计算机上。
NodeJS 附带了 NPM,一个包管理器,您将使用它来管理项目的依赖项并编排构建过程。我们将从使用它来安装 TSC 和 TSLint(TypeScript 的代码检查工具)开始。首先打开您的终端,创建一个新文件夹,然后在其中初始化一个新的 NPM 项目:
# Create a new folder
mkdir chapter-2
cd chapter-2
# Initialize a new NPM project (follow the prompts)
npm init
# Install TSC, TSLint, and type declarations for NodeJS
npm install --save-dev typescript tslint @types/node
tsconfig.json
每个 TypeScript 项目都应该在其根目录中包含一个名为tsconfig.json的文件。tsconfig.json文件用于定义诸如应编译哪些文件、将它们编译到哪个目录以及应生成哪个版本的 JavaScript 等内容。
在根目录中创建一个名为tsconfig.json的新文件(touch tsconfig.json),然后在代码编辑器中打开它,并给它以下内容:
{
"compilerOptions": {
"lib": ["es2015"],
"module": "commonjs",
"outDir": "dist",
"sourceMap": true,
"strict": true,
"target": "es2015"
},
"include": [
"src"
]
}
让我们简要介绍一下其中一些选项及其含义(参见表 2-2):
表 2-2. tsconfig.json选项
| Option | Description |
|---|---|
include |
TypeScript 编译器(TSC)应查找哪些文件夹以找到您的 TypeScript 文件? |
lib |
TSC 应假定在您运行代码的环境中存在哪些 API?这包括 ES5 的Function.prototype.bind、ES2015 的Object.assign以及 DOM 的document.querySelector等内容。 |
module |
TSC 应将您的代码编译到哪种模块系统(CommonJS、SystemJS、ES2015 等)? |
outDir |
TSC 应将生成的 JavaScript 代码放在哪个文件夹中? |
strict |
在检查无效代码时尽可能严格。此选项强制要求所有代码都经过正确类型化。我们将在本书的所有示例中使用它,您在自己的 TypeScript 项目中也应该使用它。 |
target |
TSC 应将您的代码编译到哪个 JavaScript 版本(ES3、ES5、ES2015、ES2016 等)? |
这些只是可用选项中的一小部分—tsconfig.json支持数十个选项,并且新选项会不断添加。在实际应用中,您通常不会经常更改这些选项,除非在切换到新的模块打包工具时调整module和target设置,或者在编写面向浏览器的 TypeScript 时(您将在第十二章中学到更多相关知识),将"dom"添加到lib时,或者在将现有 JavaScript 代码逐步迁移到 TypeScript 时调整strict性级别(参见“逐步从 JavaScript 迁移到 TypeScript”)。有关完整和最新支持的选项列表,请访问TypeScript 官方文档。
注意,虽然使用tsconfig.json文件配置 TSC 很方便,因为它允许我们将配置纳入源代码控制,但您也可以通过命令行设置大多数 TSC 选项。运行./node_modules/.bin/tsc --help以获取可用命令行选项列表。
tslint.json
您的项目还应包含一个 tslint.json 文件,其中包含您的 TSLint 配置,用于定义您代码的风格约定(制表符与空格等)。
注意
使用 TSLint 是可选的,但强烈建议所有 TypeScript 项目都使用它来强制执行一致的编码风格。最重要的是,它将在代码审查期间为您节省与同事争论代码风格的时间。
以下命令将生成一个包含默认 TSLint 配置的 tslint.json 文件:
./node_modules/.bin/tslint --init
然后,您可以添加覆盖以符合自己的编码风格。例如,我的 tslint.json 如下所示:
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended"
],
"rules": {
"semicolon": false,
"trailing-comma": false
}
}
要获取可用规则的完整列表,请转到TSLint 文档。您还可以添加自定义规则,或安装额外的预设(比如ReactJS)。
index.ts
现在您已经设置好了 tsconfig.json 和 tslint.json,创建一个包含您的第一个 TypeScript 文件的 src 文件夹:
mkdir src
touch src/index.ts
您项目的文件夹结构现在应该看起来像这样:
chapter-2/
├──node_modules/
├──src/
│ └──index.ts
├──package.json
├──tsconfig.json
└──tslint.json
在代码编辑器中打开 src/index.ts,并输入以下 TypeScript 代码:
console.log('Hello TypeScript!')
接下来,编译并运行您的 TypeScript 代码:
# Compile your TypeScript with TSC
./node_modules/.bin/tsc
# Run your code with NodeJS
node ./dist/index.js
如果您按照这里的所有步骤操作,您的代码应该能够运行,并且您应该在控制台中看到一个单独的日志:
Hello TypeScript!
就这样——您刚刚从头开始设置并运行了您的第一个 TypeScript 项目。干得好!
提示
由于这可能是您第一次从头开始设置 TypeScript 项目,我想逐步介绍每个步骤,以便您对所有组成部分有所了解。有几个快捷方式可以帮助您更快地完成这些步骤:
-
安装
ts-node,并使用它来编译并运行您的 TypeScript,只需一个命令即可。 -
使用像
typescript-node-starter这样的脚手架工具,快速为您生成文件夹结构。
练习
现在您的环境已设置好,请在代码编辑器中打开 src/index.ts。输入以下代码:
let a = 1 + 2
let b = a + 3
let c = {
apple: a,
banana: b
}
let d = c.apple * 4
现在将鼠标悬停在 a、b、c 和 d 上,并注意 TypeScript 如何为您推断所有变量的类型:a 是一个 number,b 是一个 number,c 是具有特定形状的对象,d 也是一个 number(参见 图 2-3)。

图 2-3. TypeScript 为您推断类型
玩弄一下您的代码。看看您是否可以:
-
当您执行无效操作时,让 TypeScript 显示红色波浪线(我们称之为“抛出
TypeError”)。 -
阅读
TypeError,并尝试理解其含义。 -
修复
TypeError,并看到红色波浪线消失。
如果您雄心勃勃,尝试编写一段 TypeScript 无法推断类型的代码片段。
^(1) 这个谱系上有各种语言:JavaScript、Python 和 Ruby 在运行时推断类型;Haskell 和 OCaml 在编译时推断并检查缺失的类型;Scala 和 TypeScript 需要一些显式类型,并在编译时推断和检查其余部分;而 Java 和 C 几乎需要为所有事情都做显式注解,它们在编译时进行检查。
^(2) 要确定,JavaScript 在解析你的程序后但在运行前能检测到语法错误和一些特定的错误(例如在相同作用域内多次声明相同名称的 const)。如果在构建过程中解析 JavaScript(例如使用 Babel),你可以在构建时检测到这些错误。
^(3) 增量编译的语言可以在你进行小改动时快速重新编译,而不需要重新编译整个程序(包括你没有触及的部分)。
^(4) 这使得 TSC 属于神秘的编译器类别,称为 自托管编译器,或者能够自我编译的编译器。
^(5) 对于这个练习,我们手动创建了一个 tsconfig.json。在未来设置 TypeScript 项目时,你可以使用 TSC 内置的初始化命令为你生成一个:./node_modules/.bin/tsc --init。
第三章:关于类型的一切
在上一章中,我介绍了类型系统的概念,但我从未真正定义过类型系统中的类型意味着什么。
如果这听起来令人困惑,让我举几个熟悉的例子:
-
boolean类型是所有布尔值的集合(只有true和false),以及您可以对它们执行的操作(如||、&&和!)。 -
number类型是所有数字的集合及其操作(如+、-、*、/、%、||、&&和?),包括您可以调用的方法,如.toFixed、.toPrecision、.toString等。 -
string类型是所有字符串的集合及其操作(如+、||和&&),包括您可以调用的方法,如.concat和.toUpperCase。
当你看到某些东西是类型 T 时,你不仅知道它是 T 类型,而且还确切知道你可以对该 T 类型做什么(以及不能做什么)。记住,整个目的是利用类型检查器阻止您执行无效操作。类型检查器知道什么是有效的,什么是无效的方法是通过查看您使用的类型及其使用方式。
在本章中,我们将介绍 TypeScript 中可用的类型,并介绍每种类型的基本用法。图 3-1 提供了一个概述。

图 3-1. TypeScript 的类型层级结构
关于类型的讨论
当程序员谈论类型时,他们使用精确的共同词汇来描述他们的意思。我们将在本书中始终使用这种词汇。
假设您有一个函数,它接受某个值并返回该值乘以它自己:
function squareOf(n) {
return n * n
}
squareOf(2) // evaluates to 4
squareOf('z') // evaluates to NaN
显然,这个函数只对数字有效——如果您将除数字以外的任何内容传递给 squareOf,结果将是无效的。因此,我们明确注释参数的类型:
function squareOf(n: number) {
return n * n
}
squareOf(2) *`// evaluates to 4`*
squareOf('z') *`// Error TS2345: Argument of type '"z"' is not assignable to`*
*`// parameter of type 'number'.`*
现在,如果我们使用除了数字以外的任何东西调用 squareOf,TypeScript 将立即发出警告。这是一个简单的示例(我们将在下一章节详细讨论函数),但已足以介绍 TypeScript 中几个关键概念。关于最后一个代码示例,我们可以说以下几点:
-
squareOf的参数n受限于number。 -
值
2的类型可以分配给(同样地:兼容于)number。
在没有类型注释的情况下,squareOf 对其参数没有限制,您可以将任何类型的参数传递给它。一旦我们对其进行限制,TypeScript 就会为我们验证我们调用函数的每个位置时都使用了兼容的参数。在这个例子中,值 2 的类型是 number,可以分配给 squareOf 的注释 number,所以 TypeScript 接受我们的代码;但 'z' 是 string,不可分配给 number,因此 TypeScript 报错。
你也可以将其视为边界:我们告诉 TypeScript,n的上界是number,因此我们传递给squareOf的任何值最多只能是一个number。如果它超出了number(例如,如果它是一个可能是number或可能是string的值),那么它就不能赋给n。
我将在第六章中更正式地定义可分配性、边界和约束。目前,你只需知道这是我们用来讨论类型是否可以在我们需要特定类型的地方使用的语言。
类型的基础知识
让我们深入了解 TypeScript 支持的各种类型、它们包含的值以及你可以对它们进行的操作。我们还将涵盖一些用于处理类型的基本语言特性:类型别名、联合类型和交叉类型。
任何
any是类型的教父。它可以为了价格而做任何事情,但是除非你已经彻底无路可走,否则不要向any求助。在 TypeScript 中,一切都需要在编译时具有类型,而当你(程序员)和 TypeScript(类型检查器)无法确定某个东西的类型时,any就是默认类型。它是最后的备选类型,尽可能避免使用它。
为什么应该避免使用它?记住类型是什么吗?(它是一组值及其可以执行的操作。)any是所有值的集合,你可以对any做任何事情。这意味着如果你有一个any类型的值,你可以对它进行加法运算,乘法运算,调用.pizza()——任何操作都可以。
any使你的值表现得像在常规 JavaScript 中一样,并且完全阻止类型检查器发挥其魔力。当你允许any进入你的代码时,你就像闭着眼睛飞行一样。避免像火一样的any,只在绝对没有其他选择时使用它。
在极少数情况下,当你确实需要使用它时,你可以这样做:
let a: any = 666 // any
let b: any = ['danger'] // any
let c = a + b // any
注意第三种类型应该报告错误(你为什么要尝试将一个数字和一个数组相加?),但是并没有,因为你告诉 TypeScript 你正在添加两个any。如果要使用any,你必须明确说明。当 TypeScript 推断出某个值是any类型时(例如,如果你忘记了注释函数的参数,或者导入了一个未经类型标注的 JavaScript 模块),它会在编译时抛出异常,并在编辑器中用红色的波浪线提示你。通过显式注释a和b的类型为any(: any),你可以避免异常——这是告诉 TypeScript 你知道自己在做什么的一种方式。
TSC 标志:noImplicitAny
默认情况下,TypeScript 是宽容的,并且不会抱怨它推断出的值为any。要让 TypeScript 抱怨隐式的any,确保在tsconfig.json中启用noImplicitAny标志。
noImplicitAny是 TSC 标志的strict系列的一部分,因此如果你已经在你的tsconfig.json中启用了strict(就像我们在“tsconfig.json”中所做的那样),你就可以开始了。
未知
如果 any 是教父,那么 unknown 就像是基努·里维斯在《终极警探》中扮演的约翰尼·尤塔:悠闲自在,与坏人混在一起,但深藏在心底的尊重法律,站在好人一边。在你确实不知道其类型的值的少数情况下,不要使用 any,而是使用 unknown。像 any 一样,它表示任何值,但 TypeScript 不会让你使用 unknown 类型,直到你通过检查它的类型来细化它(见“细化”)。
unknown 支持哪些操作?你可以比较 unknown 值(使用 ==, ===, ||, &&, 和 ?),对它们进行否定(使用 !),并使用 JavaScript 的 typeof 和 instanceof 运算符来细化它们(就像你可以处理任何其他类型一样)。像这样使用 unknown:
let a: unknown = 30 // unknown
let b = a === 123 // boolean
let c = a + 10 // Error TS2571: Object is of type 'unknown'.
if (typeof a === 'number') {
let d = a + 10 // number
}
此示例应该让你大致了解如何使用 unknown:
-
TypeScript 永远不会推断出某些东西是
unknown——你必须显式注释它 (a)。^(1) -
你可以将值与
unknown类型的值进行比较 (b)。 -
但是,你不能做那些假设
unknown值是特定类型的事情 (c);你必须先向 TypeScript 证明该值确实是该类型 (d)。
boolean
boolean 类型有两个值:true 和 false。你可以比较它们(使用 ==, ===, ||, &&, 和 ?),对它们进行否定(使用 !),除此之外几乎不能做其他事情。像这样使用 boolean:
let a = true // boolean
var b = false // boolean
const c = true // true
let d: boolean = true // boolean
let e: true = true // true
let f: true = false // Error TS2322: Type 'false' is not assignable
// to type 'true'.
此示例展示了几种告诉 TypeScript 某物是 boolean 的方法:
-
你可以让 TypeScript 推断出你的值是一个
boolean(a和b)。 -
TypeScript 可以让你推断出你的值是特定的
boolean(c)。 -
你可以显式告诉 TypeScript,你的值是一个
boolean(d)。 -
你可以显式告诉 TypeScript,你的值是一个特定的
boolean(e和f)。
通常情况下,你将在你的程序中使用第一种或第二种方式。很少情况下,你会使用第四种方式——只有当它能带来额外的类型安全性时(本书中将为您展示示例)。你几乎不会使用第三种方式。
第二和第四种情况尤其有趣,因为虽然它们做了直觉上的事情,但是它们得到的支持语言很少,所以对你来说可能是新的。在那个示例中,我做的是说,“嘿 TypeScript!看看这个变量 e,e 不只是任何旧的 boolean ——它是具体的 boolean true。”通过使用值作为类型,我本质上将 e 和 f 的可能值从所有的 boolean 限制为每个具体的 boolean。这个功能被称为类型字面量。
在第四种情况下,我明确注释了我的变量类型字面量,在第二种情况下,TypeScript 为我推断了一个字面量类型,因为我使用了const而不是let或var。因为 TypeScript 知道一旦一个基本类型使用const赋值后其值不会改变,它会为该变量推断出最窄的类型。这就是为什么在第二种情况下 TypeScript 推断c的类型为true而不是boolean的原因。要了解更多关于 TypeScript 为何对let和const推断不同类型的详细信息,请参阅“类型扩展”。
我们将在本书中多次讨论类型字面量。它们是一种强大的语言特性,可以在各个地方提供额外的安全性。类型字面量使 TypeScript 在语言世界中独具一格,并且是你应该向你的 Java 朋友炫耀的东西。
number
number是所有数字的集合:整数、浮点数、正数、负数、Infinity、NaN等等。数字可以进行诸如加法(+)、减法(-)、取模(%)和比较(<)等操作。让我们看几个例子:
let a = 1234 // number
var b = Infinity * 0.10 // number
const c = 5678 // 5678
let d = a < b // boolean
let e: number = 100 // number
let f: 26.218 = 26.218 // 26.218
let g: 26.218 = 10 // Error TS2322: Type '10' is not assignable
// to type '26.218'.
就像在boolean的示例中一样,有四种方法将某物声明为number:
-
你可以让 TypeScript 推断你的值是一个
number(a和b)。 -
你可以使用
const,这样 TypeScript 就会推断你的值是一个特定的number(c)。 -
你可以明确告诉 TypeScript 你的值是一个
number(e)。 -
你可以明确告诉 TypeScript 你的值是一个特定的
number(f和g)。
就像处理booleans一样,通常你会让 TypeScript 自动推断类型(第一种方式)。偶尔你可能会做一些需要将数值类型限制为特定值的 clever programming(第二或第四种方式)。没有充分的理由明确将某物类型声明为number(第三种方式)。
提示
当处理长数字时,使用数字分隔符使这些数字更易于阅读。数字分隔符可用于类型和值的位置:
let oneMillion = 1_000_000 // Equivalent to 1000000
let twoMillion: 2_000_000 = 2_000_000
bigint
bigint是 JavaScript 和 TypeScript 中的新成员:它允许你处理大整数而不会遇到舍入错误。而number类型只能表示最多 2⁵³的整数,bigint可以表示更大的整数。bigint类型是所有 BigInts 的集合,并支持加法(+)、减法(-)、乘法(*)、除法(/)和比较(<)。使用方式如下:
let a = 1234n // bigint
const b = 5678n // 5678n
var c = a + b // bigint
let d = a < 1235 // boolean
let e = 88.5n // Error TS1353: A bigint literal must be an integer.
let f: bigint = 100n // bigint
let g: 100n = 100n // 100n
let h: bigint = 100 // Error TS2322: Type '100' is not assignable
// to type 'bigint'.
就像在boolean和number中一样,有四种方法声明 bigints。尽量在能够的时候让 TypeScript 推断出你的 bigint 类型。
警告
在撰写本文时,bigint尚未被每个 JavaScript 引擎原生支持。如果你的应用依赖于bigint,请注意检查目标平台是否支持。
string
string是所有字符串及其相关操作的集合,比如拼接(+)、切片(.slice)等等。让我们看几个例子:
let a = 'hello' // string
var b = 'billy' // string
const c = '!' // '!'
let d = a + ' ' + b + c // string
let e: string = 'zoom' // string
let f: 'john' = 'john' // 'john'
let g: 'john' = 'zoe' // Error TS2322: Type "zoe" is not assignable
// to type "john".
类似于 boolean 和 number,有四种声明 string 类型的方式,你应该尽可能地让 TypeScript 推断类型。
符号
symbol 是一个相对较新的语言特性,随着 JavaScript 最新的主要版本(ES2015)而引入。符号在实际中并不经常出现;它们被用作对象和映射中字符串键的替代,在你确信人们使用了正确的已知键而没有意外设置键的地方——比如为对象设置默认迭代器 (Symbol.iterator),或者在运行时重写对象是否是某个实例 (Symbol.hasInstance)。符号的类型是 symbol,并且你可以做的事情并不多:
let a = Symbol('a') // symbol
let b: symbol = Symbol('b') // symbol
var c = a === b // boolean
let d = a + 'x' // Error TS2469: The '+' operator cannot be applied
// to type 'symbol'.
JavaScript 中 Symbol('a') 的工作方式是创建一个具有给定名称的新 symbol;该 symbol 是唯一的,并且不会等于任何其他 symbol(即使你使用完全相同的名称创建第二个 symbol !)。类似于当你用 let 声明时 27 被推断为 number,但当你用 const 声明时它是具体的 27,符号被推断为 symbol 类型但可以显式地标注为 unique symbol:
const e = Symbol('e') // typeof e
const f: unique symbol = Symbol('f') // typeof f
let g: unique symbol = Symbol('f') // Error TS1332: A variable whose type is a
// 'unique symbol' type must be 'const'.
let h = e === e // boolean
let i = e === f // Error TS2367: This condition will always return
// 'false' since the types 'unique symbol' and
// 'unique symbol' have no overlap.
这个示例展示了创建唯一符号的几种方法:
-
当你声明一个新的
symbol并将其赋给一个const变量(不是let或var变量)时,TypeScript 会推断它的类型为unique symbol。它将在你的代码编辑器中显示为typeofyourVariableName,而不是unique symbol。 -
你可以显式地注释
const变量的类型为unique symbol。 -
一个
unique symbol总是等于它自身。 -
TypeScript 在编译时知道
unique symbol永远不会等于任何其他unique symbol。
将 unique symbol 视为其他文字类型(比如 1、true 或 "literal")。它们是一种创建代表特定 symbol 的类型的方式。
对象
TypeScript 的对象类型指定了对象的形状。值得注意的是,它们无法区分简单对象(比如你用 {} 创建的那种)和更复杂的对象(比如你用 new Blah 创建的那种)。这是有意设计的:JavaScript 通常是 结构化类型 的,因此 TypeScript 更偏向于这种编程风格,而不是 名义类型 的风格。
在 TypeScript 中,有几种方法可以用类型来描述对象。第一种方法是将一个值声明为 object:
let a: object = {
b: 'x'
}
当你访问 b 时会发生什么?
a.b // Error TS2339: Property 'b' does not exist on type 'object'.
等等,这并不是很有用!如果你不能对 object 做任何事情,那么给它加类型有什么意义呢?
为什么,这是一个很重要的观点,有抱负的 TypeScripter!实际上,object 比 any 稍微窄一些,但差别不大。object 并不能告诉你关于它描述的值的很多信息,只是告诉你这个值是一个 JavaScript 对象(并且它不是 null)。
如果我们省略显式的类型标注,让 TypeScript 自行处理会怎样?
let a = {
b: 'x'
} // {b: string}
a.b // string
let b = {
c: {
d: 'f'
}
} // {c: {d: string}}
太棒了!你刚刚发现了第二种对象类型的类型化方法:对象字面量语法(不要与类型文字混淆)。你可以让 TypeScript 自动推断对象的形状,也可以在花括号内显式描述它:
let a: {b: number} = {
b: 12
} // {b: number}
对象字面量语法表示:“这是一个具有这种形状的东西。” 这个东西可以是对象字面量,也可以是类:
let c: {
firstName: string
lastName: string
} = {
firstName: 'john',
lastName: 'barrowman'
}
class Person {
constructor(
public firstName: string, // public is shorthand for
// this.firstName = firstName
public lastName: string
) {}
}
c = new Person('matt', 'smith') // OK
{firstName: string, lastName: string} 描述了一个对象的 形状,上一个例子中的对象字面量和类实例都满足这个形状,所以 TypeScript 允许我们将 Person 赋给 c。
让我们看看当我们添加额外属性或略过必需的属性时会发生什么:
let a: {b: number}
a = {} // Error TS2741: Property 'b' is missing in type '{}'
// but required in type '{b: number}'.
a = {
b: 1,
c: 2 // Error TS2322: Type '{b: number; c: number}' is not assignable
} // to type '{b: number}'. Object literal may only specify known
// properties, and 'c' does not exist in type '{b: number}'.
默认情况下,TypeScript 对对象属性相当严格——如果你说对象应该有一个叫做 b 的 number 属性,TypeScript 期望只有 b 而不是其他。如果 b 缺失或有额外的属性,TypeScript 将会报错。
你能告诉 TypeScript 某物是可选的,或者可能会有比你计划的更多属性吗?当然可以:
let a: {
b: number 
c?: string 
[key: number]: boolean 
}
a 有一个叫做 b 的 number 属性。
a 可能有一个叫做 c 的 string 属性。如果设置了 c,它可能是 undefined。
a 可能有任意数量的数值属性,类型为 boolean。
让我们看看我们可以分配给 a 的对象类型:
a = {b: 1}
a = {b: 1, c: undefined}
a = {b: 1, c: 'd'}
a = {b: 1, 10: true}
a = {b: 1, 10: true, 20: false}
a = {10: true} // Error TS2741: Property 'b' is missing in type
// '{10: true}'.
a = {b: 1, 33: 'red'} // Error TS2741: Type 'string' is not assignable
// to type 'boolean'.
可选项 (?) 不是声明对象类型时唯一的修饰符。你还可以使用 readonly 修饰符来标记字段为只读(即声明字段在赋初值后不能修改,有点像对象属性的 const):
let user: {
readonly firstName: string
} = {
firstName: 'abby'
}
user.firstName // string
user.firstName =
'abbey with an e' // Error TS2540: Cannot assign to 'firstName' because it
// is a read-only property.
对象字面量表示法有一个特殊情况:空对象类型 ({})。除了 null 和 undefined 之外的所有类型都可以分配给空对象类型,这可能会使其使用起来有些棘手。尽量在可能的情况下避免使用空对象类型:
let danger: {}
danger = {}
danger = {x: 1}
danger = []
danger = 2
最后关于对象,值得一提的是将某物声明为对象的最后一种方式:Object。这几乎和使用 {} 是一样的,最好避免使用。^(3)
总结一下,在 TypeScript 中声明对象有四种方式:
-
对象字面量表示法(如
{a: string}),也称为 形状。当你知道你的对象可能有哪些字段,或者当你的对象所有值都具有相同类型时使用这个。 -
空对象字面量表示法 (
{})。尽量避免使用这个。 -
object类型。当你只需要一个对象而不关心它有哪些字段时使用。 -
Object类型。尽量避免使用这个。
在你的 TypeScript 程序中,你几乎总是应该使用第一种和第三种方式。要小心避免第二种和第四种方式——使用 linter 进行警告,进行代码审查时进行抱怨,打印海报——使用你团队首选的工具来使它们远离你的代码库。
表 3-1 是前述列表中选项 2–4 的一个便利参考。
表 3-1. 值是否为有效对象?
| Value | {} |
object |
Object |
|---|---|---|---|
{} |
是 | 是 | 是 |
['a'] |
是 | 是 | 是 |
function () {} |
是 | 是 | 是 |
new String('a') |
是 | 是 | 是 |
'a' |
是 | 不 | 是 |
1 |
是 | 不 | 是 |
Symbol('a') |
是 | 不 | 是 |
null |
不 | 不 | 不 |
undefined |
不 | 不 | 不 |
插曲:类型别名、联合和交集
你很快就会成为一个经验丰富的 TypeScript 程序员。你已经看过几种类型及其工作方式,并且现在熟悉类型系统、类型和安全性的概念。现在是我们深入的时候了。
如你所知,如果你有一个值,你可以根据其类型允许的操作对其进行操作。例如,你可以使用 + 来添加两个数字,或者 .toUpperCase 来将字符串转换为大写。
如果你有一个类型,你也可以对其执行一些操作。我将在这里介绍一些类型级操作——在书中稍后还会有更多,但这些是如此常见,我希望尽早介绍它们。
类型别名
就像你可以使用变量声明(let、const 和 var)来声明一个变量别名值一样,你可以声明一个指向类型的类型别名。它看起来像这样:
type Age = number
type Person = {
name: string
age: Age
}
Age 只是一个 number。它还有助于更容易理解 Person 结构的定义。别名在 TypeScript 中不会被推断,因此你必须明确地为它们设置类型:
let age: Age = 55
let driver: Person = {
name: 'James May'
age: age
}
因为 Age 只是 number 的别名,这意味着它也可以赋给 number,所以我们可以将其重写为:
let age = 55
let driver: Person = {
name: 'James May'
age: age
}
无论何处都可以使用类型别名,而不改变程序的含义。
就像 JavaScript 的变量声明(let、const 和 var)一样,你不能重复声明一个类型:
type Color = 'red'
type Color = 'blue' // Error TS2300: Duplicate identifier 'Color'.
而像 let 和 const 一样,类型别名是块级作用域的。每个块和每个函数都有自己的作用域,内部的类型别名声明会覆盖外部的声明:
type Color = 'red'
let x = Math.random() < .5
if (x) {
type Color = 'blue' // This shadows the Color declared above.
let b: Color = 'blue'
} else {
let c: Color = 'red'
}
类型别名对于简化重复的复杂类型(^(4))很有用,并且可以清楚地说明变量的用途(有些人更喜欢使用描述性的类型名称而不是描述性的变量名称!)。在决定是否给一个类型取别名时,可以使用与决定是否将值拉出到自己的变量中相同的判断标准。
联合和交集类型
如果你有两个事物 A 和 B,它们的并集是它们的总和(A 或 B 或两者都有的所有内容),而交集是它们共有的部分(两者都有的所有内容)。最简单的方式是用集合来理解这一点。在图 3-2 中,我将集合表示为圆圈。左边是两个集合的并集或和;右边是它们的交集或积。

图 3-2. 并集(|)和交集(&)
TypeScript 给我们提供了特殊的类型运算符来描述类型的并集和交集:| 表示并集,& 表示交集。由于类型很像集合,我们可以以同样的方式思考它们:
type Cat = {name: string, purrs: boolean}
type Dog = {name: string, barks: boolean, wags: boolean}
type CatOrDogOrBoth = Cat | Dog
type CatAndDog = Cat & Dog
如果某物是 CatOrDogOrBoth,你知道它有一个 name 属性是字符串,除此之外没有什么信息。另一方面,你可以给 CatOrDogOrBoth 分配什么呢?嗯,一个 Cat,一个 Dog,或者两者都有:
// Cat
let a: CatOrDogOrBoth = {
name: 'Bonkers',
purrs: true
}
// Dog
a = {
name: 'Domino',
barks: true,
wags: true
}
// Both
a = {
name: 'Donkers',
barks: true,
purrs: true,
wags: true
}
这值得再强调一下:一个具有联合类型 (|) 的值不一定是联合中的某一个特定成员;事实上,它可以同时是两个成员!^(5)
另一方面,关于 CatAndDog,你知道什么?你的狗猫混血超级宠物不仅有一个 name,还能咕噜、吠叫和摇尾巴:
let b: CatAndDog = {
name: 'Domino',
barks: true,
purrs: true,
wags: true
}
并集比交集更自然地出现得多。例如,看看这个函数:
function trueOrNull(isTrue: boolean) {
if (isTrue) {
return 'true'
}
return null
}
这个函数返回值的类型是什么?嗯,它可能是一个string,或者可能是null。我们可以将其返回类型表示为:
type Returns = string | null
这个呢?
function(a: string, b: number) {
return a || b
}
如果 a 是真的,那么返回类型是 string,否则就是 number:换句话说,string | number。
最后一个自然出现并集的地方是数组(特别是异构数组),我们接下来会讨论它。
数组
就像在 JavaScript 中一样,TypeScript 中的数组是一种特殊的对象,支持连接、推入、搜索和切片等操作。举个例子:
let a = [1, 2, 3] // number[]
var b = ['a', 'b'] // string[]
let c: string[] = ['a'] // string[]
let d = [1, 'a'] // (string | number)[]
const e = [2, 'b'] // (string | number)[]
let f = ['red']
f.push('blue')
f.push(true) // Error TS2345: Argument of type 'true' is not
// assignable to parameter of type 'string'.
let g = [] // any[]
g.push(1) // number[]
g.push('red') // (string | number)[]
let h: number[] = [] // number[]
h.push(1) // number[]
h.push('red') // Error TS2345: Argument of type '"red"' is not
// assignable to parameter of type 'number'.
注意
TypeScript 支持两种数组的语法:T[] 和 Array<T>。它们在含义和性能上都是相同的。本书使用 T[] 语法是为了简洁性,但你可以选择自己喜欢的风格。
当你阅读这些例子时,注意除了 c 和 h 之外的一切都是隐式类型化的。你还会注意到 TypeScript 有关于你可以放入数组中的内容的规则。
一个常见的经验法则是保持数组同质化。也就是说,不要在一个数组中混合苹果、橙子和数字 —— 设计你的程序,让数组的每个元素都具有相同的类型。原因是,否则,你将不得不做更多工作来向 TypeScript 证明你的操作是安全的。
要理解当数组同质化时为什么事情变得更容易,看看例子 f。我用字符串 'red' 初始化了一个数组(在我声明数组时,它只包含字符串,因此 TypeScript 推断它必须是一个字符串数组)。然后我推入了 'blue';'blue' 是一个字符串,所以 TypeScript 让它通过了。接着我尝试将 true 推入数组,但失败了!为什么呢?因为 f 是一个字符串数组,而 true 不是一个字符串。
另一方面,当我初始化d时,我给它一个number和一个string,因此 TypeScript 推断它必须是一个类型为number | string的数组。因为每个元素可以是数字或字符串,所以在使用之前必须检查它是哪种类型。例如,假设你想要映射该数组,将每个字母转换为大写并将每个数字乘以三:
let d = [1, 'a']
d.map(_ => {
if (typeof _ === 'number') {
return _ * 3
}
return _.toUpperCase()
})
在能对它做任何操作之前,你必须用typeof查询每个项的类型,检查它是number还是string。
就像对象一样,使用const创建的数组不会提示 TypeScript 更精确地推断它们的类型。这就是为什么 TypeScript 推断d和e都是number | string数组的原因。
g是特殊情况:当你初始化一个空数组时,TypeScript 不知道数组元素的类型,因此会默认将它们设置为any。随着你操作数组并向其中添加元素,TypeScript 开始推断数组的类型。一旦数组离开了它定义的作用域(例如,如果你在函数中声明它,然后返回它),TypeScript 将为其分配一个不能再扩展的最终类型:
function buildArray() {
let a = [] // any[]
a.push(1) // number[]
a.push('x') // (string | number)[]
return a
}
let myArray = buildArray() // (string | number)[]
myArray.push(true) // Error 2345: Argument of type 'true' is not
// assignable to parameter of type 'string | number'.
因此,在使用any的用途中,这个不应该让你过于担心。
元组
元组是array的子类型。它们是一种特殊的数组类型,具有固定的长度,每个索引处的值具有特定的已知类型。与大多数其他类型不同,声明元组时必须显式指定它们的类型。这是因为 JavaScript 的语法对元组和数组是相同的(都使用方括号),并且 TypeScript 已经有规则从方括号中推断数组类型:
let a: [number] = [1]
// A tuple of [first name, last name, birth year]
let b: [string, string, number] = ['malcolm', 'gladwell', 1963]
b = ['queen', 'elizabeth', 'ii', 1926] // Error TS2322: Type 'string' is not
// assignable to type 'number'.
元组也支持可选元素。就像对象类型一样,?表示“可选”:
// An array of train fares, which sometimes vary depending on direction
let trainFares: [number, number?][] = [
[3.75],
[8.25, 7.70],
[10.50]
]
// Equivalently:
let moreTrainFares: ([number] | [number, number])[] = [
// ...
]
元组还支持剩余元素,你可以使用它们来对具有最小长度的元组进行类型标记:
// A list of strings with at least 1 element
let friends: [string, ...string[]] = ['Sara', 'Tali', 'Chloe', 'Claire']
// A heterogeneous list
let list: [number, boolean, ...string[]] = [1, false, 'a', 'b', 'c']
不仅可以安全地编码异构列表,元组类型还捕获了列表的长度。这些特性比普通的旧数组提供了更多的安全性——经常使用它们。
只读数组和元组
虽然常规数组是可变的(意味着你可以.push,.splice它们,并在原地更新它们),这可能是你大多数时间想要的,但有时你需要一个不可变数组——一个你可以更新以生成新数组,而不更改原始数组的数组。
TypeScript 默认支持readonly数组类型,你可以使用它来创建不可变数组。只读数组就像普通数组一样,但你不能直接更新它们。要创建只读数组,使用显式类型注解;要更新只读数组,使用非变异方法如.concat和.slice,而不是.push和.splice等变异方法:
let as: readonly number[] = [1, 2, 3] // readonly number[]
let bs: readonly number[] = as.concat(4) // readonly number[]
let three = bs[2] // number
as[4] = 5 // Error TS2542: Index signature in type
// 'readonly number[]' only permits reading.
as.push(6) // Error TS2339: Property 'push' does not
// exist on type 'readonly number[]'.
就像Array一样,TypeScript 提供了几种更长的方式来声明只读数组和元组:
type A = readonly string[] // readonly string[]
type B = ReadonlyArray<string> // readonly string[]
type C = Readonly<string[]> // readonly string[]
type D = readonly [number, string] // readonly [number, string]
type E = Readonly<[number, string]> // readonly [number, string]
你使用的语法是更简洁的readonly修饰符,还是更长的形式Readonly或ReadonlyArray实用程序,这取决于个人口味。
需要注意的是,尽管只读数组在某些情况下可以通过避免可变性来使代码更易于理解,但它们实际上是由常规 JavaScript 数组支持的。 这意味着即使对数组进行小更新,也需要先复制原始数组,如果不小心的话,这可能会损害应用程序的运行性能。 对于小型数组,这种开销很少引人注意,但对于较大的数组,开销可能会变得显著。
Tip
如果您计划大量使用不可变数组,请考虑使用效率更高的实现,比如 Lee Byron 的优秀 immutable。
null、undefined、void 和 never
JavaScript 有两个值用于表示缺失的情况:null 和 undefined。 TypeScript 支持这两者作为值,并为它们提供类型定义——猜猜叫什么?没错,它们的类型也叫 null 和 undefined。
在 TypeScript 中,undefined 的唯一值是 undefined,null 的唯一值是 null,因此它们都是特殊类型。
JavaScript 程序员通常可以互换使用这两个,尽管其中有一个微妙的语义差异值得一提:undefined 意味着尚未定义某事物,而 null 意味着值的缺失(例如,如果您试图计算一个值,但途中遇到错误)。 这些只是约定,TypeScript 不会强制您遵循它们,但区分它们可能是有用的。
除了 null 和 undefined,TypeScript 还有 void 和 never。 这些是非常特定的、专用的类型,更细致地划分了不同类型不存在的情况:void 是没有显式返回任何内容的函数的返回类型(例如 console.log),而 never 是根本不会返回的函数的类型(例如抛出异常的函数或永远运行的函数):
// (a) A function that returns a number or null
function a(x: number) {
if (x < 10) {
return x
}
return null
}
// (b) A function that returns undefined
function b() {
return undefined
}
// (c) A function that returns void
function c() {
let a = 2 + 2
let b = a * a
}
// (d) A function that returns never
function d() {
throw TypeError('I always error')
}
// (e) Another function that returns never
function e() {
while (true) {
doSomething()
}
}
(a)和(b)显式返回 null 和 undefined。 (c)返回 undefined,但没有显式使用 return 语句,因此我们称其返回类型为 void。 (d)抛出异常,(e)永远运行不会返回,因此我们称它们的返回类型为 never。
如果 unknown 是每种其他类型的超类型,那么 never 是每种其他类型的子类型。 我们称之为 底部类型。 这意味着它可以分配给任何其他类型,并且类型为 never 的值可以安全地在任何地方使用。 这在理论上具有重要意义,^(6) 但当您与其他语言爱好者讨论 TypeScript 时,这是一个会出现的话题。
表 3-2 总结了这四种缺失类型的使用方式。
表 3-2. 表示某种缺失的类型
| Type | 含义 |
|---|---|
null |
缺少值 |
undefined |
尚未分配值的变量 |
void |
没有 return 语句的函数 |
never |
永不返回的函数 |
枚举
枚举是一种用于列举类型可能值的方式。它们是无序的数据结构,将键映射到值。可以把它们想象成在编译时键是固定的对象,因此当你访问它时 TypeScript 可以检查给定的键是否实际存在。
有两种枚举类型:将字符串映射到字符串的枚举,以及将字符串映射到数字的枚举。它们看起来像这样:
enum Language {
English,
Spanish,
Russian
}
注意
按照约定,枚举名称为大写单数形式。它们的键也是大写的。
TypeScript 将自动推断出枚举的每个成员的数字值,但你也可以显式设置值。让我们明确 TypeScript 在上一个示例中推断出的内容:
enum Language {
English = 0,
Spanish = 1,
Russian = 2
}
要从枚举中检索值,你可以像从常规对象获取值一样,使用点号或方括号表示法:
let myFirstLanguage = Language.Russian // Language
let mySecondLanguage = Language['English'] // Language
你可以将枚举分割成多个声明,TypeScript 将自动合并它们(要了解更多,请跳到 “声明合并”)。请注意,当你分割枚举时,TypeScript 只能为其中一个声明推断值,因此最好为每个枚举成员显式分配一个值:
enum Language {
English = 0,
Spanish = 1
}
enum Language {
Russian = 2
}
你可以使用计算值,并且不必定义所有的值(TypeScript 将尽力推断缺失的部分):
enum Language {
English = 100,
Spanish = 200 + 300,
Russian // TypeScript infers 501 (the next number after 500)
}
你也可以使用字符串值来定义枚举,甚至可以混合使用字符串和数字值:
enum Color {
Red = '#c10000',
Blue = '#007ac1',
Pink = 0xc10050, // A hexadecimal literal
White = 255 // A decimal literal
}
let red = Color.Red // Color
let pink = Color.Pink // Color
TypeScript 允许你通过值和键来访问枚举,以方便使用,但这可能很快变得不安全:
let a = Color.Red // Color
let b = Color.Green // Error TS2339: Property 'Green' does not exist
// on type 'typeof Color'.
let c = Color[0] // string
let d = Color[6] // string (!!!)
你不应该能够获取 Color[6],但 TypeScript 并不会阻止你!我们可以要求 TypeScript 通过选择更安全的 const enum 子集来防止这种不安全访问。让我们重新编写先前的 Language 枚举:
const enum Language {
English,
Spanish,
Russian
}
// Accessing a valid enum key
let a = Language.English // Language
// Accessing an invalid enum key
let b = Language.Tagalog // Error TS2339: Property 'Tagalog' does not exist
// on type 'typeof Language'.
// Accessing a valid enum value
let c = Language[0] // Error TS2476: A const enum member can only be
// accessed using a string literal.
// Accessing an invalid enum value
let d = Language[6] // Error TS2476: A const enum member can only be
// accessed using a string literal.
const enum 不允许你进行反向查找,因此其行为类似于常规的 JavaScript 对象。它还不会默认生成任何 JavaScript 代码,而是在使用时内联枚举成员的值(例如,TypeScript 将会用其值 1 替换每个 Language.Spanish 的出现)。
TSC 标志:preserveConstEnums
当从别人的 TypeScript 代码中导入 const enum 时,const enum 的内联可能会导致安全问题:如果枚举的作者在你编译 TypeScript 代码后更新了其 const enum,那么你的枚举版本和他们的枚举版本在运行时可能指向不同的值,而 TypeScript 并不会察觉到这一点。
如果使用 const enum,请注意避免内联它们,并且只在你控制的 TypeScript 程序中使用它们:避免在计划发布到 NPM 或作为库供他人使用的程序中使用它们。
要在 tsconfig.json 中打开 preserveConstEnums TSC 设置以启用 const enum 的运行时代码生成:
{
"compilerOptions": {
"preserveConstEnums": true
}
}
让我们看看如何使用 const enum:
const enum Flippable {
Burger,
Chair,
Cup,
Skateboard,
Table
}
function flip(f: Flippable) {
return 'flipped it'
}
flip(Flippable.Chair) // 'flipped it'
flip(Flippable.Cup) // 'flipped it'
flip(12) // 'flipped it' (!!!)
一切看起来都很好——椅子和杯子的工作正如你所预期的那样…… 直到你意识到所有数字也可以赋值给枚举!这种行为是 TypeScript 可赋值性规则的一个不幸后果,为了修复它,你必须特别小心,只使用字符串值的枚举:
const enum Flippable {
Burger = 'Burger',
Chair = 'Chair',
Cup = 'Cup',
Skateboard = 'Skateboard',
Table = 'Table'
}
function flip(f: Flippable) {
return 'flipped it'
}
flip(Flippable.Chair) // 'flipped it'
flip(Flippable.Cup) // 'flipped it'
flip(12) // Error TS2345: Argument of type '12' is not
// assignable to parameter of type 'Flippable'.
flip('Hat') // Error TS2345: Argument of type '"Hat"' is not
// assignable to parameter of type 'Flippable'.
只需一个讨厌的数值枚举,整个枚举就不安全了。
警告
因为安全使用枚举时存在的所有陷阱,我建议您远离它们——在 TypeScript 中有很多更好的表达方式。
如果你的同事坚持使用枚举,并且你无法改变他们的想法,确保在他们离开时,秘密合并几个 TSLint 规则,以警告数值和非const枚举。
摘要
简而言之,TypeScript 自带一堆内置类型。您可以让 TypeScript 根据值推断类型,或者可以显式地为您的值指定类型。const 会推断更具体的类型,let 和 var 推断更一般的类型。大多数类型都有一般和更具体的对应类型,后者是前者的子类型(请参见 表 3-3)。
表 3-3. 类型及其更具体的子类型
| 类型 | 子类型 |
|---|---|
boolean |
布尔字面量 |
bigint |
BigInt 字面量 |
number |
数字字面量 |
string |
字符串字面量 |
symbol |
unique symbol |
object |
对象字面量 |
| 数组 | 元组 |
enum |
const enum |
练习
-
对于这些值中的每一个,TypeScript 会推断出什么类型?
-
let a = 1042 -
let b = 'apples and oranges' -
const c = 'pineapples' -
let d = [true, true, false] -
let e = {type: 'ficus'} -
let f = [1, false] -
const g = [3] -
let h = null(在您的代码编辑器中尝试一下,然后跳到 “类型扩展” 如果结果让您惊讶!)
-
-
为什么每个值都会引发它所引发的错误?
-
let i: 3 = 3 i = 4 // Error TS2322: Type '4' is not assignable to type '3'. -
let j = [1, 2, 3] j.push(4) j.push('5') // Error TS2345: Argument of type '"5"' is not // assignable to parameter of type 'number'. -
let k: never = 4 // Error TSTS2322: Type '4' is not assignable // to type 'never'. -
let l: unknown = 4 let m = l * 2 // Error TS2571: Object is of type 'unknown'.
-
^(1) 差不多了。当 unknown 是联合类型的一部分时,联合的结果将是 unknown。您将在 “联合和交叉类型” 中进一步了解联合类型。
^(2) JavaScript 中的对象使用字符串作为键;数组是一种特殊的对象,它使用数值键。
^(3) 有一个小的技术差异:{}允许您为 Object 原型上的内置方法(如 .toString 和 .hasOwnProperty)定义任何类型(请访问MDN了解更多关于原型的信息),而 Object 强制声明的类型必须可赋值给 Object 的原型上的类型。例如,这段代码类型检查通过:let a: {} = {toString() { return 3 }}。但如果您将类型注释更改为 Object,TypeScript 将抱怨:let b: Object = {toString() { return 3 }} 会导致 Error TS2322: Type 'number' is not assignable to type 'string'。
^(4) 缩写 DRY 意为“不要重复自己”,即代码不应重复。这一概念由安德鲁·亨特和大卫·托马斯在他们的书籍《程序员修炼之道:通向务实的最高境界》(Addison-Wesley)中首次提出。
^(5) 立即转到“标记联合类型”,了解如何提示 TypeScript 您的联合类型是不交叉的,该联合类型的值必须是其中之一,而不是两者兼有。
^(6) 关于底部类型的思考方式是它是一种没有值的类型。底部类型对应于数学命题,总是为假。
第四章:函数
在上一章中,我们已经讨论了 TypeScript 类型系统的基础知识:原始类型、对象、数组、元组和枚举,以及 TypeScript 类型推断的基础知识以及类型可赋值性是如何工作的。现在你已经准备好学习 TypeScript 的“杰作”(或者说是存在的理由,如果你是一个函数式编程者的话):函数。本章我们将讨论以下几个主题:
-
TypeScript 中声明和调用函数的不同方法
-
签名重载
-
多态函数
-
多态类型别名
声明和调用函数
在 JavaScript 中,函数是一等对象。这意味着你可以像对待任何其他对象一样使用它们:将它们分配给变量,传递它们给其他函数,从函数中返回它们,将它们分配给对象和原型,写入它们的属性,读取这些属性,等等。在 JavaScript 中,你可以对函数做很多事情,而 TypeScript 则通过其丰富的类型系统模拟了所有这些功能。
TypeScript 中函数的样子如下(这在上一章应该看起来很熟悉):
function add(a: number, b: number) {
return a + b
}
通常你会显式注解函数的参数(比如这个例子中的 a 和 b)— TypeScript 总是会在函数体内推断类型,但在大多数情况下它不会从参数中推断类型,除了一些特殊情况,它可以从上下文推断类型(更多关于“上下文类型推断”见 “上下文类型推断”)。返回类型 会 被推断,但如果你愿意,你也可以显式注解它:
function add(a: number, b: number): number {
return a + b
}
注意
在本书中,当它有助于你理解函数的功能时,我会显式注释返回类型。否则我会略去注释,因为 TypeScript 已经为我们推断了类型,我们为什么要重复做这项工作呢?
上一个例子使用了 命名函数语法 来声明函数,但是 JavaScript 和 TypeScript 支持至少五种方法来做到这一点:
// Named function
function greet(name: string) {
return 'hello ' + name
}
// Function expression
let greet2 = function(name: string) {
return 'hello ' + name
}
// Arrow function expression
let greet3 = (name: string) => {
return 'hello ' + name
}
// Shorthand arrow function expression
let greet4 = (name: string) =>
'hello ' + name
// Function constructor
let greet5 = new Function('name', 'return "hello " + name')
除了函数构造函数(你不应该使用它们,除非你被蜜蜂追赶,因为它们完全不安全)^(1),所有这些语法都得到了 TypeScript 安全类型的支持,并且它们都遵循通常参数需要强制类型注解和返回类型可选注解的相同规则。
注意
术语快速复习:
-
参数是函数运行所需的数据片段,作为函数声明的一部分声明。也称为 形式参数。
-
参数是你在调用函数时传递给它的数据片段。也称为 实际参数。
当你在 TypeScript 中调用函数时,你不需要提供任何额外的类型信息—只需传递一些参数,TypeScript 将开始检查你的参数是否与函数参数的类型兼容:
add(1, 2) // evaluates to 3
greet('Crystal') // evaluates to 'hello Crystal'
当然,如果你忘记了一个参数,或者传递了一个错误类型的参数,TypeScript 会迅速指出:
add(1) // Error TS2554: Expected 2 arguments, but got 1.
add(1, 'a') // Error TS2345: Argument of type '"a"' is not assignable
// to parameter of type 'number'.
可选和默认参数
就像对象和元组类型一样,你可以使用 ? 标记参数为可选的。在声明函数的参数时,必需参数必须首先出现,然后是可选参数:
function log(message: string, userId?: string) {
let time = new Date().toLocaleTimeString()
console.log(time, message, userId || 'Not signed in')
}
log('Page loaded') // Logs "12:38:31 PM Page loaded Not signed in"
log('User signed in', 'da763be') // Logs "12:38:31 PM User signed in da763be"
就像在 JavaScript 中一样,你可以为可选参数提供默认值。从语义上讲,这类似于使参数变为可选,即调用者不再需要传递它(不同之处在于默认参数不必位于参数列表的末尾,而可选参数必须)。
例如,我们可以将 log 重写为:
function log(message: string, userId `=` `'Not signed in'`) {
let time = new Date().toISOString()
console.log(time, message, userId)
}
log('User clicked on a button', 'da763be')
log('User signed out')
注意当我们为 userId 设置默认值时,我们去掉了可选注释 ?。我们也不需要再为它指定类型了。TypeScript 足够聪明,能够从其默认值推断参数的类型,使得我们的代码简洁易读。
当然,你也可以为默认参数添加显式类型注解,方式与无默认值的参数相同:
`type` `Context` `=` `{`
`appId?``:` `string`
`userId?``:` `string`
`}`
function log(message: string, `context``:` `Context` `=` `{``}`) {
let time = new Date().toISOString()
console.log(time, message, `context``.`userId)
}
你会经常发现自己使用默认参数而不是可选参数。
Rest 参数
如果一个函数接受一个参数列表,你当然可以将列表作为一个数组直接传入:
function sum(numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0)
}
sum([1, 2, 3]) // evaluates to 6
有时,你可能会选择使用 可变参数 函数 API —— 即接受可变数量参数的 API —— 而不是 固定数量 参数 API。传统上,这需要使用 JavaScript 的魔法 arguments 对象。
arguments 是“魔法”,因为你的 JavaScript 运行时自动为函数定义它,并将你传递给函数的参数列表分配给它。因为 arguments 只是类似数组而不是真正的数组,所以在调用内置的 .reduce 方法之前,你首先必须将其转换为数组:
function sumVariadic(): number {
return Array
.from(arguments)
.reduce((total, n) => total + n, 0)
}
sumVariadic(1, 2, 3) // evaluates to 6
但是使用 arguments 有一个大问题:它完全不安全!如果你在文本编辑器中悬停在 total 或 n 上,你将看到类似于在 Figure 4-1 中显示的输出。

图 4-1. arguments 是不安全的
这意味着 TypeScript 推断 n 和 total 都是类型 any,并且默默地让它通过——也就是说,直到你尝试使用 sumVariadic 时:
sumVariadic(1, 2, 3) // Error TS2554: Expected 0 arguments, but got 3.
因为我们没有声明 sumVariadic 接受参数,从 TypeScript 的角度来看它不接受任何参数,所以当我们尝试使用它时会得到 TypeError。
那么,我们如何安全地为可变参数函数设置类型?
Rest 参数来拯救!我们可以使用 Rest 参数安全地使我们的 sum 函数接受任意数量的参数,而不是借助不安全的 arguments 魔法变量:
function sumVariadicSafe(...numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0)
}
sumVariadicSafe(1, 2, 3) // evaluates to 6
就是这样!注意这个可变参数 sum 和我们原来的单参数 sum 函数之间唯一的变化是参数列表中额外的 ... —— 其他都不需要改变,完全类型安全。
函数最多可以有一个剩余参数,并且该参数必须是函数参数列表中的最后一个。例如,看看 TypeScript 内置声明的 console.log(如果不知道 interface 是什么,不用担心——我们将在 第五章 中介绍它)。console.log 接受一个可选的 message 和任意数量的其他参数来记录:
interface Console {
log(message?: any, ...optionalParams: any[]): void
}
调用、应用和绑定
除了使用圆括号 () 调用函数外,JavaScript 还支持至少两种其他调用函数的方式。看看本章前面的 add:
function add(a: number, b: number): number {
return a + b
}
add(10, 20) // evaluates to 30
add.apply(null, [10, 20]) // evaluates to 30
add.call(null, 10, 20) // evaluates to 30
add.bind(null, 10, 20)() // evaluates to 30
apply 将一个值绑定到函数内部的 this(在本例中,我们将 this 绑定到 null),并将其第二个参数展开到函数的参数上。call 执行类似操作,但按顺序应用其参数,而不是展开参数。
bind() 类似,它会将 this 参数和一系列参数绑定到函数上。不同之处在于,bind 不会立即调用函数;相反,它返回一个新的函数,然后您可以使用 ()、.call 或 .apply 调用它,并传入更多参数来绑定到迄今未绑定的参数。
TSC 标志:strictBindCallApply
要在您的代码中安全使用 .call、.apply 和 .bind,请确保在您的 tsconfig.json 中启用 strictBindCallApply 选项(如果您已启用 strict 模式,则会自动启用)。
类型化这个
如果你不是从 JavaScript 过来的,你可能会惊讶地了解,在 JavaScript 中,this 变量对每个函数都有定义,而不仅仅是作为类方法存在的函数。this 的值取决于如何调用函数,这使得它异常脆弱且难以理解。
提示
出于这个原因,许多团队禁止在代码中无处不在地使用 this,除了在类方法中。若要在您的代码库中执行此操作,请启用 no-invalid-this TSLint 规则。
this 脆弱的原因与其赋值方式有关。通常规则是,在调用方法时,this 将取左侧点号的值。例如:
let x = {
a() {
return this
}
}
x.a() // this is the object x in the body of a()
但如果在调用之前重新分配 a,结果将会改变!
let a = x.a
a() // now, this is undefined in the body of a()
假设你有一个用于格式化日期的实用函数,其代码如下:
function fancyDate() {
return ${this.getDate()}/${this.getMonth()}/${this.getFullYear()}
}
你在初学者时期设计了这个 API(在了解函数参数之前)。要使用 fancyDate,您必须将一个 Date 绑定到 this 上:
fancyDate.call(new Date) // evaluates to "4/14/2005"
如果忘记将 Date 绑定到 this,将会导致运行时异常!
fancyDate() // Uncaught TypeError: this.getDate is not a function
虽然探索 this 的所有语义超出了本书的范围^(2),但 this 的行为——取决于调用函数的方式,而不是声明函数的方式——可能会令人意外。
幸运的是,TypeScript 为您排忧解难。如果您的函数使用 this,请确保在任何其他参数之前(在任何额外的参数之前),将您期望的 this 类型声明为函数的第一个参数,并且 TypeScript 将在每个调用点强制执行 this 确实是您说的那样。this 不像其他参数一样对待——在函数签名中使用时它是一个保留字:
function fancyDate(`this``:` `Date`) {
return ${this.getDate()}/${this.getMonth()}/${this.getFullYear()}
}
现在我们来看一下调用 fancyDate 时会发生什么:
fancyDate.call(new Date) // evaluates to "6/13/2008"
fancyDate() // Error TS2684: The 'this' context of type 'void' is
// not assignable to method's 'this' of type 'Date'.
我们遇到了一个运行时错误,并向 TypeScript 提供了足够的信息,使其能够在编译时警告该错误。
TSC 标志:noImplicitThis
要强制在函数中始终明确注解 this 类型,请在您的 tsconfig.json 中启用 noImplicitThis 设置。strict 模式包含 noImplicitThis,因此如果您已经启用了它,那就没问题了。
请注意,noImplicitThis 不会强制要求类或对象上的函数使用 this 注解。
生成器函数
生成器函数(简称 生成器)是一种方便的方式来,嗯,生成 一堆值。它们让生成器的消费者可以精确控制生成值的速度。因为它们是惰性的——也就是说,只有在消费者要求时才计算下一个值——它们可以做一些其他方式难以实现的事情,比如生成无限列表。
它们的工作方式如下:
function* createFibonacciGenerator() { 
let a = 0
let b = 1
while (true) { 
yield a; 
[a, b] = [b, a + b] 
}
}
let fibonacciGenerator = createFibonacciGenerator() // IterableIterator<number> fibonacciGenerator.next() // evaluates to {value: 0, done: false} fibonacciGenerator.next() // evaluates to {value: 1, done: false} fibonacciGenerator.next() // evaluates to {value: 1, done: false} fibonacciGenerator.next() // evaluates to {value: 2, done: false} fibonacciGenerator.next() // evaluates to {value: 3, done: false} fibonacciGenerator.next() // evaluates to {value: 5, done: false}
在函数名前的星号(*)将该函数定义为生成器。调用生成器会返回一个可迭代的迭代器。
我们的生成器可以无限生成值。
生成器使用 yield 关键字来,嗯,产出 值。当消费者请求生成器的下一个值时(例如通过调用 next),yield 将结果发送回给消费者,并暂停执行,直到消费者请求下一个值。通过这种方式,while(true) 循环不会立即导致程序无限运行并崩溃。
要计算下一个斐波那契数,我们将 a 重新赋值为 b,并将 b 重新赋值为 a + b,这一切都在一步完成。
我们调用了 createFibonacciGenerator,它返回了一个 IterableIterator。每次调用 next 方法时,迭代器计算下一个斐波那契数并将其 yield 回给我们。注意 TypeScript 如何能够根据 yield 的值推断出迭代器的类型。
您还可以显式注释生成器,将其产出的类型包装在 IterableIterator 中:
function* createNumbers(): IterableIterator<number> {
let n = 0
while (1) {
yield n++
}
}
let numbers = createNumbers()
numbers.next() // evaluates to {value: 0, done: false}
numbers.next() // evaluates to {value: 1, done: false}
numbers.next() // evaluates to {value: 2, done: false}
在本书中我们不会深入讨论生成器——它们是一个大的主题,而本书关注 TypeScript,我不想被 JavaScript 的特性分心。简而言之,它们是 JavaScript 的一个非常酷的语言特性,TypeScript 也支持它们。要了解更多关于生成器的信息,请访问 MDN。
迭代器
迭代器是生成器的反面:生成器是产生一系列值的方法,而迭代器是消费这些值的方法。术语可以变得相当混乱,因此让我们从一些定义开始。
当您创建一个生成器(比如通过调用createFibonacciGenerator),您会得到一个既是可迭代又是迭代器的值——一个可迭代迭代器,因为它同时定义了Symbol.iterator属性和next方法。
您可以通过创建一个实现Symbol.iterator或next的对象(或类)来手动定义迭代器或可迭代对象。例如,让我们定义一个返回 1 到 10 的数字的迭代器:
let numbers = {
*[Symbol.iterator]() {
for (let n = 1; n <= 10; n++) {
yield n
}
}
}
如果您将该迭代器键入到您的代码编辑器中并悬停在其上,您将看到 TypeScript 推断其类型(见图 4-2)。

图 4-2. 手动定义一个迭代器
换句话说,numbers是一个迭代器,调用生成器函数numbers[Symbol.iterator]()会返回一个可迭代的迭代器。
不仅可以定义自己的迭代器,还可以使用 JavaScript 内置的迭代器来处理常见的集合类型——Array、Map、Set、String,^(3) 等等,以执行如下操作:
// Iterate over an iterator with for-of
for (let a of numbers) {
// 1, 2, 3, etc.
}
// Spread an iterator
let allNumbers = [...numbers] // number[]
// Destructure an iterator
let [one, two, ...rest] = numbers // [number, number, number[]]
再次强调,本书不会深入讲解迭代器。你可以在MDN上了解更多关于迭代器和异步迭代器的信息。
TSC 标志:downlevelIteration
如果您将 TypeScript 编译到早于ES2015的 JavaScript 版本中,您可以在tsconfig.json中使用downlevelIteration标志启用自定义迭代器。
如果您的应用程序对捆绑大小特别敏感,您可能希望保持downlevelIteration禁用:在旧环境中使自定义迭代器工作需要大量代码。例如,前面的numbers示例生成了将近 1 KB 的代码(经过 gzip 压缩)。
调用签名
到目前为止,我们已经学会了为函数的参数和返回类型进行类型化。现在,让我们转换方向,讨论如何表达函数本身的完整类型。
让我们回顾一下本章开头提到的sum。作为提醒,它看起来像这样:
function sum(a: number, b: number): number {
return a + b
}
sum的类型是什么?嗯,sum是一个函数,所以它的类型是:
Function
正如你可能已经猜到的那样,Function类型并不是您大多数时候想要使用的类型。就像object描述了所有对象一样,Function是一个适用于所有函数的通用类型,并且不能告诉您有关其具体类型的任何信息。
我们还可以如何为sum定义类型?sum是一个接受两个number并返回一个number的函数。在 TypeScript 中,我们可以表示它的类型为:
(a: number, b: number) => number
这是 TypeScript 用于函数类型的语法,或者调用签名(也称为类型签名)。您会注意到它看起来非常类似于箭头函数——这是有意为之!当您将函数作为参数传递,或者从其他函数返回它们时,您将使用这种语法来为它们类型化。
注意
参数名a和b仅作为文档说明,不影响具有该类型的函数的可赋值性。
函数调用签名只包含类型级别代码,即只有类型,没有值。这意味着函数调用签名可以表达参数类型、this类型(参见“Typing this”)、返回类型、剩余类型和可选类型,但它们不能表达默认值(因为默认值是一个值,不是一个类型)。并且因为它们没有函数体供 TypeScript 推断,调用签名需要显式的返回类型注释。
让我们来看看本章中我们已经见过的一些函数示例,并将它们的类型提取为独立的调用签名,然后将它们绑定到类型别名:
// function greet(name: string)
type Greet = (name: string) => string
// function log(message: string, userId?: string)
type Log = (message: string, userId?: string) => void
// function sumVariadicSafe(...numbers: number[]): number
type SumVariadicSafe = (...numbers: number[]) => number
搞清楚了吗?函数的调用签名看起来与它们的实现非常相似。这是有意为之的,是一种语言设计选择,使调用签名更易于理解。
让我们使调用签名与它们的实现关系更加具体化。如果你有一个调用签名,你如何声明一个实现该签名的函数?你只需将调用签名与实现它的函数表达式组合起来。例如,让我们重写Log以使用它的全新签名:
type Log = (message: string, userId?: string) => void
let log: Log = ( 
message, 
userId = 'Not signed in' 
) => { 
let time = new Date().toISOString()
console.log(time, message, userId)
}
我们声明了一个函数表达式log,并明确将其类型为Log类型。
我们不需要两次注释我们的参数。因为message已经作为Log定义的一部分注释为string,我们不需要在这里再次注释它。相反,我们让 TypeScript 从Log中推断它。
我们为userId添加了一个默认值,因为我们在Log的签名中捕获了userId的类型,但是我们无法将默认值作为Log的一部分捕获,因为Log是一种类型,不能包含值。
我们不需要再次注释我们的返回类型,因为我们已经在Log类型中声明了它为void。
上下文类型推断
注意,最后一个示例是我们看到的第一个示例,我们不需要显式注释我们函数参数类型。因为我们已经声明了log的类型为Log,TypeScript 能够从上下文推断出message必须是string类型。这是 TypeScript 类型推断的一个强大特性,称为上下文类型推断。
在本章的早些时候,我们提到了另一个上下文类型推断出现的地方:回调函数。^(5)
让我们声明一个函数times,它调用其回调函数f多次n次,每次将当前索引传递给f:
function times(
f: (index: number) => void,
n: number
) {
for (let i = 0; i < n; i++) {
f(i)
}
}
当你调用times时,如果你在内联声明函数,你不需要显式地注释传递给times的函数:
times(n => console.log(n), 4)
TypeScript 从上下文中推断出 n 是一个 number ——我们在 times 的签名中声明 f 的参数 index 是一个 number,而 TypeScript 足够智能,推断出 n 就是该参数,因此它必须是一个 number。
请注意,如果我们没有内联声明 f,TypeScript 将无法推断其类型:
function f(n) { // Error TS7006: Parameter 'n' implicitly has an 'any' type.
console.log(n)
}
times(f, 4)
函数重载类型
在上一节中我们使用的函数类型语法——type Fn = (...) => ...——是简写调用签名。我们也可以更明确地写出它。再次以 Log 的例子来说明:
// Shorthand call signature
type Log = (message: string, userId?: string) => void
// Full call signature
type Log = {
(message: string, userId?: string): void
}
两者在所有方面完全等效,仅在语法上有所不同。
您是否希望在简写和完整签名之间选择完整的调用签名?对于像我们的 Log 函数这样的简单情况,您应该更倾向于简写;但对于更复杂的函数,完整签名有几个很好的使用案例。
其中之一是函数类型的重载。但首先,重载函数是什么意思呢?
在大多数编程语言中,一旦声明了一个接受一些参数并生成某种返回类型的函数,您可以使用完全相同的参数集调用该函数,并且始终会得到相同的返回类型。但在 JavaScript 中不是这样的。由于 JavaScript 是一种如此动态的语言,有一个常见模式是有多种方式调用给定函数;不仅如此,有时输出类型实际上取决于参数的输入类型!
TypeScript 通过其静态类型系统模拟了这种动态性——函数重载声明以及函数的输出类型取决于其输入类型。我们可能认为这种语言特性理所当然,但对于类型系统来说,这确实是一个非常先进的功能!
您可以使用函数重载签名设计非常表达力的 API。例如,让我们设计一个用于预订假期的 API——我们称之为Reserve。让我们从勾勒其类型开始(这次是完整的类型签名):
type Reserve = {
(from: Date, to: Date, destination: string): Reservation
}
接下来,让我们为 Reserve 做一个桩实现:
let reserve: Reserve = (from, to, destination) => {
// ...
}
因此,想要预订到巴厘岛的用户必须使用我们的 reserve API,提供一个from日期,一个to日期,并将"Bali"作为目的地传入。
我们可以重新设计我们的 API 来支持单程旅行:
type Reserve = {
(from: Date, to: Date, destination: string): Reservation
(from: Date, destination: string): Reservation
}
当您尝试运行此代码时,您会注意到 TypeScript 将在您实现 Reserve 的地方给出错误(见 图 4-3)。

图 4-3. 当缺少组合重载签名时的 TypeError
这是因为在 TypeScript 中调用签名重载函数的方式。如果为函数 f 声明了一组重载签名,从调用者的角度来看,f 的类型是这些重载签名的联合。但从 f 的实现角度来看,必须有一个单一的、合并的类型实际上可以被实现。在实现 f 时,您需要手动声明这个合并的调用签名——它不会为您推断。对于我们的 Reserve 示例,我们可以像这样更新我们的 reserve 函数:
type Reserve = {
(from: Date, to: Date, destination: string): Reservation
(from: Date, destination: string): Reservation
} 
let reserve: Reserve = (
from: Date,
toOrDestination: Date | string,
destination?: string
) => { 
// ... }
我们声明了两个重载函数签名。
实现签名是我们手动组合两个重载签名的结果(换句话说,我们通过手工计算得到 Signature1 | Signature2)。请注意,组合签名对调用 reserve 的函数不可见;从消费者的角度来看,Reserve 的签名是:
type Reserve = {
(from: Date, to: Date, destination: string): Reservation
(from: Date, destination: string): Reservation
}
值得注意的是,这不包括我们创建的组合签名:
// Wrong!
type Reserve = {
(from: Date, to: Date, destination: string): Reservation
(from: Date, destination: string): Reservation
(from: Date, toOrDestination: Date | string,
destination?: string): Reservation
}
由于 reserve 可能以两种方式之一被调用,因此在实现 reserve 时,您必须向 TypeScript 证明您检查了它的调用方式:^(6)
let reserve: Reserve = (
from: Date,
toOrDestination: Date | string,
destination?: string
) => {
if (toOrDestination instanceof Date && destination !== undefined) {
// Book a one-way trip
} else if (typeof toOrDestination === 'string') {
// Book a round trip
}
}
浏览器 DOM API 中自然会遇到重载。例如,createElement DOM API 用于创建新的 HTML 元素。它接受一个对应于 HTML 标签的字符串,并返回该标签类型的新 HTML 元素。TypeScript 包含每个 HTML 元素的内置类型。这些包括:
-
<a>元素的HTMLAnchorElement -
<canvas>元素的HTMLCanvasElement -
<table>元素的HTMLTableElement
重载的调用签名是模拟 createElement 如何工作的一种自然方式。想想您可能如何为 createElement 编写类型(在阅读以下内容之前尝试自己回答!)。
答案是:
type CreateElement = {
(tag: 'a'): HTMLAnchorElement 
(tag: 'canvas'): HTMLCanvasElement
(tag: 'table'): HTMLTableElement
(tag: string): HTMLElement 
}
let createElement: CreateElement = (tag: string): HTMLElement => { 
// ... }
我们会根据参数的类型进行重载,使用字符串字面类型进行匹配。
我们添加了一个捕获所有情况:如果用户传递了一个自定义标签名称,或者是一个尚未包含在 TypeScript 内置类型声明中的前沿实验性标签名称,我们将返回一个通用的 HTMLElement。由于 TypeScript 解析重载时会按照声明的顺序进行(例如,调用没有特定重载定义的字符串时,如 createElement('foo'),TypeScript 将退而求其次返回 HTMLElement)。
为了为实现的参数设置类型,我们将 createElement 的重载签名中可能具有的所有类型组合在一起,结果是 'a' | 'canvas' | 'table' | string。由于这三个字符串字面类型都是 string 的子类型,类型最终简化为 string。
注意
在本节中的所有示例中,我们都重载了函数表达式。但是,如果我们想要重载函数声明怎么办?幸运的是,TypeScript 提供了等效于函数表达式的语法。让我们重新编写我们的 createElement 的重载:
function createElement(tag: 'a'): HTMLAnchorElement
function createElement(tag: 'canvas'): HTMLCanvasElement
function createElement(tag: 'table'): HTMLTableElement
function createElement(tag: string): HTMLElement {
// ...
}
您可以根据需要选择使用哪种语法,这取决于您要重载的函数类型(函数表达式还是函数声明)。
完整的类型签名不仅限于重载函数的调用方式。您还可以使用它们来模拟函数的属性。由于 JavaScript 函数只是可调用对象,您可以为它们分配属性来执行诸如以下操作的功能:
function warnUser(warning) {
if (warnUser.wasCalled) {
return
}
warnUser.wasCalled = true
alert(warning)
}
warnUser.wasCalled = false
也就是说,我们向用户显示警告,但我们不会重复显示警告。让我们使用 TypeScript 来定义warnUser的完整签名:
type WarnUser = {
(warning: string): void
wasCalled: boolean
}
然后,我们可以将warnUser重写为一个实现该签名的函数表达式:
let warnUser: WarnUser = (warning: string) => {
if (warnUser.wasCalled) {
return
}
warnUser.wasCalled = true
alert(warning)
}
warnUser.wasCalled = false
请注意,TypeScript 足够聪明,尽管在声明warnUser函数时我们没有将wasCalled赋值给它,但我们确实在声明后立即将wasCalled赋值给它了。
多态性
到目前为止,在这本书中,我们一直在讨论具体类型的使用方式和原因,以及使用具体类型的函数。什么是具体类型?到目前为止,我们看到的每一种类型都是具体类型:
-
boolean -
string -
Date[] -
{a: number} | {b: string} -
(numbers: number[]) => number
具体类型在您确切知道期望的类型并希望验证实际传递的类型时非常有用。但有时,您并不知道预期的类型,并且不希望将函数的行为限制为特定类型!
作为我所说的一个例子,让我们来实现filter。您可以使用filter来迭代数组并细化它;在 JavaScript 中,它可能看起来像这样:
function filter(array, f) {
let result = []
for (let i = 0; i < array.length; i++) {
let item = array[i]
if (f(item)) {
result.push(item)
}
}
return result
}
filter([1, 2, 3, 4], _ => _ < 3) // evaluates to [1, 2]
让我们首先提取filter的完整类型签名,并为类型添加一些占位符unknown:
type Filter = {
(array: unknown, f: unknown) => unknown[]
}
现在,让我们尝试用number来填充类型:
type Filter = {
(array: `number``[``]`, f: `(``item``:` `number``)` `=``>` `boolean`): `number``[``]`
}
将数组的元素类型定义为number对于这个例子效果很好,但是filter函数应该是一个泛型函数——您可以过滤数字、字符串、对象、其他数组或任何类型的数组。我们编写的签名适用于数字数组,但对其他类型的数组不适用。让我们尝试使用重载来扩展它,使其也适用于字符串数组:
type Filter = {
(array: number[], f: (item: number) => boolean): number[]
`(``array``:` `string``[``]``,` `f``:` `(``item``:` `string``)` `=``>` `boolean``)``:` `string``[``]`
}
到目前为止还好(虽然为每种类型编写重载可能会变得混乱)。那么对象数组怎么办?
type Filter = {
(array: number[], f: (item: number) => boolean): number[]
(array: string[], f: (item: string) => boolean): string[]
`(``array``:` `object``[``]``,` `f``:` `(``item``:` `object``)` `=``>` `boolean``)``:` `object``[``]`
}
乍一看,这看起来可能没问题,但让我们试着使用它来看看它在哪里出问题。如果您使用该签名(即filter: Filter)来实现一个filter函数,并尝试使用它,您会得到:
let names = [
{firstName: 'beth'},
{firstName: 'caitlyn'},
{firstName: 'xin'}
]
let result = filter(
names,
_ => _.firstName.startsWith('b')
) // Error TS2339: Property 'firstName' does not exist on type 'object'.
result[0].firstName // Error TS2339: Property 'firstName' does not exist
// on type 'object'.
此时,TypeScript 抛出错误应该有点讲得通了。我们告诉 TypeScript 我们可能会传递一个数字、字符串或对象的数组给filter。我们传递了一个对象数组,但请记住,object并不告诉您对象的具体形状。因此,每次我们尝试访问数组中对象的属性时,TypeScript 都会抛出错误,因为我们没有告诉它对象具体是什么形状。
该怎么办?
如果您来自支持泛型类型的语言,那么现在您可能正在翻白眼并大声喊道,“这就是泛型的用途!”好消息是,您说得对(坏消息是,您刚刚把邻居家的孩子吵醒了)。
如果您之前没有使用过泛型类型,我将首先定义它们,然后通过我们的filter函数给出一个示例。
回到我们的filter示例,当我们使用泛型类型参数T重新编写它时,它的类型如下所示:
type Filter = {
`<``T``>`(array: `T`[], f: (item: `T`) => boolean): `T`[]
}
我们在这里做的是告诉 TypeScript:“这个函数 filter 使用一个泛型类型参数 T;我们事先不知道这种类型会是什么,所以 TypeScript 如果你能推断出每次我们调用 filter 时它是什么类型,那就太好了。” TypeScript 会从我们为 array 传入的类型推断出 T。一旦 TypeScript 推断出给 filter 的特定调用中的 T 是什么,它就会用该类型替换它看到的每一个 T。T 就像是一个占位符类型,将从上下文中由类型检查器填充;它 参数化 了 Filter 的类型,这就是为什么我们称其为泛型类型 参数。
注意
因为每次都说 “泛型类型参数” 很麻烦,人们通常将其缩短为 “泛型类型” 或简称 “泛型”。本书中我会交替使用这些术语。
看起来有点滑稽的尖括号 <>,是你声明泛型类型参数的方式(把它们想象成 type 关键字,但用于泛型类型);尖括号的位置决定了泛型的作用范围(你可以放它们的地方很有限),TypeScript 会确保在它们的范围内,所有泛型类型参数的实例最终都绑定到相同的具体类型。在这个例子中,由于尖括号的位置,当我们调用 filter 时,TypeScript 将为我们的泛型 T 绑定具体类型。它会根据我们如何调用 filter 来决定将哪种具体类型绑定到 T。你可以在一对尖括号之间声明任意数量的逗号分隔的泛型类型参数。
注意
T 只是一个类型名称,我们完全可以使用其他任何名称:A、Zebra 或 l33t。按照惯例,人们使用从大写单字母 T 开始一直到 U、V、W 等,具体取决于他们需要多少个泛型。
如果你连续声明了很多泛型,或者在复杂的情况下使用它们,请考虑偏离这种惯例,而是使用更具描述性的名称,比如 Value 或 WidgetType。
有些人喜欢从 A 开始而不是从 T 开始。不同的编程语言社区根据其传统偏好其中之一:函数语言用户喜欢 A、B、C 等,因为它们类似于数学证明中可能会出现的希腊字母 α、β 和 γ;面向对象语言用户倾向于使用 T 表示 “类型”。尽管 TypeScript 支持这两种编程风格,但它使用后者的约定。
就像一个函数的参数在每次调用该函数时都会被重新绑定一样,每次调用 filter 都会为 T 获得自己的绑定:
type Filter = {
<T>(array: T[], f: (item: T) => boolean): T[]
}
let filter: Filter = (array, f) => // ...
// (a) T is bound to number
filter([1, 2, 3], _ => _ > 2)
// (b) T is bound to string
filter(['a', 'b'], _ => _ !== 'b')
// (c) T is bound to {firstName: string}
let names = [
{firstName: 'beth'},
{firstName: 'caitlyn'},
{firstName: 'xin'}
]
filter(names, _ => _.firstName.startsWith('b'))
TypeScript 从我们传入的参数类型中推断出这些泛型绑定。让我们逐步了解 TypeScript 如何为 (a) 绑定 T:
-
从
filter的类型签名中,TypeScript 知道array是一个包含某种类型T元素的数组。 -
TypeScript 注意到我们传入了数组
[1, 2, 3],因此T必须是number。 -
TypeScript 在任何地方看到
T时都会将其替换为number类型。因此,参数f: (item: T) => boolean变成了f: (item: number) => boolean,返回类型T[]变成了number[]。 -
TypeScript 检查所有类型是否满足可赋值性,并检查我们传入的函数
f是否可分配给其新推断的签名。
泛型是一种比具体类型更通用地描述函数行为的强大方式。理解泛型的方式就像约束一样。就像将函数参数注释为n: number约束参数n的值类型为number一样,使用泛型T约束了绑定到T的任何类型在出现T的任何地方都是相同的类型。
提示
泛型类型也可以用于类型别名、类和接口中——在本书中我们将大量使用它们。随着我们涵盖更多的主题,我会在相关上下文中介绍它们。
尽可能使用泛型。它们将有助于保持代码的通用性、重用性和简洁性。
泛型何时被绑定?
声明泛型类型的位置不仅仅是作用域的问题,还决定了 TypeScript 何时将具体类型绑定到你的泛型中。从最后一个例子可以看出:
type Filter = {
<T>(array: T[], f: (item: T) => boolean): T[]
}
let filter: Filter = (array, f) =>
// ...
因为我们将<T>声明为调用签名的一部分(在签名的开括号()之前),当我们实际调用类型为Filter的函数时,TypeScript 将会将一个具体的类型绑定到T。
如果我们将T限定为类型别名Filter,TypeScript 将要求我们在使用Filter时显式绑定一个类型:
type Filter`<``T``>` = {
(array: T[], f: (item: T) => boolean): T[]
}
let filter: Filter = (array, f) => *`// Error TS2314: Generic type 'Filter'`*
// ... *`// requires 1 type argument(s).`*
type OtherFilter = Filter *`// Error TS2314: Generic type 'Filter'`*
*`// requires 1 type argument(s).`*
let filter: Filter<number> = (array, f) =>
// ...
type StringFilter = Filter<string>
let stringFilter: StringFilter = (array, f) =>
// ...
通常情况下,TypeScript 在你使用泛型时会将具体类型绑定到你的泛型中:对于函数来说,是在调用它们时;对于类来说,是在实例化它们时(更多内容请参见“多态性”);对于类型别名和接口(请参见“接口”),是在使用或实现它们时。
你可以在哪里声明泛型?
对于 TypeScript 声明调用签名的每种方式,都有一种方法可以为其添加泛型类型:
type Filter = { 
<T>(array: T[], f: (item: T) => boolean): T[]
}
let filter: Filter = // ...
type Filter<T> = { 
(array: T[], f: (item: T) => boolean): T[]
}
let filter: Filter<number> = // ...
type Filter = <T>(array: T[], f: (item: T) => boolean) => T[] 
let filter: Filter = // ...
type Filter<T> = (array: T[], f: (item: T) => boolean) => T[] 
let filter: Filter<string> = // ...
function filter<T>(array: T[], f: (item: T) => boolean): T[] { 
// ... }
全部调用签名,T作用域限定为单个签名。因为T限定在单个签名中,所以当你调用类型为filter的函数时,TypeScript 将为该签名的T绑定一个具体的类型。每次调用filter都会为T获得自己的绑定。
全部调用签名,T作用域涵盖所有签名。因为T声明为Filter类型的一部分(而不是特定签名类型的一部分),所以当你声明类型为Filter的函数时,TypeScript 将绑定T。
就像,但是这是一种简写的调用签名而不是完整的调用签名。
就像,但是这是一种简写的调用签名而不是完整的调用签名。
一个命名函数调用签名,T作用域在签名中。当你调用filter时,TypeScript 将一个具体类型绑定到T,每次调用filter都会为T得到一个新的绑定。
作为第二个例子,让我们编写一个map函数。map函数与filter非常相似,但是它不会从数组中移除项目,而是使用映射函数转换每个项目。我们首先草拟实现如下:
function map(array: unknown[], f: (item: unknown) => unknown): unknown[] {
let result = []
for (let i = 0; i < array.length; i++) {
result[i] = f(array[i])
}
return result
}
在你继续之前,试着思考一下如何使map函数成为泛型,用某些类型替换每个unknown。你需要多少个泛型?如何声明你的泛型,并将其作用域限制在map函数中?array、f和返回值应该是什么类型?
准备好了吗?如果你还没有自己尝试,请尝试一下。你能做到的。真的!
好了,不再唠叨。答案如下:
function map`<``T``,` `U``>`(array: `T`[], f: (item: `T`) => `U`): `U`[] {
let result = []
for (let i = 0; i < array.length; i++) {
result[i] = f(array[i])
}
return result
}
我们需要确切两个泛型类型:T表示输入数组成员的类型,U表示输出数组成员的类型。我们传入一个T类型的数组,并且传入一个将T映射到U的映射函数。最后,我们返回一个U类型的数组。
泛型类型推断
在大多数情况下,TypeScript 很好地推断出了泛型类型。当你调用我们之前编写的map函数时,TypeScript 推断出T是string,U是boolean:
function map<T, U>(array: T[], f: (item: T) => U): U[] {
// ...
}
map(
['a', 'b', 'c'], // An array of T
_ => _ === 'a' // A function that returns a U
)
不过,你也可以显式注释你的泛型。对于泛型的显式注释,要么对所有必需的泛型类型进行注释,要么不对任何泛型类型进行注释:
map <string, boolean>(
['a', 'b', 'c'],
_ => _ === 'a'
)
map <string>( // Error TS2558: Expected 2 type arguments, but got 1.
['a', 'b', 'c'],
_ => _ === 'a'
)
TypeScript 将检查每个推断出的泛型类型是否可以分配给其相应的显式绑定泛型类型;如果不能分配,就会报错:
// OK, because boolean is assignable to boolean | string
map<string, boolean | string>(
['a', 'b', 'c'],
_ => _ === 'a'
)
map<string, number>(
['a', 'b', 'c'],
_ => _ === 'a' // Error TS2322: Type 'boolean' is not assignable
) // to type 'number'.
由于 TypeScript 从你传递给泛型函数的参数中推断出泛型的具体类型,有时你会遇到这样的情况:
let promise = new Promise(resolve =>
resolve(45)
)
promise.then(result => // Inferred as {}
result * 4 // Error TS2362: The left-hand side of an arithmetic operation must
) // be of type 'any', 'number', 'bigint', or an enum type.
怎么回事?为什么 TypeScript 将result推断为{}?因为我们没有给它足够的信息来处理——因为 TypeScript 只使用泛型函数参数的类型来推断泛型的类型,它默认将T推断为{}!
要修复这个问题,我们必须显式注释Promise的泛型类型参数:
let promise = new Promise<number>(resolve =>
resolve(45)
)
promise.then(result => // number
result * 4
)
泛型类型别名
我们已经在本章的Filter示例中涉及了泛型类型别名。如果你还记得上一章的Array和ReadonlyArray类型(参见“只读数组和元组”),那些也是泛型类型别名!让我们通过一个简短的示例深入了解在类型别名中使用泛型。
让我们定义一个描述 DOM 事件(如click或mousedown)的MyEvent类型:
type MyEvent<T> = {
target: T
type: string
}
注意,在类型别名中声明泛型类型的唯一有效位置是:在类型别名的名称后面、分配(=)之前。
MyEvent的target属性指向发生事件的元素:如<button />,<div />等。例如,你可以像这样描述一个按钮事件:
type ButtonEvent = MyEvent<HTMLButtonElement>
当您使用像 MyEvent 这样的泛型类型时,必须在使用该类型时显式绑定其类型参数;它们不会为您推断出来:
let myEvent: Event<HTMLButtonElement | null> = {
target: document.querySelector('#myButton'),
type: 'click'
}
您可以使用 MyEvent 来构建另一个类型——比如 TimedEvent。当泛型 T 在 TimedEvent 中被绑定时,TypeScript 也会将其绑定到 MyEvent:
type TimedEvent<T> = {
event: MyEvent<T>
from: Date
to: Date
}
您也可以在函数的签名中使用泛型类型别名。当 TypeScript 将一个类型绑定到 T 时,它也会为您将其绑定到 MyEvent:
function triggerEvent<T>(event: MyEvent<T>): void {
// ...
}
triggerEvent({ // T is Element | null
target: document.querySelector('#myButton'),
type: 'mouseover'
})
让我们一步步地解析这里发生的事情:
-
我们使用一个对象调用
triggerEvent。 -
TypeScript 看到,根据我们函数的签名,我们传递的参数必须是
MyEvent<T>类型。它还注意到,我们定义了MyEvent<T>为{target: T, type: string}。 -
TypeScript 注意到我们传递的对象的
target字段是document.querySelector('#myButton')。这意味着T必须是document.querySelector('#myButton')的类型:Element | null。所以T现在绑定到Element | null。 -
TypeScript 遍历并替换每个
T的出现位置为Element | null。 -
TypeScript 检查所有类型是否满足可赋值性。它们确实如此,所以我们的代码类型检查通过。
有界多态性
注意
在本节中,我将以二叉树为例。如果您之前没有使用过二叉树,请不要担心。对于我们的目的,基本原理是:
-
二叉树是一种数据结构。
-
一个二叉树由节点组成。
-
一个节点持有一个值,并且可以指向最多两个子节点。
-
一个节点可以是两种类型之一:叶节点(表示它没有子节点)或内部节点(表示它至少有一个子节点)。
有时,仅仅说“这个东西是某种泛型类型 T,并且那个东西必须具有相同的类型 T”是不够的。有时,您还想说“类型 U 应该至少是 T”。我们称这在 U 上设置上界。
为什么我们要这样做?假设我们正在实现一个二叉树,并且有三种类型的节点:
-
常规的
TreeNode -
LeafNode是没有子节点的TreeNode -
InnerNode是具有子节点的TreeNode
让我们从声明节点类型开始:
type TreeNode = {
value: string
}
type LeafNode = TreeNode & {
isLeaf: true
}
type InnerNode = TreeNode & {
children: [TreeNode] | [TreeNode, TreeNode]
}
我们要说的是:TreeNode 是一个带有单个属性 value 的对象。LeafNode 类型具有 TreeNode 的所有属性,加上一个始终为 true 的 isLeaf 属性。InnerNode 也具有 TreeNode 的所有属性,加上一个指向一个或两个子节点的 children 属性。
接下来,让我们编写一个 mapNode 函数,它接受一个 TreeNode 并映射其值,返回一个新的 TreeNode。我们希望设计一个可以像这样使用的 mapNode 函数:
let a: TreeNode = {value: 'a'}
let b: LeafNode = {value: 'b', isLeaf: true}
let c: InnerNode = {value: 'c', children: [b]}
let a1 = mapNode(a, _ => _.toUpperCase()) // TreeNode
let b1 = mapNode(b, _ => _.toUpperCase()) // LeafNode
let c1 = mapNode(c, _ => _.toUpperCase()) // InnerNode
现在暂停一下,考虑一下如何编写一个 mapNode 函数,它接受 TreeNode 的子类型并返回相同的子类型。传入 LeafNode 应该返回 LeafNode,传入 InnerNode 应该返回 InnerNode,传入 TreeNode 应该返回 TreeNode。在继续之前,请考虑一下如何做到这一点。这可能吗?
答案如下:
function mapNode<T extends TreeNode>( 
node: T, 
f: (value: string) => string
): T { 
return {
...node,
value: f(node.value)
}
}
mapNode是一个定义了单个泛型类型参数T的函数。T的上界是TreeNode。也就是说,T可以是TreeNode,或者TreeNode的子类型。
mapNode接受两个参数,第一个参数是类型为T的node。因为在 中我们声明了
node extends TreeNode,如果我们传入的不是TreeNode,比如空对象{}、null或者TreeNode的数组,那么会立即显示红色下划线。node必须是TreeNode或TreeNode的子类型。
mapNode返回类型为T的值。请记住,T可能是TreeNode,也可能是TreeNode的任何子类型。
为什么我们要那样声明T呢?
-
如果我们将
T简单地声明为T(省略extends TreeNode),那么mapNode将会抛出编译时错误,因为在未限定类型为T的情况下安全读取node.value是不可能的(如果用户传递一个数字会怎么样?)。 -
如果我们完全省略了
T并声明mapNode为(node: TreeNode, f: (value: string) => string) => TreeNode,那么在映射节点后我们将丢失信息:a1、b1和c1都将只是TreeNode。
通过声明T extends TreeNode,我们能够在映射后保留输入节点的具体类型(TreeNode、LeafNode或InnerNode)。
使用多个约束的有界多态性
在上一个例子中,我们对T施加了单一类型约束:T至少必须是一个TreeNode。但是如果你想要多个类型约束呢?
只需扩展这些约束的交集(&):
type HasSides = {numberOfSides: number}
type SidesHaveLength = {sideLength: number}
function logPerimeter< 
Shape extends HasSides & SidesHaveLength 
>(s: Shape): Shape { 
console.log(s.numberOfSides * s.sideLength)
return s
}
type Square = HasSides & SidesHaveLength
let square: Square = {numberOfSides: 4, sideLength: 3}
logPerimeter(square) // Square, logs "12"
logPerimeter是一个接受类型为Shape的单一参数s的函数。
Shape是一个泛型类型,它同时扩展了HasSides类型和SidesHaveLength类型。换句话说,一个Shape至少具有具有长度的边。
logPerimeter返回的值与你给它的完全相同的类型。
使用有界多态性来建模参数个数
另一个你将会使用有界多态性的地方是模拟可变参数函数(接受任意数量参数的函数)。例如,让我们实现自己版本的 JavaScript 内置的call函数(提醒一下,call是一个函数,它接受一个函数和可变数量的参数,并将这些参数应用于函数)。^(8) 我们将定义并使用它如下,使用unknown来填充我们稍后会填写的类型:
function call(
f: (...args: unknown[]) => unknown,
...args: unknown[]
): unknown {
return f(...args)
}
function fill(length: number, value: string): string[] {
return Array.from({length}, () => value)
}
call(fill, 10, 'a') // evaluates to an array of 10 'a's
现在让我们填写这些unknown。我们想要表达的约束是:
-
f应该是一个接受某些参数T并返回某种类型R的函数。我们事先不知道它将有多少个参数。 -
call接受f,以及与f本身接受的T相同的一组参数。再次强调,我们事先不知道期望的参数数量。 -
call返回与f返回相同的类型R。
我们需要两个类型参数:T,它是一个参数数组,以及R,它是一个任意返回值。让我们填写类型:
function call`<``T` `extends` `unknown``[``]``,` `R``>`( 
f: (...args: `T`) => `R`, 
...args: `T` 
): `R` { 
return f(...args)
}
这究竟是如何工作的?让我们逐步走过它:
call是一个可变参数函数(提醒一下,可变参数函数是接受任意数量参数的函数),具有两个类型参数:T和R。T是unknown[]的子类型;也就是说,T是任何类型的数组或元组。
call的第一个参数是一个函数f。f也是可变参数的,并且其参数与args共享类型:无论args的类型是什么,f的参数类型都完全相同。
除了函数f外,call还具有一组可变数量的额外参数...args。args是一个剩余参数,描述了可变数量的参数。args的类型是T,而T必须是数组类型(事实上,如果我们忘记说T扩展了数组类型,TypeScript 会向我们抛出一个波浪线),因此 TypeScript 将根据我们传递给args的特定参数推断出T的元组类型。
call返回类型为R的值(R绑定到f返回的任何类型)。
现在当我们调用call时,TypeScript 将精确地知道返回类型,并且当我们传递错误数量的参数时,它会抱怨:
let a = call(fill, 10, 'a') // string[]
let b = call(fill, 10) // Error TS2554: Expected 3 arguments; got 2.
let c = call(fill, 10, 'a', 'z') // Error TS2554: Expected 3 arguments; got 4.
我们使用类似的技术来利用 TypeScript 为剩余参数推断元组类型以改进元组在“改进元组的类型推断”中的类型推断。
泛型类型默认值
就像你可以给函数参数提供默认值一样,你也可以给泛型类型参数提供默认类型。例如,让我们重新访问来自“泛型类型别名”的MyEvent类型。作为提醒,我们用这个类型来模拟 DOM 事件,它看起来像这样:
type MyEvent<T> = {
target: T
type: string
}
要创建一个新事件,我们必须显式地将一个泛型类型绑定到MyEvent,表示事件分派在其上的 HTML 元素的类型:
let buttonEvent: MyEvent<HTMLButtonElement> = {
target: myButton,
type: string
}
为了方便起见,当我们事先不知道MyEvent将绑定到的具体元素类型时,我们可以为MyEvent的泛型添加一个默认值:
type MyEvent<T `=` `HTMLElement`> = {
target: T
type: string
}
我们还可以利用这个机会应用我们在最近几节中学到的内容,并给T添加一个限制,以确保T是一个 HTML 元素:
type MyEvent<T `extends` `HTMLElement` = HTMLElement> = {
target: T
type: string
}
现在,我们可以轻松创建一个不特定于特定 HTML 元素类型的事件,并且在创建事件时不必手动将MyEvent的T绑定到HTMLElement:
let myEvent: MyEvent = {
target: myElement,
type: string
}
请注意,与函数中的可选参数一样,具有默认值的泛型类型必须出现在没有默认值的泛型类型之后:
// Good
type MyEvent2<
Type extends string,
Target extends HTMLElement = HTMLElement,
> = {
target: Target
type: Type
}
// Bad
type MyEvent3<
Target extends HTMLElement = HTMLElement,
Type extends string // Error TS2706: Required type parameters may
> = { // not follow optional type parameters.
target: Target
type: Type
}
类型驱动开发
强大的类型系统带来了巨大的威力。当你使用 TypeScript 编写代码时,你经常会发现自己“以类型为先导”。这当然是指 类型驱动开发。
静态类型系统的目的是限制表达式可以保存的值的类型。类型系统越具表达性,它就越能告诉你关于表达式中包含的值的信息。当你将一个具有表达性的类型系统应用于函数时,函数的类型签名可能会告诉你大部分关于该函数的所需信息。
让我们来看看本章前面提到的 map 函数的类型签名:
function map<T, U>(array: T[], f: (item: T) => U): U[] {
// ...
}
即使你以前从未见过 map 函数,单看这个签名,你也应该对 map 的功能有一定直觉:它接受一个 T 类型的数组和一个从 T 到 U 的映射函数,并返回一个 U 类型的数组。请注意,你并不需要看函数的实现就能知道这些!^(9)
当你编写 TypeScript 程序时,首先定义函数的类型签名——换句话说,以类型为先导——稍后再填写实现细节。通过首先在类型层面上勾勒出你的程序,你确保在深入实现之前所有内容都在高层次上有意义。
到目前为止,我们一直在做相反的事情:先实现,然后推断类型。现在你已经掌握了在 TypeScript 中编写和类型化函数的方法,我们将改变方式,首先勾勒类型,然后再填写细节。
摘要
在本章中,我们讨论了如何声明和调用函数,如何为参数类型化,以及如何在 TypeScript 中表达常见的 JavaScript 函数特性,如默认参数、剩余参数、生成器函数和迭代器。我们讨论了函数的调用签名和实现之间的区别,上下文类型化,以及多态函数和类型别名的深入理解:为什么它有用,如何在何处声明泛型类型,TypeScript 如何推断泛型类型,以及如何为泛型类型添加界限和默认值。最后,我们简要介绍了类型驱动开发:它是什么,以及如何利用你对函数类型的新知识来实现它。
练习
-
TypeScript 推断函数类型签名的哪些部分:参数、返回类型还是两者都包括?
-
JavaScript 的
arguments对象类型安全吗?如果不安全,有什么替代品? -
你希望能够立即预订一个假期。在本章前面的“函数重载”中更新
reserve函数的多个签名之一,添加一个只接受目的地参数而不需要明确开始日期的第三个调用签名。更新reserve的实现以支持这个新的重载签名。 -
[Hard] 更新我们章节前面的
call实现(“使用有界多态性来建模元数”),只能用于第二个参数为string的函数。对于所有其他函数,你的实现应在编译时失败。 -
实现一个小型类型安全的断言库,
is。首先勾勒出你的类型。完成后,你应该能像这样使用它:
// Compare a string and a string
is('string', 'otherstring') // false
// Compare a boolean and a boolean
is(true, false) // false
// Compare a number and a number
is(42, 42) // true
// Comparing two different types should give a compile-time error
is(10, 'foo') // Error TS2345: Argument of type '"foo"' is not assignable
// to parameter of type 'number'.
// [Hard] I should be able to pass any number of arguments
is([1], [1, 2], [1, 2, 3]) // false
^(1) 为什么它们不安全?如果你将最后一个示例输入到你的代码编辑器中,你会看到它的类型是 Function。这个 Function 类型是什么?它是一个可以调用的对象(你知道,通过在其后加上 ()),并具有来自 Function.prototype 的所有原型方法。但它的参数和返回类型是未定义的,所以你可以用任何参数来调用这个函数,而 TypeScript 则会无动于衷地看着你做一些在你所在的任何城市都应该是非法的事情。
^(2) 深入了解 this,请查看 Kyle Simpson 的 You Don’t Know JS 系列书籍。
^(3) 特别要注意,Object 和 Number 并不是迭代器。
^(4) 例外情况是枚举和命名空间。枚举会生成类型和值,而命名空间仅存在于值级别。详见附录 C 获取完整参考。
^(5) 如果你之前没听说过“回调”这个术语,它只是指将一个函数作为参数传递给另一个函数。
^(6) 想了解更多,请跳转至“精炼”章节。
^(7) 大部分情况下,TypeScript 会将文字重载提升至非文字重载之上,再按顺序解析。不过,你可能不希望依赖这个特性,因为它可能会让其他不熟悉此行为的工程师难以理解你的重载。
^(8) 为了简化我们的实现,我们将设计我们的 call 函数时不考虑 this。
^(9) 有一些编程语言(如类似 Haskell 的语言 Idris)具有内置的约束求解器,能够自动从你编写的签名中实现函数体!
第五章:类和接口
如果您像大多数来自面向对象编程语言的程序员一样,类是您的基础和要点。类是您组织和思考代码的方式,也是封装的主要单元。您会高兴地发现,TypeScript 类大量借鉴了 C#,支持可见性修饰符、属性初始化器、多态、装饰器和接口等功能。但由于 TypeScript 类编译成常规 JavaScript 类,您也可以以类型安全的方式表达 JavaScript 习语,如混合类。
TypeScript 的某些类特性,如属性初始化器和装饰器,也被 JavaScript 类支持,^(1) 因此会生成运行时代码。其他特性,如可见性修饰符、接口和泛型,则是 TypeScript 独有的特性,仅在编译时存在,编译应用程序为 JavaScript 时不会生成任何代码。
在本章中,我将通过一个扩展示例向您介绍如何在 TypeScript 中使用类,以便您不仅可以对 TypeScript 的面向对象语言特性有直观的理解,还可以理解我们使用它们的方式和原因。请尝试跟着进行,将代码输入到您的代码编辑器中。
类和继承
我们要构建一个国际象棋引擎。我们的引擎将模拟国际象棋游戏,并为两名玩家提供轮流进行移动的 API。
我们将从勾勒类型开始:
// Represents a chess game
class Game {}
// A chess piece
class Piece {}
// A set of coordinates for a piece
class Position {}
有六种类型的棋子:
// ...
class King extends Piece {}
class Queen extends Piece {}
class Bishop extends Piece {}
class Knight extends Piece {}
class Rook extends Piece {}
class Pawn extends Piece {}
每个棋子都有颜色和当前位置。在国际象棋中,位置被建模为(字母,数字)坐标对;字母沿着 x 轴从左到右,数字沿着 y 轴从底部到顶部(图 5-1)。

图 5-1. 国际象棋中的标准代数符号:A–H(x 轴)称为“列”,1–8(反向 y 轴)称为“行”
让我们向我们的 Piece 类添加颜色和位置:
`type` `Color` `=` `'Black'` `|` `'White'`
`type` `File` `=` `'A'` `|` `'B'` `|` `'C'` `|` `'D'` `|` `'E'` `|` `'F'` `|` `'G'` `|` `'H'`
`type` `Rank` `=` `1` `|` `2` `|` `3` `|` `4` `|` `5` `|` `6` `|` `7` `|` `8` 
class Position {
`constructor``(`
`private` `file``:` `File``,` 
`private` `rank``:` `Rank`
`)` `{``}`
}
class Piece {
`protected` `position``:` `Position` 
`constructor``(`
`private` `readonly` `color``:` `Color``,` 
`file``:` `File``,`
`rank``:` `Rank`
`)` `{`
`this``.``position` `=` `new` `Position``(``file``,` `rank``)`
`}`
}
由于颜色、行和列相对较少,我们可以手动枚举它们的可能值作为类型文字。这将通过将这些类型的域约束为一些非常具体的字符串和数字,来帮助我们提供额外的安全性。
在构造函数中,private 访问修饰符 自动将参数分配给 this(this.file 等),并将其可见性设置为私有,这意味着 Piece 实例内的代码可以读取和写入它,但 Piece 实例外的代码不能。不同 Piece 实例可以访问彼此的私有成员;任何其他类的实例,甚至是 Piece 的子类,也不能访问。
我们将实例变量 position 声明为 protected。与 private 不同,protected 将属性分配给 this,但与 private 不同的是,protected 使得属性对 Piece 的实例和任何 Piece 的子类的实例都可见。我们在声明时没有为 position 分配一个值,因此我们必须在 Piece 构造函数中为其分配一个值。如果我们没有在构造函数中为其分配一个值,TypeScript 会告诉我们该变量未被 确定赋值,即我们说它是类型 T,但实际上是 T | undefined,因为它在属性初始化程序或构造函数中未被赋值 —— 因此我们需要更新其签名以表明它不一定是 Position,而可能也是 undefined。
new Piece 接受三个参数:color、file 和 rank。我们对 color 添加了两个修饰符:private,意味着将其分配给 this 并确保它仅从 Piece 的实例访问,以及 readonly,意味着在此初始分配后,它只能读取,不能再写入。
TSC 标志:strictNullChecks 和 strictPropertyInitialization
要为类实例变量启用确定赋值检查,请在你的 tsconfig.json 中启用 strictNullChecks 和 strictPropertyInitialization 标志。如果你已经使用了 strict 标志,你可以继续使用。
TypeScript 支持类的属性和方法的三种访问修饰符:
public
可从任何地方访问。这是默认的访问级别。
protected
可从此类的实例和其子类访问。
private
仅从此类的实例访问。
使用访问修饰符,你可以设计不会过多暴露其实现细节的类,并暴露出为他人使用定义良好的 API。
我们定义了一个 Piece 类,但不希望用户直接实例化一个新的 Piece —— 我们希望他们扩展它以创建 Queen、Bishop 等,并实例化那些类。我们可以使用类型系统来强制执行这一点,使用 abstract 关键字:
*`// ...`*
`abstract` class Piece {
constructor(
*`// ...`*
现在如果你尝试直接实例化一个 Piece,TypeScript 会报错:
new Piece('White', 'E', 1) // Error TS2511: Cannot create an instance
// of an abstract class.
abstract 关键字意味着你不能直接实例化这个类,但并不意味着你不能在其上定义一些方法:
*`// ...`*
abstract class Piece {
*`// ...`*
`moveTo``(``position``:` `Position``)` `{`
`this``.``position` `=` `position`
`}`
`abstract` `canMoveTo``(``position``:` `Position``)``:` `boolean`
}
我们的 Piece 类现在:
-
告知其子类它们必须实现一个名为
canMoveTo的方法,该方法的签名必须兼容。如果一个类扩展了Piece但忘记实现抽象的canMoveTo方法,那在编译时就是类型错误:当你实现一个抽象类时,你必须也要实现其抽象方法。 -
提供了一个默认的
moveTo实现(其子类可以选择覆盖)。我们没有在moveTo上放置访问修饰符,所以默认是public,意味着它可从任何其他代码中读写。
让我们更新 King,实现 canMoveTo,以满足这个新需求。我们还将实现一个 distanceFrom 函数,以便轻松计算两个棋子之间的距离:
*`// ...`*
class Position {
*`// ...`*
`distanceFrom``(``position``:` `Position``)` `{`
`return` `{`
`rank``:` `Math.abs``(``position``.``rank` `-` `this``.``rank``)``,`
`file``:` `Math.abs``(``position``.``file``.``charCodeAt``(``0``)` `-` `this``.``file``.``charCodeAt``(``0``)``)`
`}`
`}`
}
class King extends Piece {
`canMoveTo``(``position``:` `Position``)` `{`
`let` `distance` `=` `this``.``position``.``distanceFrom``(``position``)`
`return` `distance``.``rank` `<` `2` `&&` `distance``.``file` `<` `2`
`}`
}
当我们创建一个新游戏时,我们会自动创建一个棋盘和一些棋子:
*`// ...`*
class Game {
`private` `pieces` `=` `Game``.``makePieces``(``)`
`private` `static` `makePieces() {`
`return` `[`
`// Kings ` `new` `King``(``'White'``,` `'E'``,` `1``)``,`
`new` `King``(``'Black'``,` `'E'``,` `8``)``,`
`// Queens ` `new` `Queen``(``'White'``,` `'D'``,` `1``)``,`
`new` `Queen``(``'Black'``,` `'D'``,` `8``)``,`
`// Bishops ` `new` `Bishop``(``'White'``,` `'C'``,` `1``)``,`
`new` `Bishop``(``'White'``,` `'F'``,` `1``)``,`
`new` `Bishop``(``'Black'``,` `'C'``,` `8``)``,`
`new` `Bishop``(``'Black'``,` `'F'``,` `8``)``,`
*`// ...`*
`]`
}
}
由于我们严格类型化了 Rank 和 File,如果我们输入了另一个字母(如 'J')或超出范围的数字(如 12),TypeScript 将在编译时给出错误(见图 5-2 )。

图 5-2。TypeScript 帮助我们坚持有效的等级和文件
这已足以展示 TypeScript 类的工作原理——我将避免深入讨论如何知道骑士何时可以吃掉一枚棋子,主教如何移动等细节。如果你有雄心壮志,看看是否可以以我们迄今为止所做的作为实现游戏其余部分的起点。
总结一下:
-
使用
class关键字声明类。使用extends关键字扩展类。 -
类可以是具体类或
abstract类。抽象类可以有abstract方法和abstract属性。 -
方法可以是
private、protected或默认的public。它们可以是实例方法或静态方法。 -
类可以有实例属性,这些属性也可以是
private、protected或默认的public。你可以在构造函数参数中声明它们,也可以在属性初始化器中声明它们。 -
在声明时,可以将实例属性标记为
readonly。
super
与 JavaScript 一样,TypeScript 支持 super 调用。如果你的子类覆盖了父类定义的方法(比如 Queen 和 Piece 都实现了 take 方法),子类实例可以使用 super 调用来调用其父类的方法(例如 super.take)。super 调用有两种类型:
-
方法调用,如
super.take。 -
构造函数调用,具有特殊形式
super(),只能从构造函数中调用。如果你的子类有构造函数,必须从子类的构造函数中调用super(),以正确连接类(别担心,TypeScript 会在你忘记时提醒你;这就像一个酷炫的未来机器大象)。
请注意,你只能使用 super 访问父类的方法,而不能访问其属性。
使用 this 作为返回类型
就像你可以将 this 用作值一样,你也可以将其用作类型(就像我们在“Typing this”中所做的那样)。在处理类时,this 类型对于注释方法的返回类型非常有用。
例如,让我们构建一个简化版的 ES6 Set 数据结构,支持两种操作:向集合中添加一个数字,以及检查给定的数字是否在集合中。你可以像这样使用它:
let set = new Set
set.add(1).add(2).add(3)
set.has(2) // true
set.has(4) // false
让我们定义 Set 类,从 has 方法开始:
class Set {
has(value: number): boolean {
// ...
}
}
add 方法如何?当你调用 add 时,会得到一个 Set 的实例。我们可以将其类型定义为:
class Set {
has(value: number): boolean {
*`// ...`*
}
`add``(``value``:` `number``)``:` `Set` `{`
*`// ...`*
`}`
}
到目前为止,一切都好。当我们尝试子类化 Set 时会发生什么?
class MutableSet extends Set {
delete(value: number): boolean {
// ...
}
}
当然,Set的add方法仍然返回一个Set,我们需要为我们的子类MutableSet覆盖它:
class MutableSet extends Set {
delete(value: number): boolean {
// ...
}
`add``(``value``:` `number``)``:` `MutableSet` `{`
`// ... ` `}`
}
当你与扩展其他类的类一起工作时,这可能会变得有点乏味——你必须为每个返回this的方法覆盖签名。如果你最终不得不为了满足类型检查器而覆盖每个方法,那么从基类继承有什么意义呢?
相反,你可以使用this作为返回类型注释,让 TypeScript 为你完成这项工作:
class Set {
has(value: number): boolean {
// ...
}
add(value: number): `this` {
// ...
}
}
现在,你可以从MutableSet中删除add的覆盖,因为Set中的this指向一个Set实例,而MutableSet中的this指向一个MutableSet实例:
class MutableSet extends Set {
delete(value: number): boolean {
// ...
}
}
这对于与链式 API(如我们在“构建器模式”中所做的)一起工作非常方便。
接口
当你使用类时,你经常会发现自己使用接口。
像类型别名一样,接口是一种命名类型的方法,这样你就不必在内联中定义它。类型别名和接口在大多数情况下是同一件事的两种语法(就像函数表达式和函数声明一样),但它们有一些小差异。让我们从它们的共同点开始。考虑以下类型别名:
type Sushi = {
calories: number
salty: boolean
tasty: boolean
}
将它重写为接口是很容易的:
`interface` `Sushi` {
calories: number
salty: boolean
tasty: boolean
}
在你使用Sushi类型别名的任何地方,你也可以使用Sushi接口。这两个声明都定义了形状,并且这些形状可以互相赋值(实际上它们是相同的!)。
当你开始组合类型时,事情变得更加有趣。让我们模拟除了Sushi之外的另一种食物:
type Cake = {
calories: number
sweet: boolean
tasty: boolean
}
很多食物都有卡路里,而且味道不错——不只是Sushi和Cake。让我们将Food提取到它自己的类型中,并根据它重新定义我们的食物:
type Food = {
calories: number
tasty: boolean
}
type Sushi = `Food` `&` {
salty: boolean
}
type Cake = `Food` `&` {
sweet: boolean
}
几乎等效地,你也可以用接口做到这一点:
interface Food {
calories: number
tasty: boolean
}
interface Sushi `extends` `Food` {
salty: boolean
}
interface Cake `extends` `Food` {
sweet: boolean
}
注意
接口不必扩展其他接口。事实上,一个接口可以扩展任何形状:一个对象type,一个class或另一个interface。
类型和接口之间有哪些区别?有三个,而且它们是微妙的。
首先,类型别名更通用,因为它们的右侧可以是任何类型,包括类型表达式(一种类型,可能还包括一些类型操作符如&或|);对于接口来说,右侧必须是一个形状。例如,无法将以下类型别名重写为接口:
type A = number
type B = A | string
第二个区别是,当你扩展一个接口时,TypeScript 将确保你扩展的接口可以赋值给你的扩展。例如:
interface A {
good(x: number): string
bad(x: number): string
}
interface B extends A {
good(x: string | number): string
bad(x: string): string // Error TS2430: Interface 'B' incorrectly extends
} // interface 'A'. Type 'number' is not assignable
// to type 'string'.
当你使用交集类型时不是这样的:如果你将上一个示例中的接口转换为类型别名,并将extends转换为交集(&),TypeScript 将尽力将你的扩展与它扩展的类型结合起来,导致bad的重载签名而不是编译时错误(在你的代码编辑器中试试吧!)。
当您为对象类型建模继承时,TypeScript 为接口进行的可分配性检查可以帮助您捕捉错误。
第三个区别是在相同作用域中多个名称相同的类型别名将抛出编译时错误。这是一种称为声明合并的功能。
声明合并
声明合并是 TypeScript 自动组合多个具有相同名称的声明的方式。当我们引入枚举时(“枚举”),它出现了;当处理namespace声明(参见“命名空间”)等其他功能时也会出现。在本节中,我们将简要介绍接口上下文中的声明合并。要深入了解,请转到“声明合并”。
例如,如果您声明两个名称相同的User接口,则 TypeScript 会自动将它们合并为单个接口:
// User has a single field, name
interface User {
name: string
}
// User now has two fields, name and age
interface User {
age: number
}
let a: User = {
name: 'Ashley',
age: 30
}
如果您使用类型别名重复该示例的情况如下:
type User = { // Error TS2300: Duplicate identifier 'User'.
name: string
}
type User = { // Error TS2300: Duplicate identifier 'User'.
age: number
}
请注意,这两个接口不能冲突;如果一个类型将property声明为T,而另一个类型将其声明为U,并且T和U不相同,则会出现错误:
interface User {
age: string
}
interface User {
age: number // Error TS2717: Subsequent property declarations must have
} // the same type. Property 'age' must be of type 'string',
// but here has type 'number'.
如果您的接口声明泛型(请跳到“多态性”以了解更多),那些泛型必须以完全相同的方式声明两个接口才能合并 - 直到泛型的名称为止!
interface User<Age extends number> { // Error TS2428: All declarations of 'User'
age: Age // must have identical type parameters.
}
interface User<Age extends string> {
age: Age
}
有趣的是,这是 TypeScript 检查两种类型不仅可分配,而且相同的罕见场所。
实现
当您声明类时,可以使用implements关键字表示它满足特定接口。与其他显式类型注释一样,这是一种方便的方式,可以在接口实现不正确的情况下将类型级约束添加到实现本身,以便在下游显示不清楚为什么会引发错误。这也是实现常见设计模式(如适配器、工厂和策略)的熟悉方式(有关一些示例,请参阅本章末尾)。
看起来是这样的:
interface Animal {
eat(food: string): void
sleep(hours: number): void
}
class Cat implements Animal {
eat(food: string) {
console.info('Ate some', food, '. Mmm!')
}
sleep(hours: number) {
console.info('Slept for', hours, 'hours')
}
}
Cat必须实现Animal声明的每个方法,并且可以在其上实现更多方法和属性。
接口可以声明实例属性,但不能声明可见性修饰符(private、protected和public),也不能使用static关键字。您还可以像在对象(在第三章)中一样为实例属性标记为readonly:
interface Animal {
`readonly` `name``:` `string`
eat(food: string): void
sleep(hours: number): void
}
您不限于实现一个接口,您可以实现尽可能多的接口:
interface Animal {
readonly name: string
eat(food: string): void
sleep(hours: number): void
}
`interface` `Feline` `{`
`meow``(``)``:` `void`
`}`
class Cat implements Animal`,` `Feline` {
name = 'Whiskers'
eat(food: string) {
console.info('Ate some', food, '. Mmm!')
}
sleep(hours: number) {
console.info('Slept for', hours, 'hours')
}
`meow() {`
`console``.``info``(``'Meow'``)`
`}`
}
所有这些功能完全安全。如果您忘记实现方法或属性,或者实现不正确,TypeScript 将来拯救您(参见图 5-3)。

图 5-3. TypeScript 在你忘记实现必需方法时会抛出错误。
实现接口与扩展抽象类的区别
实现接口与扩展抽象类非常相似。不同之处在于接口更通用和轻量级,而抽象类则更专用和功能丰富。
接口是一种建模形状的方式。在值级别上,这意味着对象、数组、函数、类或类实例。接口不生成 JavaScript 代码,只存在于编译时。
抽象类只能模拟类。它生成运行时代码,你猜对了,是 JavaScript 类。抽象类可以有构造函数,提供默认实现,并设置属性和方法的访问修饰符。接口则不能做这些事情。
你使用哪个取决于你的用例。当一个实现被多个类共享时,请使用抽象类。当你需要一种轻量级方式来表达“这个类是T”时,请使用接口。
类是结构化类型化的
像 TypeScript 中的其他类型一样,TypeScript 通过它们的结构而不是它们的名称来比较类。类与任何其他共享相同形状的类型兼容,包括定义与类相同属性或方法的常规对象。对于从 C#、Java、Scala 和大多数其他按名义类型化类的语言中来的人来说,这一点很重要。这意味着如果你有一个接受Zebra的函数,并给它一个Poodle,TypeScript 可能不会介意:
class Zebra {
trot() {
// ...
}
}
class Poodle {
trot() {
// ...
}
}
function ambleAround(animal: Zebra) {
animal.trot()
}
let zebra = new Zebra
let poodle = new Poodle
ambleAround(zebra) // OK
ambleAround(poodle) // OK
正如你们中的系统发生学家所知,斑马不是贵宾犬,但 TypeScript 并不介意!只要Poodle可分配给Zebra,TypeScript 就没问题,因为从我们函数的角度来看,这两者是可以互换的;重要的是它们都实现了.trot方法。如果你使用的是几乎任何其他按名义类型化类的语言,此代码将引发错误;但 TypeScript 是完全结构化类型化的,所以此代码是完全可接受的。
此规则的例外是带有private或protected字段的类:在检查形状是否可分配给类时,如果类具有任何private或protected字段,并且形状不是该类或其子类的实例,则形状不能分配给类:
class A {
private x = 1
}
class B extends A {}
function f(a: A) {}
f(new A) // OK
f(new B) // OK
f({x: 1}) // Error TS2345: Argument of type '{x: number}' is not
// assignable to parameter of type 'A'. Property 'x' is
// private in type 'A' but not in type '{x: number}'.
类声明值和类型两者:
在 TypeScript 中你可以表达的大多数事物要么是值,要么是类型:
// values
let a = 1999
function b() {}
// types
type a = number
interface b {
(): void
}
在 TypeScript 中,类型和值是分开命名空间的。根据你如何使用术语(例如本例中的a或b),TypeScript 知道是否将其解析为类型还是值:
// ...
if (a + 1 > 3) //... // TypeScript infers from context that you mean the value a
let x: a = 3 // TypeScript infers from context that you mean the type a
这种上下文术语解析真的很好,让我们能够做一些很酷的事情,比如实现伴生类型(见“伴生对象模式”)。
类和枚举是特殊的。它们是独特的,因为它们在类型命名空间和值命名空间都生成了一个类型和一个值:
class C {}
let c: C 
= new C 
enum E {F, G}
let e: E 
= E.F 
在这个上下文中,C 指的是我们的 C 类的实例类型。
在这个上下文中,C 指的是值 C。
在这个上下文中,E 指的是我们的 E 枚举类型。
在这个上下文中,E 指的是值 E。
当我们使用类时,我们需要一种方式来表达“这个变量应该是这个类的实例”,枚举类型也是如此(“这个变量应该是这个枚举的成员”)。因为类和枚举在类型级别生成类型,我们能够轻松地表达这种“is-a”关系。^(2)
我们还需要一种方法在运行时表示类,以便我们可以使用 new 实例化它,调用静态方法,使用元编程,并使用 instanceof 操作它——因此类还需要生成一个值。
在上一个示例中,C 指的是类 C 的实例。那么如何讨论 C 类本身?我们使用 typeof 关键字(TypeScript 提供的类型运算符,类似于 JavaScript 的值级 typeof,但用于类型)。
让我们创建一个 StringDatabase 类——这是世界上最简单的数据库:
type State = {
[key: string]: string
}
class StringDatabase {
state: State = {}
get(key: string): string | null {
return key in this.state ? this.state[key] : null
}
set(key: string, value: string): void {
this.state[key] = value
}
static from(state: State) {
let db = new StringDatabase
for (let key in state) {
db.set(key, state[key])
}
return db
}
}
这个类声明生成了哪些类型?实例类型 StringDatabase:
interface StringDatabase {
state: State
get(key: string): string | null
set(key: string, value: string): void
}
而构造函数类型 typeof StringDatabase:
interface StringDatabaseConstructor {
new(): StringDatabase
from(state: State): StringDatabase
}
即,StringDatabaseConstructor 有一个单一的方法 .from,并且通过 new 构造函数创建一个 StringDatabase 实例。结合起来,这两个接口模拟了类的构造函数和实例两个方面。
new() 这一部分称为 构造函数签名,是 TypeScript 表示给定类型可以使用 new 操作符实例化的方式。由于 TypeScript 是结构化类型的,这是我们描述类的最佳方式:一个类是任何可以使用 new 实例化的东西。
在这种情况下,构造函数不接受任何参数,但是您也可以使用它来声明接受参数的构造函数。例如,假设我们更新 StringDatabase 来接受一个可选的初始状态:
class StringDatabase {
constructor(public state: State = {}) {}
// ...
}
然后我们可以将 StringDatabase 的构造函数签名类型化为:
interface StringDatabaseConstructor {
new(state?: State): StringDatabase
from(state: State): StringDatabase
}
因此,类声明不仅在值和类型层面生成术语,还在类型层面生成了两个术语:一个表示类的实例;一个表示类构造函数本身(可以使用 typeof 类型操作符访问)。
多态性
类和接口像函数和类型一样,对泛型类型参数具有丰富的支持,包括默认值和边界。您可以将泛型范围限制到整个类或接口,或者到特定的方法:
class MyMap<K, V> { 
constructor(initialKey: K, initialValue: V) { 
// ...
}
get(key: K): V { 
// ...
}
set(key: K, value: V): void {
// ...
}
merge<K1, V1>(map: MyMap<K1, V1>): MyMap<K | K1, V | V1> { 
// ...
}
static of<K, V>(k: K, v: V): MyMap<K, V> { 
// ...
}
}
在你声明你的 class 时绑定类作用域的泛型类型。这里,K 和 V 可用于 MyMap 上的每个实例方法和实例属性。
注意,你不能在 constructor 中声明泛型类型。相反,将声明提升到你的 class 声明中。
在你的类内部任何地方使用类作用域的泛型类型。
实例方法可以访问类级别的泛型,并且还可以在顶部声明它们自己的泛型。.merge 使用了类级别的泛型 K 和 V,并且还声明了它自己的两个泛型,K1 和 V1。
静态方法不可以访问它们类的泛型,就像在值级别上它们不能访问它们类的实例变量一样。of 不能访问在 中声明的
K 和 V;相反,它声明了它自己的 K 和 V 泛型。
你也可以将泛型绑定到接口上:
interface MyMap<K, V> {
get(key: K): V
set(key: K, value: V): void
}
就像函数一样,你可以显式地将具体类型绑定到泛型上,或者让 TypeScript 自动推断类型:
let a = new MyMap<string, number>('k', 1) // MyMap<string, number>
let b = new MyMap('k', true) // MyMap<string, boolean>
a.get('k')
b.set('k', false)
Mixins
JavaScript 和 TypeScript 没有 trait 或 mixin 关键字,但是我们可以自己实现它们。两者都是模拟“多继承”(一个类可以继承多个其他类)和“角色导向编程”的方式,一种编程风格,你不会说“这个东西是一个 Shape”,而是描述一个东西的属性,比如“它可以被测量”或“它有四个边”。而不是“是一个”的关系,你描述“能”和“有一个”的关系。
让我们来实现一个 mixin。
Mixins 是一种模式,允许我们将行为和属性“混入”到一个类中。按照惯例,mixins:
-
可以有状态(即实例属性)
-
只能提供具体方法(而不是抽象方法)
-
可以有构造函数,它们按照它们被混合进来的类的顺序调用
TypeScript 没有内置的 mixin 概念,但是我们可以自己实现它们。例如,让我们为 TypeScript 类设计一个调试库。我们将其称为 EZDebug。该库通过让你在运行时记录使用该库的任何类的信息来工作,以便你可以检查它们。我们可以这样使用它:
class User {
// ...
}
User.debug() // evaluates to 'User({"id": 3, "name": "Emma Gluzman"})'
有了标准的 .debug 接口,我们的用户将能够调试任何东西!让我们来构建它。我们将使用一个 mixin 来建模它,我们称之为 withEZDebug。一个 mixin 只是一个接受一个类构造函数并返回一个类构造函数的函数,因此我们的 mixin 可能看起来像这样:
type ClassConstructor = new(...args: any[]) => {} 
function withEZDebug<C extends ClassConstructor>(Class: C) { 
return class extends Class { 
constructor(...args: any[]) { 
super(...args) 
}
}
}
我们首先声明一个类型ClassConstructor,它代表任何构造函数。由于 TypeScript 是完全结构化类型的,我们说一个构造函数是可以new的任何东西。我们不知道构造函数可能具有什么类型的参数,因此我们说它接受任意数量的任何类型的参数。^(3)
我们声明我们的withEZDebug混入一个类型参数C。C至少必须是一个类构造函数,我们通过一个extends子句来强制执行这一点。我们让 TypeScript 推断withEZDebug的返回类型,这是C和我们的新匿名类的交集。
由于混入是一个接受构造函数并返回构造函数的函数,我们返回一个匿名类构造函数。
类构造函数必须至少接受类可能接受的参数。但请记住,由于我们事先不知道可能传入的类是什么,我必须保持尽可能一般化,这意味着任意数量的任何类型的参数——就像ClassConstructor一样。
最后,由于这个匿名类扩展了另一个类,为了正确地连接一切,我们需要记住调用Class的构造函数。
就像常规 JavaScript 类一样,如果在constructor中没有更多的逻辑,你可以省略和
行。对于这个
withEZDebug示例,我们不会在构造函数中放置任何逻辑,所以可以省略它们。
现在我们已经设置好样板,是时候进行一些调试魔术了。当我们调用.debug时,我们希望记录出类的构造函数名称和实例的值:
type ClassConstructor = new(...args: any[]) => {}
function withEZDebug<C extends ClassConstructor>(Class: C) {
return class extends Class {
`debug() {`
`let` `Name` `=` `Class``.``constructor``.``name`
`let` `value` `=` `this``.``getDebugValue``(``)`
`return` `Name` `+` `'('` `+` `JSON``.``stringify``(``value``)` `+` `')'`
`}`
}
}
但是!我们如何确保类实现了.getDebugValue方法,以便我们可以调用它?在继续之前,请花点时间考虑一下——你能想出来吗?
答案是,我们不接受任何旧类,而是使用泛型类型确保传递给withEZDebug的类定义了.getDebugValue方法:
type ClassConstructor`<``T``>` = new(...args: any[]) => `T` 
function withEZDebug<C extends ClassConstructor`<``{`
`getDebugValue``(``)``:` `object` 
`}``>`>(Class: C) {
*`// ...`*
}
我们向ClassConstructor添加了一个泛型类型参数。
我们将一个形状类型绑定到ClassConstructor,C,强制执行我们传递给withEZDebug的构造函数至少定义.getDebugValue方法。
就这样!那么,你如何使用这个令人难以置信的调试工具呢?像这样:
class HardToDebugUser {
constructor(
private id: number,
private firstName: string,
private lastName: string
) {}
getDebugValue() {
return {
id: this.id,
name: this.firstName + ' ' + this.lastName
}
}
}
let User = withEZDebug(HardToDebugUser)
let user = new User(3, 'Emma', 'Gluzman')
user.debug() // evaluates to 'User({"id": 3, "name": "Emma Gluzman"})'
酷,对吧?你可以将尽可能多的混入应用于一个类,从而产生行为更丰富的类,所有这些都是类型安全的。混入有助于封装行为,是指定可重用行为的一种表达方式。^(4)
装饰器
装饰器是 TypeScript 的一项实验性功能,它为类、类方法、属性和方法参数的元编程提供了清晰的语法。它们只是在你装饰的内容上调用函数的语法而已。
TSC Flag: experimentalDecorators
因为它们仍处于实验阶段——这意味着它们可能会在未来的 TypeScript 版本中以不兼容的方式更改,甚至可能会完全移除——装饰器被隐藏在了一个 TSC 标志后面。如果你可以接受这一点,并且想要尝试这个功能,请在你的 tsconfig.json 中设置 "experimentalDecorators": true,然后继续阅读。
为了了解装饰器的工作原理,让我们从一个例子开始:
@serializable
class APIPayload {
getValue(): Payload {
// ...
}
}
类装饰器@serializable包装我们的APIPayload类,并可选择返回替代它的新类。没有装饰器的话,你可能需要用以下方式来实现同样的功能:
let APIPayload = serializable(class APIPayload {
getValue(): Payload {
// ...
}
})
对于每种类型的装饰器,TypeScript 要求在作用域中具有指定名称和所需签名的函数(见表 5-1)。
表 5-1. 不同类型装饰器函数的预期类型签名
| 正在装饰的内容 | 预期的类型签名 |
|---|---|
| 类 | (Constructor: {new(...any[]) => any}) => any |
| 方法 | (classPrototype: {}, methodName: string, descriptor: PropertyDescriptor) => any |
| 静态方法 | (Constructor: {new(...any[]) => any}, methodName: string, descriptor: PropertyDescriptor) => any |
| 方法参数 | (classPrototype: {}, paramName: string, index: number) => void |
| 静态方法参数 | (Constructor: {new(...any[]) => any}, paramName: string, index: number) => void |
| 属性 | (classPrototype: {}, propertyName: string) => any |
| 静态属性 | (Constructor: {new(...any[]) => any}, propertyName: string) => any |
| 属性的 getter/setter | (classPrototype: {}, propertyName: string, descriptor: PropertyDescriptor) => any |
| 静态属性的 getter/setter | (Constructor: {new(...any[]) => any}, propertyName: string, descriptor: PropertyDescriptor) => any |
TypeScript 并没有任何内置的装饰器:无论使用什么装饰器,都需要自己实现(或从 NPM 安装)。对于每种装饰器类型(类、方法、属性和函数参数),其实现都是一个满足特定签名的常规函数。例如,我们的 @serializable 装饰器可能看起来像这样:
type ClassConstructor<T> = new(...args: any[]) => T 
function serializable<
T extends ClassConstructor<{
getValue(): Payload 
}>
>(Constructor: T) { 
return class extends Constructor { 
serialize() {
return this.getValue().toString()
}
}
}
记住,在 TypeScript 中,new() 是我们对类构造函数进行结构化类型的方式。对于可以被扩展(使用extends)的类构造函数,TypeScript 要求我们用any展开来对其参数进行类型化:new(...any[])。
@serializable 可以装饰任何其实例实现了 .getValue 方法且返回 Payload 的类。
类装饰器是一个接受单个参数——类的函数。如果装饰器函数返回一个类(如示例中所示),它将在运行时替换它所装饰的类;否则,它将返回原始类。
要装饰类,我们返回一个扩展它并添加 .serialize 方法的类。
当我们尝试调用 .serialize 时会发生什么?
let payload = new APIPayload
let serialized = payload.serialize() // Error TS2339: Property 'serialize' does
// not exist on type 'APIPayload'.
TypeScript 假设装饰器不会改变其装饰对象的形状——这意味着你没有添加或移除方法和属性。它在编译时检查你返回的类是否可以分配给传入的类,但目前 TypeScript 不会跟踪你在装饰器中所做的扩展。
在 TypeScript 的装饰器成为更成熟的功能之前,建议避免使用它们,而是坚持使用普通函数:
let DecoratedAPIPayload = serializable(APIPayload)
let payload = new DecoratedAPIPayload
payload.serialize() // string
我们不会在本书中深入探讨装饰器。更多信息请访问官方文档。
模拟 final 类
虽然 TypeScript 不支持类或方法的 final 关键字,但可以轻松地为类模拟它。如果你之前没有接触过面向对象语言,final 是一些语言用来标记类为不可扩展或方法为不可覆盖的关键字。
要模拟 TypeScript 中的 final 类,我们可以利用私有构造函数:
class MessageQueue {
private constructor(private messages: string[]) {}
}
当 constructor 被标记为 private 时,你不能 new 这个类或者扩展它:
class BadQueue extends MessageQueue {} // Error TS2675: Cannot extend a class
// 'MessageQueue'. Class constructor is
// marked as private.
new MessageQueue([]) // Error TS2673: Constructor of class
// 'MessageQueue' is private and only
// accessible within the class
// declaration.
除了阻止你扩展类之外,私有构造函数还阻止你直接实例化它。但是对于 final 类,我们希望能够实例化类,只是不允许扩展它。我们如何保留第一个限制但消除第二个限制呢?很简单:
class MessageQueue {
private constructor(private messages: string[]) {}
`static` `create``(``messages``:` `string``[``]``)` `{`
`return` `new` `MessageQueue``(``messages``)`
`}`
}
这改变了 MessageQueue 的 API 一点,但在编译时很好地防止扩展:
class BadQueue extends MessageQueue {} // Error TS2675: Cannot extend a class
// 'MessageQueue'. Class constructor is
// marked as private.
MessageQueue.create([]) // MessageQueue
设计模式
如果我们在 TypeScript 中没有实现一两个设计模式,这将不会是一个面向对象编程的章节,对吧?
工厂模式
工厂模式 是一种创建某种类型对象的方式,将具体对象的创建决策留给创建该对象的特定工厂。
让我们建立一个鞋厂。我们首先定义一个Shoe类型,和几种鞋子:
type Shoe = {
purpose: string
}
class BalletFlat implements Shoe {
purpose = 'dancing'
}
class Boot implements Shoe {
purpose = 'woodcutting'
}
class Sneaker implements Shoe {
purpose = 'walking'
}
注意,此示例使用了 type,但我们也可以使用 interface。
现在,让我们来制造一个鞋厂:
let Shoe = {
create(type: 'balletFlat' | 'boot' | 'sneaker'): Shoe { 
switch (type) { 
case 'balletFlat': return new BalletFlat
case 'boot': return new Boot
case 'sneaker': return new Sneaker
}
}
}
使用联合类型来定义 type 可以尽可能地使 .create 类型安全,防止在编译时传入无效的 type。
切换 type 让 TypeScript 能够轻松强制我们处理每种 Shoe 的类型。
在这个示例中,我们使用伴生对象模式(参见 “伴生对象模式”)来声明类型Shoe和具有相同名称的值Shoe(请记住 TypeScript 为值和类型分别提供了独立的命名空间),作为一种信号表明该值提供了操作该类型的方法。要使用工厂,我们只需调用.create:
Shoe.create('boot') // Shoe
大功告成!我们有了一个工厂模式。请注意,我们还可以进一步指示Shoe.create的类型签名,传入'boot'将返回一个Boot,传入'sneaker'将返回一个Sneaker等等,但这将打破工厂模式给我们的抽象(消费者不应知道他们将得到什么具体的类,只知道这个类满足特定的接口)。
建造者模式
建造者模式(builder pattern)是一种将对象的构建过程与其实际实现方式分离的方法。如果你使用过 JQuery 或者 ES6 中的数据结构如Map和Set,这种 API 风格应该很熟悉。它看起来像这样:
new RequestBuilder()
.setURL('/users')
.setMethod('get')
.setData({firstName: 'Anna'})
.send()
如何实现RequestBuilder?很简单——我们从一个空的类开始:
class RequestBuilder {}
首先,我们将添加.setURL方法:
class RequestBuilder {
`private` `url``:` `string` `|` `null` `=` `null` 
`setURL``(``url``:` `string``)``:` `this` `{` 
`this``.``url` `=` `url`
`return` `this`
`}`
}
我们使用一个私有实例变量url来跟踪用户设置的 URL,初始值为null。
setURL方法的返回类型是this(参见 “将 this 用作返回类型”),即用户在setURL上调用的具体RequestBuilder实例。
现在让我们添加示例中的其他方法:
class RequestBuilder {
`private` `data``:` `object` `|` `null` `=` `null`
`private` `method``:` `'get'` `|` `'post'` `|` `null` `=` `null`
private url: string | null = null
`setMethod``(``method``:` `'get'` `|` `'post'``)``:` `this` `{`
`this``.``method` `=` `method`
`return` `this`
`}`
`setData``(``data``:` `object``)``:` `this` `{`
`this``.``data` `=` `data`
`return` `this`
`}`
setURL(url: string): this {
this.url = url
return this
}
`send() {`
`// ... ` `}`
}
就是这样了。
注意
这种传统的构建者设计并不完全安全:我们可以在设置方法、URL 或数据之前调用.send,导致运行时异常(记住,那是糟糕的异常)。参见练习 4,了解如何改进这种设计。
总结
现在,我们全面探索了 TypeScript 类的各个方面:如何声明类;如何从类继承和实现接口;如何将类标记为abstract以防止实例化;如何使用static在类上添加字段或方法,以及在实例上不使用它;如何使用private、protected和public可见性修饰符来控制对字段或方法的访问;如何使用readonly修饰符将字段标记为只读。我们讨论了如何安全地使用this和super,探讨了类同时作为值和类型的含义,以及类型别名和接口之间的差异,声明合并的基础知识,以及如何在类中使用泛型类型。最后,我们还涵盖了一些更高级的类模式:混入(mixins)、装饰器(decorators)以及模拟final类。为了结束本章,我们总结了几种常见的处理类的模式。
练习
-
类和接口之间有哪些区别?
-
当你将一个类的构造函数标记为
private时,这意味着你不能实例化或扩展这个类。如果你将它标记为protected会发生什么?在你的代码编辑器中尝试一下,并看看你能否弄清楚。 -
扩展我们开发的 “工厂模式” 实现,使其更安全,牺牲一些抽象性。更新实现,以便消费者在编译时知道调用
Shoe.create('boot')返回一个Boot,调用Shoe.create('balletFlat')返回一个BalletFlat(而不是两者都返回一个Shoe)。提示:回想一下 “重载函数类型”。 -
[困难] 作为一个练习,想一想你如何设计一个类型安全的建造者模式。扩展 “建造者模式” 以:
-
在编译时保证在设置至少一个 URL 和一个方法之前,某人不能调用
.send。如果你还强制用户按特定顺序调用方法,会更容易实现这个保证吗?(提示:你可以返回什么代替this?) -
[困难] 如果你想要做出这个保证,同时又让人们可以按任意顺序调用方法,你会如何修改你的设计?(提示:你可以使用 TypeScript 的哪个特性,使得每个方法的返回类型在每次方法调用后“添加”到
this类型?)
-
^(1) 或者很快将由 JavaScript 类支持。
^(2) 因为 TypeScript 是结构化类型的,所以对于类的关系更像是“看起来像”—任何实现与你的类相同形状的对象都可以赋值给你的类的类型。
^(3) 注意 TypeScript 在这里很挑剔:构造函数类型的参数类型必须是 any[](而不是 void、unknown[] 等),以便我们能够扩展它。
^(4) 少数语言—Scala、PHP、Kotlin 和 Rust 等—实现了一个精简版的混入,称为 traits。Traits 类似于混入,但没有构造函数,也不支持实例属性。这样更容易将它们连接起来,防止多个 trait 访问它们与基类之间共享的状态时发生冲突。
第六章:高级类型
TypeScript 拥有一个世界级的类型系统,支持强大的类型级编程特性,甚至可能让最顽固的 Haskell 程序员也会嫉妒。正如你现在所知道的,该类型系统不仅极具表现力,而且易于使用,使得声明类型约束和关系变得简单、简洁,并且大多数情况下都能被推断出来。
我们需要这样一个富有表现力且不寻常的类型系统,因为 JavaScript 是如此动态。建模原型、动态绑定的 this、函数重载和始终变化的对象等内容需要一个丰富的类型系统和一整套类型操作符,这些让蝙蝠侠都会瞪大眼睛。
我将以深入探讨 TypeScript 中的子类型、可赋值性、变异和扩展开始本章,更详细地介绍 TypeScript 基于控制流的类型检查特性,包括细化和全面性,并继续介绍一些高级的类型级编程特性:对象类型的键入和映射、使用条件类型、定义自己的类型保护以及像类型断言和确定赋值断言这样的逃逸口。最后,我将介绍一些高级模式,以提高类型的安全性:伴随对象模式、改进元组类型的推断、模拟名义类型以及安全地扩展原型。
类型之间的关系
让我们从更近距离来看 TypeScript 中的类型关系。
子类型和超类型
我们在“谈论类型”中稍微提到了可赋值性。既然你已经见识了 TypeScript 提供的大部分类型,我们可以更深入地探讨,从顶层开始:什么是子类型?

图 6-1. B 是 A 的子类型
如果你回头看第 3-1 图,你会看到 TypeScript 内建的子类型关系。例如:
-
数组是对象的子类型。
-
元组是数组的子类型。
-
一切都是
any的子类型。 -
never是一切的子类型。 -
如果你有一个扩展
Animal的类Bird,那么Bird是Animal的子类型。
从我刚给出的子类型的定义来看,这意味着:
-
你需要对象的地方也可以使用数组。
-
你需要数组的任何地方也可以使用元组。
-
你需要
any的地方也可以使用对象。 -
你可以在任何地方使用
never。 -
你需要
Animal的地方也可以使用Bird。
正如你所猜测的那样,超类型是子类型的反义词。

图 6-2. B 是 A 的超类型
再次来自图 3-1 中的流程图:
-
数组是元组的超类型。
-
对象是数组的超类型。
-
Any是一切的超类型。 -
never是一无所有的超类型。 -
Animal是Bird的超类型。
这只是子类型工作的反向操作,没有更多内容。
变异性
对于大多数类型来说,直觉上判断某个类型A是否是另一个类型B的子类型是相当容易的。对于诸如number、string等简单类型,你可以在图 3-1 中查找,或通过推理(“number包含在联合类型number | string中,因此它必须是它的子类型”)。
但对于参数化(泛型)类型和其他更复杂的类型,情况就变得更加复杂了。考虑这些情况:
-
Array<A>何时是Array<B>的子类型? -
当形状
A何时是另一个形状B的子类型? -
一个函数
(a: A) => B何时是另一个函数(c: C) => D的子类型?
对于包含其他类型的类型的子类型规则(即具有类型参数的东西,如Array<A>,具有字段的形状如{a: number},或函数如(a: A) => B),推理起来更为困难,答案也不那么明确。事实上,关于这些复杂类型的子类型规则是编程语言之间差异的一个重要争议点——几乎没有两种语言是相同的!
为了使以下规则更易于阅读,我将介绍一些语法片段,让我们能更精确、更简洁地谈论类型。这种语法不是有效的 TypeScript;这只是让你和我在谈论类型时使用的一种共同语言。别担心,我保证这种语法不是数学:
-
A <: B的意思是“A是B类型的子类型或相同类型。” -
A >: B的意思是“A是B类型的超类型或相同类型。”
形状和数组的变化
要对为什么不同的语言在复杂类型的子类型规则上存在分歧有所直觉,让我通过一个例子复杂类型来带你了解一下:形状。假设您的应用程序中有一个描述用户的形状。您可能用一对看起来像这样的类型表示它:
// An existing user that we got from the server
type ExistingUser = {
id: number
name: string
}
// A new user that hasn't been saved to the server yet
type NewUser = {
name: string
}
现在假设您公司的一位实习生被要求编写一些代码来删除用户。他们从这样开始:
function deleteUser(user: {id?: number, name: string}) {
delete user.id
}
let existingUser: ExistingUser = {
id: 123456,
name: 'Ima User'
}
deleteUser(existingUser)
deleteUser接受一个类型为{id?: number, name: string}的对象,并且传递了一个类型为{id: number, name: string}的existingUser。请注意,id属性的类型(number)是预期类型(number | undefined)的子类型。因此整个对象{id: number, name: string}是{id?: number, name: string}的子类型,因此 TypeScript 允许它通过。
你能看到这里的安全问题吗?这是一个微妙的问题:在将ExistingUser传递给deleteUser后,TypeScript 不知道用户的id已经被删除了,所以如果我们在用deleteUser(existingUser)删除后仍然读取existingUser.id,TypeScript 仍然认为existingUser.id的类型是number!
显然,在某个期望其超类型的位置使用对象类型可能是不安全的。那么为什么 TypeScript 允许呢?一般来说,TypeScript 并不是设计为完全安全的;相反,它的类型系统试图在捕捉真正的错误和易用性之间取得平衡,而不需要你去攻读编程语言理论学位来理解为什么某些事情是错误的。这种特定的不安全情况是实用的:因为在实践中破坏性更新(比如删除属性)相对较少,TypeScript 是宽松的,允许你将对象赋给期望其超类型的位置。
那么相反的情况呢——你可以将一个对象分配给期望其子类型的位置吗?
让我们为旧用户添加一个新类型,然后删除一个属于该类型的用户(假设你要在开始使用 TypeScript 之前为同事编写的代码添加类型)。
type LegacyUser = {
id?: number | string
name: string
}
let legacyUser: LegacyUser = {
id: '793331',
name: 'Xin Yang'
}
deleteUser(legacyUser) // Error TS2345: Argument of type 'LegacyUser' is not
// assignable to parameter of type '{id?: number |
// undefined, name: string}'. Type 'string' is not
// assignable to type 'number | undefined'.
当我们传递一个具有属性类型为期望类型的超类型的形状时,TypeScript 会抱怨。这是因为 id 是 string | number | undefined,而 deleteUser 只处理 id 是 number | undefined 的情况。
TypeScript 的行为如下:如果你期望一个形状,你也可以传递具有其属性类型为 <: 其期望类型的类型,但你不能传递具有其属性类型为其期望类型的超类型的形状。当谈论类型时,我们说 TypeScript 的形状(对象和类)在其属性类型方面是协变的。也就是说,要使对象 A 能够分配给对象 B,它的每个属性必须是其在 B 中对应属性的 <:。
更一般地说,协变只是四种变性之一:
不变
你需要确切地一个 T。
协变
你需要一个 <:T。
逆变
你需要一个 >:T。
双变性
你可以接受 <:T 或者 >:T。
在 TypeScript 中,每种复杂类型在其成员(对象、类、数组和函数返回类型)方面是协变的,唯一的例外是函数参数类型,它们是逆变的。
注意
并非所有语言都做出相同的设计决策。在一些语言中,对象在其属性类型方面是不变的,因为正如我们所见,协变的属性类型可能导致不安全行为。一些语言对可变和不可变对象有不同的规则(尝试自行推理!)。一些语言——比如 Scala、Kotlin 和 Flow——甚至为程序员提供了专门的语法来为其自定义数据类型指定变性。
在设计 TypeScript 时,其作者选择在易用性和安全性之间取得平衡。当你使对象在其属性类型方面是不变的时候,即使它更安全,也会使类型系统变得繁琐,因为你最终会禁止实践中安全的事物(例如,如果我们没有在 deleteUser 中删除 id,那么将一个超类型的对象传入将是完全安全的)。
函数变性
让我们从几个例子开始。
如果函数 A 是函数 B 的子类型,那么 A 的参数数量不能多于 B 的参数数量,而且:
-
A的this类型要么未指定,要么是>: B的this类型。 -
A的每个参数都是>:它对应的B中的参数。 -
A的返回类型是<:B的返回类型。
多次阅读并确保你理解每条规则的含义。你可能已经注意到,要使函数 A 成为函数 B 的子类型,我们说它的 this 类型和参数必须是 >: 于 B 中对应的,而返回类型必须是 <:!为什么方向会这样变化?为什么不像对象、数组、联合类型等那样简单地都是 <: 呢?
要回答这个问题,让我们自己推导一下。我们将从定义三种类型开始(为了清晰起见,我们将使用 class,但这对于任何类型的选择都适用,其中 A <: B <: C):
class Animal {}
class Bird extends Animal {
chirp() {}
}
class Crow extends Bird {
caw() {}
}
在这个例子中,Crow 是 Bird 的一个子类型,而 Bird 是 Animal 的一个子类型。也就是说,Crow <: Bird <: Animal。
现在,让我们定义一个接受 Bird 并让它叫的函数:
function chirp(bird: Bird): Bird {
bird.chirp()
return bird
}
目前为止一切顺利。TypeScript 让你可以传递哪些类型的东西给 chirp?
chirp(new Animal) // Error TS2345: Argument of type 'Animal' is not assignable
chirp(new Bird) // to parameter of type 'Bird'.
chirp(new Crow)
你可以传递一个 Bird 的实例(因为 chirp 的参数 bird 的类型就是它)或者一个 Crow 的实例(因为它是 Bird 的子类型)。很好:传递子类型正常工作。
让我们创建一个新的函数。这次,它的参数将是一个函数:
function clone(f: (b: Bird) => Bird): void {
// ...
}
clone 需要一个函数 f,该函数接受一个 Bird 并返回一个 Bird。你可以安全地传递哪些类型的函数给 f?显然你可以传递一个接受 Bird 并返回 Bird 的函数:
function birdToBird(b: Bird): Bird {
// ...
}
clone(birdToBird) // OK
那么接受一个 Bird 并返回一个 Crow 或者 Animal 的函数呢?
function birdToCrow(d: Bird): Crow {
// ...
}
clone(birdToCrow) // OK
function birdToAnimal(d: Bird): Animal {
// ...
}
clone(birdToAnimal) // Error TS2345: Argument of type '(d: Bird) => Animal' is
// not assignable to parameter of type '(b: Bird) => Bird'.
// Type 'Animal' is not assignable to type 'Bird'.
birdToCrow 正常工作,但 birdToAnimal 给了我们一个错误。为什么?想象一下,如果 clone 的实现看起来像这样:
function clone(f: (b: Bird) => Bird): void {
let parent = new Bird
let babyBird = f(parent)
babyBird.chirp()
}
如果我们传递给 clone 函数一个返回 Animal 的 f,那么我们就不能在其上调用 .chirp 了!因此 TypeScript 在编译时必须确保我们传递的函数返回至少是一个 Bird。
我们说函数在其返回类型上是协变的,这是说为了一个函数能够成为另一个函数的子类型,其返回类型必须是小于等于另一个函数的返回类型。
好了,那么参数类型呢?
function animalToBird(a: Animal): Bird {
// ...
}
clone(animalToBird) // OK
function crowToBird(c: Crow): Bird {
// ...
}
clone(crowToBird) // Error TS2345: Argument of type '(c: Crow) => Bird' is not
// assignable to parameter of type '(b: Bird) => Bird'.
要使一个函数能够分配给另一个函数,它的参数类型(包括 this)都必须是大于等于另一个函数中对应参数类型的。为了理解原因,想想用户在传递 clone 之前如何实现 crowToBird。
function crowToBird(c: Crow): Bird {
c.caw()
return new Bird
}
现在如果 clone 用一个 new Bird 调用 crowToBird,我们会得到一个异常,因为.caw 只在 Crow 上定义,而不是所有的 Bird 上。
这意味着函数在其参数和 this 类型上是逆变的。也就是说,对于一个函数作为另一个函数的子类型,其每个参数及其 this 类型必须是与另一个函数中相应参数 >: 的对应参数。
幸运的是,你不必记住并背诵这些规则。当你在代码编辑器中传递错误类型的函数时,只需将它们记在脑后,这样你就知道为什么 TypeScript 给出了错误提示。
TSC 标志:strictFunctionTypes
由于遗留原因,TypeScript 中的函数默认情况下实际上是协变的,在其参数和 this 类型上。要选择我们刚刚探讨过的更安全的逆变行为,请确保在你的 tsconfig.json 中启用 {"strictFunctionTypes": true} 标志。
strict 模式包括 strictFunctionTypes,所以如果你已经使用 {"strict": true},你可以放心使用。
可赋值性
子类型和超类型关系是任何静态类型语言中的核心概念。它们对于理解可赋值性的工作方式也非常重要(作为提醒,可赋值性是指 TypeScript 中确定是否可以将类型 A 用在另一类型 B 所需位置的规则)。
当 TypeScript 想要回答“类型 A 是否可以分配给类型 B?”这个问题时,它遵循几个简单的规则。对于非枚举类型,如数组、布尔值、数字、对象、函数、类、类实例和字符串,包括文字类型,如果以下任一条件为真,则 A 可分配给 B:
-
A <: B。 -
A是any。
规则 1 只是子类型的定义:如果 A 是 B 的子类型,则无论何时需要 B,你也可以使用 A。
规则 2 是规则 1 的例外,并且是与 JavaScript 代码互操作的便利性。
对于使用 enum 或 const enum 关键字创建的枚举类型,如果以下任一条件为真,则类型 A 可分配给枚举 B:
-
A是枚举B的成员。 -
B至少有一个是number类型的成员,并且A是number。
规则 1 与简单类型的规则完全相同(如果 A 是枚举 B 的成员,则 A 的类型是 B,因此我们所说的是 B <: B)。
规则 2 是与枚举一起工作的便利性。正如我们在 “枚举” 中讨论的那样,规则 2 是 TypeScript 中不安全性的一个重要原因,这也是我建议彻底摒弃枚举的原因之一。
类型扩展
类型扩展是理解 TypeScript 类型推断工作方式的关键。总体而言,TypeScript 在推断类型时会比较宽松,通常会推断出更一般的类型而不是可能的最具体类型。这使得作为程序员的生活更轻松,意味着减少了消除类型检查器投诉的时间。
在 第三章 中,你已经看到几个类型扩展的实例。让我们看几个更多的例子。
当您以后可以更改其变异的方式声明一个变量(例如使用let或var)时,其类型会从其字面值扩展到该字面值所属的基本类型:
let a = 'x' // string
let b = 3 // number
var c = true // boolean
const d = {x: 3} // {x: number}
enum E {X, Y, Z}
let e = E.X // E
对于不可变声明,则不适用:
const a = 'x' // 'x'
const b = 3 // 3
const c = true // true
enum E {X, Y, Z}
const e = E.X // E.X
您可以使用显式类型注释防止类型扩展:
let a: 'x' = 'x' // 'x'
let b: 3 = 3 // 3
var c: true = true // true
const d: {x: 3} = {x: 3} // {x: 3}
当您使用let或var重新分配非扩展类型时,TypeScript 会为您扩展它。要告诉 TypeScript 保持狭义,将显式类型注释添加到您的原始声明中:
const a = 'x' // 'x'
let b = a // string
const c: 'x' = 'x' // 'x'
let d = c // 'x'
变量初始化为null或undefined时会被扩展为any:
let a = null // any
a = 3 // any
a = 'b' // any
但是,当变量初始化为null或undefined并离开其声明范围时,TypeScript 会为其分配一个确定的类型:
`function` `x() {`
let a = null *`// any`*
a = 3 *`// any`*
a = 'b' *`// any`*
`return` `a`
`}`
`x``(``)` *`// string`*
常量类型
TypeScript 带有一个特殊的const类型,您可以使用它来选择不对声明进行类型扩展。将其用作类型断言(请阅读“类型断言”):
let a = {x: 3} // {x: number}
let b: {x: 3} // {x: 3}
let c = {x: 3} as const // {readonly x: 3}
const选择了不对类型进行扩展,并递归地将其成员标记为readonly,即使是对于深度嵌套的数据结构也是如此:
let d = [1, {x: 2}] // (number | {x: number})[]
let e = [1, {x: 2}] as const // readonly [1, {readonly x: 2}]
当您希望 TypeScript 尽可能狭义地推断您的类型时,请使用as const。
多余属性检查
类型扩展也涉及到 TypeScript 检查一个对象类型是否可分配给另一个对象类型。
请回想“形状和数组的差异”,对象类型在其成员中是协变的。但是,如果 TypeScript 坚持这一规则而不进行任何额外的检查,可能会导致问题。
例如,考虑一个您可能传递到类中以配置它的Options对象:
type Options = {
baseURL: string
cacheSize?: number
tier?: 'prod' | 'dev'
}
class API {
constructor(private options: Options) {}
}
new API({
baseURL: 'https://api.mysite.com',
tier: 'prod'
})
现在,如果您拼写选项错误会发生什么?
new API({
baseURL: 'https://api.mysite.com',
tierr: 'prod' // Error TS2345: Argument of type '{tierr: string}'
}) // is not assignable to parameter of type 'Options'.
// Object literal may only specify known properties,
// but 'tierr' does not exist in type 'Options'.
// Did you mean to write 'tier'?
这在使用 JavaScript 时是一个常见的 bug,因此 TypeScript 帮助我们捕获它真的很有帮助。但是如果对象类型在其成员中是协变的,那么 TypeScript 是如何捕获这个问题的呢?
那就是:
-
我们预期的类型是
{baseURL: string, cacheSize?: number, tier?: 'prod' | 'dev'}。 -
我们传递了类型
{baseURL: string, tierr: string}。 -
我们传入的类型是我们预期类型的子类型,但不知何故,TypeScript 知道报告错误。
TypeScript 之所以能够捕捉到这一点,是因为它的多余属性检查,工作原理如下:当您尝试将一个新的对象字面类型T分配给另一个类型U时,而T具有U中不存在的属性时,TypeScript 会报告错误。
新的对象字面类型是 TypeScript 从对象字面值中推断出的类型。如果该对象字面量使用类型断言(参见“类型断言”)或分配给变量,则新的对象字面类型将被扩展为常规对象类型,其新鲜度将消失。
此定义内容丰富,让我们再次以示例进行详细解释,这次试着换几个不同的变体:
type Options = {
baseURL: string
cacheSize?: number
tier?: 'prod' | 'dev'
}
class API {
constructor(private options: Options) {}
}
new API({ 
baseURL: 'https://api.mysite.com',
tier: 'prod'
})
new API({ 
baseURL: 'https://api.mysite.com',
badTier: 'prod' // Error TS2345: Argument of type '{baseURL: string; badTier: }) // string}' is not assignable to parameter of type 'Options'.
new API({ 
baseURL: 'https://api.mysite.com',
badTier: 'prod'
} as Options)
let badOptions = { 
baseURL: 'https://api.mysite.com',
badTier: 'prod'
}
new API(badOptions)
let options: Options = { 
baseURL: 'https://api.mysite.com',
badTier: 'prod' // Error TS2322: Type '{baseURL: string; badTier: string}' } // is not assignable to type 'Options'. new API(options)
我们使用baseURL和我们的两个可选属性之一,tier,来实例化API。这符合预期。
在这里,我们将tier错误拼写为badTier。我们传递给new API的选项对象是新鲜的(因为其类型被推断,没有分配给变量,并且我们没有对其类型进行断言),因此 TypeScript 在其上运行多余属性检查,揭示了多余的badTier属性(在我们的选项对象中定义,但不在Options类型中)。
我们断言我们的无效选项对象是Options类型。TypeScript 不再将其视为新鲜的,并退出多余属性检查:无错误。如果你对as T语法不熟悉,请继续阅读“类型断言”。
我们将选项对象分配给变量badOptions。TypeScript 不再将其视为新鲜的,并退出多余属性检查:无错误。
当我们明确将options类型声明为Options时,我们分配给options的对象是新鲜的,因此 TypeScript 执行多余属性检查,捕获了我们的 bug。请注意,在这种情况下,多余属性检查不会发生在我们将options传递给new API时,而是在我们尝试将选项对象分配给变量options时发生。
别担心 —— 你不需要记住这些规则。它们是 TypeScript 捕捉尽可能多 bug 的内部启发式规则,以便不会成为程序员负担的实用方式,只需在疑惑 TypeScript 为何要抱怨那一个 bug 时记在心中,即使是你们公司代码库的老兵伊凡,也未必察觉得到。
精化
TypeScript 执行基于流的类型推断,这是一种符号执行,其中类型检查器使用控制流语句如if、?、||和switch,以及类型查询如typeof、instanceof和in,在进行过程中精化类型,就像程序员阅读代码一样。^(1) 这是一项对类型检查器非常方便的功能,但令人惊讶的是,非常少的语言支持这种功能。^(2)
让我们举个例子。假设我们已经在 TypeScript 中构建了一个用于定义 CSS 规则的 API,一位同事想要用它来设置 HTML 元素的width。他们传入了宽度,然后我们想要解析和验证它。
首先,我们将实现一个函数,将 CSS 字符串解析为值和单位:
// We use a union of string literals to describe
// the possible values a CSS unit can have
type Unit = 'cm' | 'px' | '%'
// Enumerate the units
let units: Unit[] = ['cm', 'px', '%']
// Check each unit, and return null if there is no match
function parseUnit(value: string): Unit | null {
for (let i = 0; i < units.length; i++) {
if (value.endsWith(units[i])) {
return units[i]
}
}
return null
}
然后,我们可以使用parseUnit来解析用户传递给我们的宽度值。width可能是一个数字(我们假设是像素),或者一个附带单位的字符串,或者可能是null或undefined。
在这个示例中,我们几次利用了类型精化:
type Width = {
unit: Unit,
value: number
}
function parseWidth(width: number | string | null | undefined): Width | null {
// If width is null or undefined, return early
if (width == null) { 
return null
}
// If width is a number, default to pixels
if (typeof width === 'number') { 
return {unit: 'px', value: width}
}
// Try to parse a unit from width
let unit = parseUnit(width)
if (unit) { 
return {unit, value: parseFloat(width)}
}
// Otherwise, return null
return null 
}
TypeScript 足够聪明,能够知道在 JavaScript 中,对 null 进行宽松相等性检查将返回 true,对 undefined 也是如此。它知道如果此检查通过,则我们将返回,并且如果没有返回,则意味着检查未通过,因此从那时起,width 的类型就是 number | string(它不再可能是 null 或 undefined)。我们称这种类型从 number | string | null | undefined 细化为 number | string。
typeof 检查在运行时查询值的类型。TypeScript 在编译时也利用 typeof:在检查通过的 if 分支中,TypeScript 知道 width 是一个 number;否则(因为该分支有 return),width 必须是一个 string ——这是唯一剩下的类型。
因为调用 parseUnit 可能会返回 null,所以我们通过测试其结果是否真值来检查它是否返回了。^(3) TypeScript 知道如果 unit 是真值,则在 if 分支中它必须是 Unit 类型 ——否则,unit 必须是假值,意味着它必须是 null 类型(从 Unit | null 细化而来)。
最后,我们返回 null。这只会发生在用户为 width 传递了一个 string,但该字符串包含了我们不支持的单位。
我已经详细说明了 TypeScript 在执行每个类型细化时的思考过程,但我希望对您作为程序员来说,阅读该代码时这些已经是直观且显而易见的。TypeScript 在读写代码时非常善于捕捉您思维中的内容,并以类型检查和推断规则的形式加以表现。
区分联合类型
正如我们刚刚学到的,TypeScript 对 JavaScript 的工作原理有深入的理解,并且能够在您精炼类型的过程中跟随,就像您在头脑中追踪程序一样。
例如,假设我们正在为一个应用程序构建一个自定义事件系统。我们首先定义了几种事件类型,以及一个处理进来事件的函数。想象一下,UserTextEvent 模拟了键盘事件(例如,用户在文本 <input /> 中输入了一些内容),而 UserMouseEvent 模拟了鼠标事件(例如,用户将鼠标移动到坐标 [100, 200] 处):
type UserTextEvent = {value: string}
type UserMouseEvent = {value: [number, number]}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (typeof event.value === 'string') {
event.value // string
// ...
return
}
event.value // [number, number]
}
在 if 块内部,TypeScript 知道 event.value 必须是一个 string(因为 typeof 检查),这意味着在 if 块之后,event.value 必须是一个元组 [number, number](因为在 if 块中有 return)。
如果我们使这个过程变得更加复杂会发生什么呢?让我们为我们的事件类型添加更多信息,看看当我们细化我们的类型时,TypeScript 的表现如何:
type UserTextEvent = {value: string`,` `target``:` `HTMLInputElement`}
type UserMouseEvent = {value: [number, number]`,` `target``:` `HTMLElement`}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (typeof event.value === 'string') {
event.value *`// string`*
`event``.``target` *`// HTMLInputElement | HTMLElement (!!!)`*
// ...
return
}
event.value *`// [number, number]`*
`event``.``target` *`// HTMLInputElement | HTMLElement (!!!)`*
}
虽然细化适用于event.value,但并不适用于event.target。为什么呢?当handle接受类型为UserEvent的参数时,并不意味着我们必须传递UserTextEvent或UserMouseEvent——事实上,我们可以传递UserMouseEvent | UserTextEvent类型的参数。并且,由于联合类型的成员可能重叠,TypeScript 需要一种更可靠的方式来知道我们处于联合类型的哪种情况下。
实现此目的的方法是使用文字类型来标记联合类型的每个情况。一个好的标记是:
-
在联合类型的每个情况中都使用相同的位置。这意味着如果是对象类型的联合,则使用相同的对象字段,如果是元组类型的联合,则使用相同的索引。在实践中,带标签的联合通常使用对象类型。
-
类型化为文字类型(文字字符串、数字、布尔等)。您可以混合和匹配不同类型的文字,但是最好坚持使用单一类型;通常是字符串文字类型。
-
不是泛型的。标记不应采用任何泛型类型参数。
-
互斥的(即在联合类型内唯一的)。
有了这些知识,让我们再次更新我们的事件类型:
type UserTextEvent = {`type``:` `'TextEvent'``,` value: string, target: HTMLInputElement}
type UserMouseEvent = {`type``:` `'MouseEvent'``,` value: [number, number],
target: HTMLElement}
type UserEvent = UserTextEvent | UserMouseEvent
function handle(event: UserEvent) {
if (`event``.``type` `===` `'TextEvent'`) {
event.value *`// string`*
event.target *`// HTMLInputElement`*
// ...
return
}
event.value *`// [number, number]`*
event.target *`// HTMLElement`*
}
现在,当我们根据其标记字段(event.type)的值来细化event时,TypeScript 知道在if分支中event必须是UserTextEvent,并且在if分支后它必须是UserMouseEvent。由于标记在联合类型中是唯一的,TypeScript 知道这两者是互斥的。
当编写需要处理联合类型不同情况的函数时,请使用带标签的联合类型。例如,在处理 Flux 操作、Redux 减速器或 React 的useReducer时,它们非常有用。
总体性
程序员在睡前在床头桌上放了两个杯子:一个装满的,以防口渴;一个空的,以防不渴。
匿名
总体性,也称为穷尽性检查,允许类型检查器确保您已覆盖所有情况。这一概念源自 Haskell、OCaml 和其他基于模式匹配的语言。
TypeScript 将在多种情况下检查总体性,并在您遗漏某些情况时提供有用的警告。这是一个非常有用的功能,可以防止真正的错误。例如:
type Weekday = 'Mon' | 'Tue'| 'Wed' | 'Thu' | 'Fri'
type Day = Weekday | 'Sat' | 'Sun'
function getNextDay(w: Weekday): Day {
switch (w) {
case 'Mon': return 'Tue'
}
}
我们显然错过了几天(这是一个漫长的一周)。TypeScript 来拯救:
Error TS2366: Function lacks ending return statement and
return type does not include 'undefined'.
TSC 标志:noImplicitReturns
要求 TypeScript 检查所有函数代码路径是否返回一个值(并在您遗漏情况时抛出前面的警告),请在您的tsconfig.json中启用noImplicitReturns标志。是否启用此标志取决于您:有些人喜欢代码风格少一些显式的return,而有些人则愿意在更好的类型安全和类型检查器捕获更多错误的情况下多一些return。
这个错误信息告诉我们,要么我们遗漏了一些情况,并且应该在最后添加一个万能的return语句,返回类似'Sat'的内容(那将很好,对吧),要么我们应该调整getNextDay的返回类型为Day | undefined。在我们为每个Day添加一个case之后,错误就消失了(试试看!)。因为我们注解了getNextDay的返回类型,并且并非所有分支都保证返回该类型的值,TypeScript 给出了警告。
在这个示例中的具体实现细节并不重要:无论你使用何种控制结构——switch、if、throw等,TypeScript 都会确保你的后背有所依托,以确保你覆盖了每一个情况。
这里还有一个例子:
function isBig(n: number) {
if (n >= 100) {
return true
}
}
也许客户关于错过的最后期限的持续语音邮件让你感到不安,你忘记在业务关键的isBig函数中处理小于100的数字。再次,无需担心——TypeScript 会保护你:
Error TS7030: Not all code paths return a value.
也许周末给了你一个清空思绪的机会,你意识到应该重写之前的getNextDay示例以提高效率。不要再使用switch了,为什么不在对象中进行常量时间查找呢?
let nextDay = {
Mon: 'Tue'
}
nextDay.Mon // 'Tue'
当你的比熊犬在另一个房间狂吠(似乎是关于邻居的狗?)时,你心不在焉地忘记在提交代码并转向其他事情之前填写新的nextDay对象中的其他日期。
虽然 TypeScript 会在下次尝试访问nextDay.Tue时给出错误,但在首次声明nextDay时,你本可以更为主动。有两种方法可以做到这一点,正如你将在“记录类型”和“映射类型”中学到的;但在此之前,让我们稍微偏离一下,讨论对象类型的类型运算符。
高级对象类型
对象在 JavaScript 中至关重要,而 TypeScript 则为你提供了安全表达和操作它们的多种方式。
对象类型的类型运算符
还记得我在“联合和交集类型”中介绍的联合(|)和交集(&)两种类型运算符吗?原来 TypeScript 还为你提供了其他几种有用的类型运算符!让我们来看看一些处理形状的类型运算符。
键入运算符
假设你有一个复杂的嵌套类型,用来模拟你从所选社交媒体 API 中获取的 GraphQL API 响应:
type APIResponse = {
user: {
userId: string
friendList: {
count: number
friends: {
firstName: string
lastName: string
}[]
}
}
}
你可以从 API 获取响应,然后将其渲染:
function getAPIResponse(): Promise<APIResponse> {
// ...
}
function renderFriendList(friendList: unknown) {
// ...
}
let response = await getAPIResponse()
renderFriendList(response.user.friendList)
friendList的类型应该是什么?(它现在被存根为unknown。)你可以将它类型化,并根据它重新实现你的顶层APIResponse类型:
type FriendList = {
count: number
friends: {
firstName: string
lastName: string
}[]
}
type APIResponse = {
user: {
userId: string
friendList: FriendList
}
}
function renderFriendList(friendList: FriendList) {
// ...
}
但是你需要为每个顶层类型想出名称,这并不总是你想要的(例如,如果你使用构建工具从 GraphQL 架构生成 TypeScript 类型)。相反,你可以键入到你的类型中:
type APIResponse = {
user: {
userId: string
friendList: {
count: number
friends: {
firstName: string
lastName: string
}[]
}
}
}
`type` `FriendList` `=` `APIResponse``[``'user'``]``[``'friendList'``]`
function renderFriendList(friendList: FriendList) {
*`// ...`*
}
你可以对任何形状(对象、类构造函数或类实例)和任何数组进行键入。例如,要获取单个朋友的类型:
type Friend = FriendList['friends'][number]
number 是键入到数组类型的方式;对于元组,请使用 0、1 或其他数字字面类型来表示你想要键入的索引。
键入的语法故意设计得与在常规 JavaScript 对象中查找字段的方式相似——就像你可能在对象中查找值一样,你也可以在形状中查找类型。请注意,当进行属性类型键入时,必须使用方括号而不是点表示法。
键入操作符 keyof
使用 keyof 可以将对象的所有键作为字符串字面类型的联合体。使用前面的 APIResponse 示例:
type ResponseKeys = keyof APIResponse // 'user'
type UserKeys = keyof APIResponse['user'] // 'userId' | 'friendList'
type FriendListKeys =
keyof APIResponse['user']['friendList'] // 'count' | 'friends'
结合键入和 keyof 操作符,可以实现一个类型安全的获取器函数,该函数在对象中查找给定键的值:
function get< 
O extends object,
K extends keyof O 
>(
o: O,
k: K
): O[K] { 
return o[k]
}
get 是一个函数,它接受一个对象 o 和一个键 k。
keyof O 是字符串字面类型的联合体,表示 O 的所有键。泛型类型 K 扩展并且是该联合体的子类型。例如,如果 o 的类型是 {a: number, b: string, c: boolean},那么 keyof o 的类型是 'a' | 'b' | 'c',而 K(它扩展了 keyof o)可以是 'a'、'b'、'a' | 'c' 或 keyof o 的任何子类型。
O[K] 是当你在 O 中查找 K 时得到的类型。继续上述例子 ,如果
K 是 'a',那么我们在编译时知道 get 返回一个 number。或者,如果 K 是 'b' | 'c',那么我们知道 get 返回 string | boolean。
这些类型操作符的优点在于它们如何精确且安全地描述形状类型:
type ActivityLog = {
lastEvent: Date
events: {
id: string
timestamp: Date
type: 'Read' | 'Write'
}[]
}
let activityLog: ActivityLog = // ...
let lastEvent = get(activityLog, 'lastEvent') // Date
TypeScript 会为你工作,编译时验证 lastEvent 的类型是 Date。当然,你也可以扩展它,以便更深入地键入对象。让我们重载 get 以接受最多三个键:
type Get = { 
<
O extends object,
K1 extends keyof O
>(o: O, k1: K1): O[K1] 
<
O extends object,
K1 extends keyof O,
K2 extends keyof O[K1] 
>(o: O, k1: K1, k2: K2): O[K1][K2] 
<
O extends object,
K1 extends keyof O,
K2 extends keyof O[K1],
K3 extends keyof O[K1][K2]
>(o: O, k1: K1, k2: K2, k3: K3): O[K1][K2][K3] 
}
let get: Get = (object: any, ...keys: string[]) => {
let result = object
keys.forEach(k => result = result[k])
return result
}
get(activityLog, 'events', 0, 'type') // 'Read' | 'Write'
get(activityLog, 'bad') // Error TS2345: Argument of type '"bad"'
// is not assignable to parameter of type
// '"lastEvent" | "events"'.
我们为 get 声明了一个重载函数签名,涵盖了调用 get 时使用一个键、两个键和三个键的情况。
这个单键情况与上一个例子相同:O 是 object 的子类型,K1 是该对象的键的子类型,返回类型是你在 O 中用 K1 键入时得到的具体类型。
两个键的情况类似于一个键的情况,但我们声明了一个额外的泛型类型 K2,以模拟通过 K1 键入 O 后得到的嵌套对象上可能的键。
我们在 的基础上进行了两次键入——首先获取
O[K1] 的类型,然后获取结果上的 [K2] 的类型。
对于这个示例,我们处理最多三层嵌套的键;如果你正在编写一个真实的库,你可能会想处理更多的情况。
很酷,对吧?如果你有一分钟,向你的 Java 朋友展示这个例子,并确保在向他们讲解时好好炫耀。
TSC 标志:keyofStringsOnly
在 JavaScript 中,对象和数组可以同时具有字符串和符号键。而且按照惯例,我们通常在数组中使用数字键,这些键在运行时被强制转换为字符串。
因此,默认情况下 TypeScript 的keyof返回一个number | string | symbol类型的值(虽然如果你在更具体的形状上调用它,TypeScript 可以推断出该联合的更具体的子类型)。
这种行为是正确的,但可能会使得使用keyof变得啰嗦,因为你可能需要向 TypeScript 证明你操作的特定键是一个string,而不是一个number或symbol。
要选择 TypeScript 的传统行为——键必须是字符串——请启用tsconfig.json中的keyofStringsOnly标志。
Record 类型
TypeScript 的内置Record类型是一种描述对象的一种到另一种的映射的方法。
在“全体性”中的Weekday示例中,我们可以回想到有两种方法来强制对象定义特定的键集合。Record类型是第一种方法。
让我们使用Record来构建一个从每周的一天到下一天的映射。使用Record,你可以对nextDay的键和值施加一些约束:
type Weekday = 'Mon' | 'Tue'| 'Wed' | 'Thu' | 'Fri'
type Day = Weekday | 'Sat' | 'Sun'
let nextDay: Record<Weekday, Day> = {
Mon: 'Tue'
}
现在,你会立即得到一个很好的、有用的错误信息:
Error TS2739: Type '{Mon: "Tue"}' is missing the following properties
from type 'Record<Weekday, Day>': Tue, Wed, Thu, Fri.
添加缺少的Weekday到你的对象中,当然可以解决这个错误。
Record给你比常规对象索引签名多了一层自由度:普通索引签名只能约束对象值的类型,但键只能是常规的string、number或symbol类型;而Record还允许你将对象的键类型约束为string和number的子类型。
映射类型
TypeScript 给了我们第二种更强大的声明安全nextDay类型的方式:映射类型。让我们使用映射类型来表示nextDay是一个具有每个Weekday键的对象,其值是一个Day:
let nextDay: {[K in Weekday]: Day} = {
Mon: 'Tue'
}
这是另一种帮助你修复遗漏的提示方式:
Error TS2739: Type '{Mon: "Tue"}' is missing the following properties
from type '{Mon: Weekday; Tue: Weekday; Wed: Weekday; Thu: Weekday;
Fri: Weekday}': Tue, Wed, Thu, Fri.
映射类型是 TypeScript 中独有的语言特性。像字面量类型一样,它们是一种对静态类型化 JavaScript 挑战的实用功能。
正如你所见,映射类型有其特殊的语法。而且和索引签名一样,你每个对象只能有一个映射类型:
type MyMappedType = {
[Key in UnionType]: ValueType
}
正如其名称所示,它是一种映射对象键和值类型的方法。事实上,TypeScript 使用映射类型来实现我们之前使用的内置Record类型:
type Record<K extends keyof any, T> = {
[P in K]: T
}
映射类型比普通的Record给你更多的自由度,因为除了让你给对象的键和值赋予类型之外,当你将它们与键入的类型结合时,它们还允许你对对应键名的值类型施加约束。
让我们快速浏览一下您可以使用映射类型做的一些事情。
type Account = {
id: number
isEmployee: boolean
notes: string[]
}
// Make all fields optional type OptionalAccount = {
[K in keyof Account]?: Account[K] 
}
// Make all fields nullable type NullableAccount = {
[K in keyof Account]: Account[K] | null 
}
// Make all fields read-only type ReadonlyAccount = {
readonly [K in keyof Account]: Account[K] 
}
// Make all fields writable again (equivalent to Account) type Account2 = {
-readonly [K in keyof ReadonlyAccount]: Account[K] 
}
// Make all fields required again (equivalent to Account) type Account3 = {
[K in keyof OptionalAccount]-?: Account[K] 
}
通过映射Account并在途中将每个字段标记为可选,我们创建了一个新的对象类型OptionalAccount。
通过映射Account并途中为每个字段添加null,我们创建了一个新的对象类型NullableAccount。
我们通过获取Account并将其每个字段标记为只读(即可读但不可写)来创建一个新的对象类型ReadonlyAccount。
我们可以标记字段为可选(?)或readonly,也可以取消标记。通过减号(–)操作符——仅在映射类型中可用的特殊类型操作符——我们可以撤消?和readonly,使字段再次成为必需且可写。在此示例中,我们通过映射ReadonlyAccount并使用减号(–)操作符去除readonly修饰符,创建了一个等效于我们的Account类型的新对象类型Account2。
通过映射OptionalAccount并使用减号(–)操作符去除可选(?)操作符,我们创建了一个等效于原始Account类型的新对象类型Account3。
注意
减号(–)有一个对应的加号(+)类型操作符。你可能永远不会直接使用这个操作符,因为它是隐含的:在映射类型内部,readonly等同于+readonly,?等同于+?。+只是为了完整性而存在。
内置映射类型
我们在上一节中派生的映射类型非常有用,以至于 TypeScript 内置了许多这样的类型:
Record<Keys, Values>
具有类型为Keys的键和类型为Values的值的对象
Partial<Object>
将Object中的每个字段标记为可选
Required<Object>
将Object中的每个字段标记为非可选
Readonly<Object>
将Object中的每个字段标记为只读
Pick<Object, Keys>
返回Object的子类型,仅包含给定的Keys
伴随对象模式
伴随对象模式来自于Scala,它是一种将对象和共享相同名称的类配对的方法。在 TypeScript 中,有一种类似的模式,同样很有用——我们也称之为伴随对象模式——我们可以使用它来配对类型和对象。
它看起来像这样:
type Currency = {
unit: 'EUR' | 'GBP' | 'JPY' | 'USD'
value: number
}
let Currency = {
DEFAULT: 'USD',
from(value: number, unit = Currency.DEFAULT): Currency {
return {unit, value}
}
}
请记住,在 TypeScript 中,类型和值存在于不同的命名空间;您将在“声明合并”中稍微了解这一点。这意味着在同一作用域内,您可以同时将同一个名称(在本例中为Currency)绑定为类型和值。通过伴随对象模式,我们利用这种分开的命名空间来两次声明一个名称:首先作为类型,然后作为值。
这种模式具有一些优点。它允许你将语义上属于单一名称(如Currency)的类型和值信息分组在一起。它还允许消费者同时导入两者:
import {Currency} from './Currency'
let amountDue: Currency = { 
unit: 'JPY',
value: 83733.10
}
let otherAmountDue = Currency.from(330, 'EUR') 
将Currency用作类型
将Currency用作值
当类型和对象在语义上相关联时,可以使用伴生对象模式,其中对象提供操作该类型的实用方法。
高级函数类型
让我们看看一些通常与函数类型一起使用的更高级技术。
提升元组的类型推断
当你在 TypeScript 中声明一个元组时,TypeScript 在推断该元组的类型时会比较宽松。它将根据你提供的内容推断出最一般的可能类型,忽略元组的长度以及每个位置上的类型:
let a = [1, true] // (number | boolean)[]
但有时候你希望推断更严格,希望将a视为固定长度的元组而不是数组。当然,你可以使用类型断言将你的元组转换为元组类型(关于此更多内容,请参见“类型断言”)。或者,你可以使用as const断言(关于“const 类型”)尽可能地推断元组类型,标记它为只读。
如果你想将你的元组类型化为元组,但又想避免类型断言、避免as const给出的窄推断和只读修饰符,该怎么办?为了做到这一点,你可以利用 TypeScript 为剩余参数推断类型的方式(参见“使用有界多态来模拟元组长度”获取更多信息):
function tuple< 
T extends unknown[] 
>(
...ts: T 
): T { 
return ts 
}
let a = tuple(1, true) // [number, boolean]
我们声明一个tuple函数,用于构造元组类型(而不是使用内置的[]语法)。
我们声明了一个名为T的单一类型参数,它是unknown[]的子类型(意味着T是任何类型的数组)。
tuple接受一个名为ts的可变参数。由于T描述了一个剩余参数,TypeScript 将为其推断出一个元组类型。
tuple返回一个与其推断出的ts相同类型的值。
我们的函数返回我们传递给它的相同参数。其中的魔力都在于类型上。
利用这种技术,可以在代码使用大量元组类型时避免类型断言。
用户定义的类型保护
对于某些返回boolean的函数,简单地声明函数返回boolean可能还不够。例如,让我们编写一个函数,告诉你是否传递了一个string:
function isString(a: unknown): boolean {
return typeof a === 'string'
}
isString('a') // evaluates to true
isString([7]) // evaluates to false
到目前为止还不错。如果你尝试在一些实际代码中使用isString会发生什么?
function parseInput(input: string | number) {
let formattedInput: string
if (isString(input)) {
formattedInput = input.toUpperCase() // Error TS2339: Property 'toUpperCase'
} // does not exist on type 'number'.
}
怎么回事?如果typeof适用于常规类型细化(参见“细化”),为什么在这里不起作用?
类型细化的问题在于它只能在你所在的作用域中细化变量的类型。一旦你离开该作用域,这种细化就不会传递到你进入的新作用域。在我们的isString实现中,我们使用typeof将输入参数的类型细化为string,但是因为类型细化不会传递到新的作用域,它就丢失了——TypeScript 只知道isString返回了一个boolean。
我们可以告诉类型检查器,isString不仅返回一个boolean,而且当这个boolean为true时,我们传递给isString的参数是一个string。为了做到这一点,我们使用了一种称为用户定义的类型守卫的东西:
function isString(a: unknown): `a` `is` `string` {
return typeof a === 'string'
}
类型守卫是 TypeScript 的内置特性,可以让你使用typeof和instanceof来细化类型。但有时候,你需要能够自己声明类型守卫——这就是is运算符的用途。当你有一个函数可以细化其参数类型并返回一个boolean时,你可以使用用户定义的类型守卫来确保在使用该函数时流动该细化。
用户定义的类型守卫限制于单个参数,但并不限于简单类型:
type LegacyDialog = // ...
type Dialog = // ...
function isLegacyDialog(
dialog: LegacyDialog | Dialog
): dialog is LegacyDialog {
// ...
}
你不经常使用用户定义的类型守卫,但当你使用时,它们非常适合编写干净、可重用的代码。如果没有它们,你将不得不内联所有你的typeof和instanceof类型守卫,而不是像isLegacyDialog和isString这样构建函数,以更好封装、更可读的方式执行相同的检查。
条件类型
条件类型可能是 TypeScript 中最独特的功能。在高层次上,条件类型让你说,“声明一个依赖于类型U和V的类型T;如果U <: V,则将T分配给A,否则,将T分配给B。”
在代码中,它可能看起来像这样:
type IsString<T> = T extends string 
? true 
: false 
type A = IsString<string> // true type B = IsString<number> // false
让我们逐行分解这个问题。
我们声明了一个新的条件类型IsString,它接受一个泛型类型T。这个条件类型的“条件”部分是T extends string;也就是说,“T是string的子类型吗?”
如果T是string的子类型,我们解析为类型true。
否则,我们解析为类型false。
注意语法看起来就像是常规的值级三元表达式,但在类型级别。而且和常规的三元表达式一样,你也可以嵌套它们。
条件类型并不限于类型别名。你可以在几乎任何你可以使用类型的地方使用它们:在类型别名中、接口、类、参数类型以及函数和方法中的泛型默认值。
分布式条件
虽然你可以用 TypeScript 中的条件类型、重载函数签名和映射类型来表达简单的条件,但条件类型让你可以做更多事情。原因在于它们遵循 分布法则(还记得代数课上学过的吗?)。这意味着如果你有一个条件类型,右侧的表达式在 表 6-1 中等效于左侧的表达式。
表 6-1. 条件类型的分布
| 这… | 等同于 |
|---|---|
string extends T ? A : B |
string extends T ? A : B |
(string | number) extends T ? A : B |
(string extends T ? A : B) | (number extends T ? A : B) |
(string | number | boolean) extends T ? A : B |
(string extends T ? A : B) | (number extends T ? A : B) | (boolean extends T ? A : B) |
我知道,我知道,你不是为了数学知识买这本书的——你是为了类型。那么让我们更具体点。假设我们有一个函数,它接受类型 T 的某个变量,并将其提升为类型 T[] 的数组。如果我们为 T 传入一个联合类型会发生什么?
type ToArray<T> = T[]
type A = ToArray<number> // number[]
type B = ToArray<number | string> // (number | string)[]
非常直观。那么如果我们添加一个条件类型会发生什么呢?(请注意,此处的条件实际上没有做任何事情,因为它的两个分支都会解析为相同的类型 T[];它只是告诉 TypeScript 分布 T 到元组类型中。)看一下:
type ToArray2<T> = T extends unknown ? T[] : T[]
type A = ToArray2<number> *`// number[]`*
type B = ToArray2<number | string> *`// number[] | string[]`*
你明白了吗?当你使用条件类型时,TypeScript 将会将联合类型分布到条件的分支中。这就像是将条件类型映射(或者说 分布)到联合的每个元素中一样。
这些有什么作用呢?它让你能够安全地表达一些常见的操作。
例如,TypeScript 提供了 & 来计算两种类型共有的部分,| 来获取两种类型的并集。我们来构建 Without<T, U>,它计算了存在于 T 中但不存在于 U 中的类型。
type Without<T, U> = T extends U ? never : T
你可以这样使用 Without:
type A = Without<
boolean | number | string,
boolean
> // number | string
让我们来看看 TypeScript 如何计算这种类型:
-
从输入开始:
type A = Without<boolean | number | string, boolean> -
在联合中分布条件:
type A = Without<boolean, boolean> | Without<number, boolean> | Without<string, boolean> -
在
Without的定义中替换并应用T和U:type A = (boolean extends boolean ? never : boolean) | (number extends boolean ? never : number) | (string extends boolean ? never : string) -
评估条件:
type A = never | number | string -
简化:
type A = number | string
如果没有条件类型的分布性质,我们将会得到 never 类型(如果你不确定为什么,请自行思考会发生什么!)。
infer 关键字
条件类型的最终特性是能够将通用类型声明为条件的一部分。简而言之,到目前为止,我们只见过一种声明通用类型参数的方式:使用尖括号 (<T>)。条件类型有自己的语法来内联声明通用类型:infer 关键字。
让我们声明一个条件类型 ElementType,用来获取数组元素的类型:
type ElementType<T> = T extends unknown[] ? T[number] : T
type A = ElementType<number[]> // number
现在,让我们使用 infer 来重写它:
type ElementType2<T> = T extends (infer U)[] ? U : T
type B = ElementType2<number[]> // number
在这个简单的例子中,ElementType 等价于 ElementType2。注意 infer 子句如何声明一个新的类型变量 U —— TypeScript 将根据您传递给 ElementType2 的 T 的上下文推断出 U 的类型。
还要注意为什么我们内联声明 U 而不是与 T 一起最前面声明它。如果我们最前面声明它会发生什么?
type ElementUgly<T, U> = T extends U[] ? U : T
type C = ElementUgly<number[]> // Error TS2314: Generic type 'ElementUgly'
// requires 2 type argument(s).
哎呀。因为 ElementUgly 定义了两个泛型类型 T 和 U,当实例化 ElementUgly 时,我们必须同时传入它们。但如果我们这样做,那么就失去了首次引入 ElementUgly 类型的意义;它把计算 U 的负担放在了调用者身上,而我们希望 ElementUgly 自己计算类型。
老实说,这个例子有点傻,因为我们已经有了键入运算符([])来查找数组元素的类型。那么更复杂的例子呢?
type SecondArg<F> = F extends (a: any, b: infer B) => any ? B : never
// Get the type of Array.slice
type F = typeof Array['prototype']['slice']
type A = SecondArg<F> // number | undefined
因此,[].slice 的第二个参数是 number | undefined。我们在编译时知道这一点 —— 尝试在 Java 中做这个。
内置条件类型
条件类型允许您在类型级别表达一些非常强大的操作。这就是为什么 TypeScript 提供了一些全局可用的条件类型:
Exclude<T, U>
就像我们之前的 Without 类型一样,计算在 T 中不在 U 中的那些类型:
type A = number | string
type B = string
type C = Exclude<A, B> // number
Extract<T, U>
计算在 T 中可以赋值给 U 的类型:
type A = number | string
type B = string
type C = Extract<A, B> // string
NonNullable<T>
计算 T 的一个版本,排除 null 和 undefined:
type A = {a?: number | null}
type B = NonNullable<A['a']> // number
ReturnType<F>
计算函数的返回类型(请注意,这对于泛型和重载函数的预期效果与您期望的不同):
type F = (a: number) => string
type R = ReturnType<F> // string
InstanceType<C>
计算类构造函数的实例类型:
type A = {new(): B}
type B = {b: number}
type I = InstanceType<A> // {b: number}
逃生舱口
有时您没有时间完全打字,只想 TypeScript 相信您所做的是安全的。也许您正在使用第三方模块的类型声明存在错误,希望在贡献修复到 DefinitelyTyped 之前测试您的代码,^(4) 或者您正在从 API 获取数据,但尚未使用 Apollo 重新生成类型声明。
幸运的是,TypeScript 知道我们只是人类,所以在我们只想做一些事情而没有时间向 TypeScript 证明它是安全的时候,它给了我们一些逃生舱口。
注意
如果不明显的话,您应尽可能少地使用以下 TypeScript 功能。如果发现自己依赖它们,可能做错了什么。
类型断言
如果您有类型 B 并且 A <: B <: C,那么您可以断言给类型检查器,B 实际上是 A 或 C。特别地,您只能断言类型是其自身的超类型或子类型 —— 例如,您不能断言 number 是 string,因为这些类型没有关系。
TypeScript 为我们提供了两种类型断言语法:
function formatInput(input: string) {
// ... }
function getUserInput(): string | number {
// ... }
let input = getUserInput()
// Assert that input is a string formatInput(input as string) 
// This is equivalent to formatInput(<string>input) 
我们使用类型断言(as)告诉 TypeScript input 是一个string,而不是类型会告诉我们的string | number。例如,如果您想快速测试您的formatInput函数,并且您确定getUserInput返回一个string,您可能会这样做。
类型断言的传统语法使用尖括号。这两种语法在功能上是等效的。
注意
更喜欢使用as语法进行类型断言,而不是尖括号(<>)语法。前者是明确的,而后者可能与 TSX 语法冲突(参见“TSX = JSX + TypeScript”)。使用 TSLint 的no-angle-bracket-type-assertion规则可以自动强制执行此操作,适用于您的代码库。
有时,两种类型可能不足够相关,因此您不能断言一种类型是另一种类型。为了解决这个问题,简单地断言为any(从“可赋值性”中记住,any可以赋值给任何类型),然后在角落里花几分钟思考您所做的事情:
function addToList(list: string[], item: string) {
// ...
}
addToList('this is really,' as any, 'really unsafe')
显然,类型断言是不安全的,在可能的情况下应避免使用它们。
非空断言
对于可为空类型的特殊情况——即类型为T | null或T | null | undefined——TypeScript 有特殊的语法来断言该类型的值是T,而不是null或undefined。这在几个地方会遇到。
例如,假设我们已经为在 Web 应用程序中显示和隐藏对话框编写了一个框架。每个对话框都有一个唯一的 ID,我们使用它来获取对话框的 DOM 节点的引用。一旦对话框从 DOM 中移除,我们删除其 ID,表示它在 DOM 中不再存在:
type Dialog = {
id?: string
}
function closeDialog(dialog: Dialog) {
if (!dialog.id) { 
return
}
setTimeout(() => 
removeFromDOM(
dialog,
document.getElementById(dialog.id) // Error TS2345: Argument of type
// 'string | undefined' is not assignable
// to parameter of type 'string'. 
)
)
}
function removeFromDOM(dialog: Dialog, element: Element) {
element.parentNode.removeChild(element) // Error TS2531: Object is possibly
//'null'. 
delete dialog.id
}
如果对话框已被删除(因此没有id),我们会提前返回。
在事件循环的下一个轮次中,我们从 DOM 中移除对话框,以便依赖于dialog的任何其他代码都有机会完成运行。
因为我们现在在箭头函数内部,所以我们现在处于一个新的作用域中。TypeScript 不知道在和
之间的某些代码是否会更改
dialog,因此它无效了我们在中进行的细化。此外,虽然我们知道如果
dialog.id被定义,则具有该 ID 的元素肯定存在于 DOM 中(因为我们设计了我们的框架),但 TypeScript 只知道调用document.getElementById返回一个HTMLElement | null。我们知道它总是一个非空的HTMLElement,但 TypeScript 不知道这一点——它只知道我们给它的类型。
同样地,虽然我们知道对话框肯定在 DOM 中,并且它确实有一个父 DOM 节点,但 TypeScript 只知道 element.parentNode 的类型是 Node | null。
修复此问题的一种方法是在每个地方添加一堆 if (_ === null) 检查。如果你不确定某些东西是否为 null,那么这样做是正确的方式,但是 TypeScript 提供了特殊的语法,当你确定它不是 null | undefined 时可以使用它:
type Dialog = {
id?: string
}
function closeDialog(dialog: Dialog) {
if (!dialog.id) {
return
}
setTimeout(() =>
removeFromDOM(
dialog,
document.getElementById(dialog.id`!`)`!`
)
)
}
function removeFromDOM(dialog: Dialog, element: Element) {
element.parentNode`!`.removeChild(element)
delete dialog.id
}
注意非null断言操作符 (!) 的使用,告诉 TypeScript 我们确定 dialog.id、document.getElementById 调用的结果以及 element.parentNode 是定义的。当非null断言跟随一个可能是 null 或 undefined 的类型时,TypeScript 将假定该类型是定义的:T | null | undefined 变成 T,number | string | null 变成 number | string,依此类推。
当你发现自己经常使用非null断言时,这通常是你应该重构代码的一个标志。例如,我们可以通过将 Dialog 拆分为两种类型的联合来消除断言:
type VisibleDialog = {id: string}
type DestroyedDialog = {}
type Dialog = VisibleDialog | DestroyedDialog
然后我们可以更新 closeDialog 来利用这个联合:
function closeDialog(dialog: Dialog) {
if (!`(``'id'` `in` `dialog``)`) {
return
}
setTimeout(() =>
removeFromDOM(
dialog,
document.getElementById(`dialog``.``id`)!
)
)
}
function removeFromDOM(dialog: `VisibleDialog`, element: Element) {
element.parentNode!.removeChild(element)
delete dialog.id
}
在我们检查 dialog 是否定义了 id 属性之后——这意味着它是一个 Visible``Dialog——即使在箭头函数内部 TypeScript 也知道引用 dialog 没有改变:箭头函数内部的 dialog 与函数外部的 dialog 是同一个,因此类型细化会被保留,而不像上一个示例中那样失效。
确定赋值断言
TypeScript 为确定赋值检查的非null断言特例提供了特殊的语法(作为提醒,确定赋值检查是 TypeScript 确保在你使用变量之前,该变量已被赋值的方式)。例如:
let userId: string
userId.toUpperCase() // Error TS2454: Variable 'userId' is used
// before being assigned.
显然,TypeScript 通过捕获此错误为我们提供了极大的帮助。我们声明了变量 userId,但在尝试将其转换为大写之前忘记给它赋值。如果 TypeScript 没有注意到,这将是一个运行时错误!
但是,如果我们的代码看起来更像这样呢?
let userId: string
`fetchUser``(``)`
userId.toUpperCase() *`// Error TS2454: Variable 'userId' is used`*
*`// before being assigned.`*
`function` `fetchUser() {`
`userId` `=` `globalCache``.``get``(``'userId'``)`
`}`
我们碰巧拥有世界上最棒的缓存,当我们查询此缓存时,我们百分百的时间都会命中缓存。因此在调用 fetchUser 后,userId 被保证已定义。但 TypeScript 无法静态检测到这一点,因此仍然抛出与之前相同的错误。我们可以使用确定赋值断言告诉 TypeScript,在我们读取它之前,userId 一定会被赋值(注意感叹号):
let userId`!`: string
fetchUser()
userId.toUpperCase() *`// OK`*
function fetchUser() {
userId = globalCache.get('userId')
}
和类型断言和非null断言一样,如果你发现自己经常使用确定赋值断言,那么你可能做错了什么。
模拟名义类型
在本书的这一阶段,如果我在凌晨三点把你摇醒并大声喊“TYPESCRIPT 的类型系统是结构化的还是名义的?!”你会大声回答“当然是结构化的!现在快滚出去,不然我要报警了!”这对我突然问类型系统问题确实是一个合理的反应。
法律之外,事实上有时名义类型确实很有用。例如,假设您的应用程序中有几种ID类型,表示系统中不同对象类型的唯一方式:
type CompanyID = string
type OrderID = string
type UserID = string
type ID = CompanyID | OrderID | UserID
类型为UserID的值可能是一个看起来像"d21b1dbf"的简单哈希。所以虽然你可能将其别名为UserID,但在底层它当然只是一个普通的string。接受UserID的函数可能如下所示:
function queryForUser(id: UserID) {
// ...
}
这是很好的文档,有助于团队中的其他工程师确切地知道他们应该传递哪种类型的ID。但是由于UserID只是string的别名,这种方法几乎无法防止错误。工程师可能会不小心传递错误类型的ID,而类型系统对此一无所知!
let id: CompanyID = 'b4843361'
queryForUser(id) // OK (!!!)
这就是名义类型派上用场的地方^(5)。虽然 TypeScript 不直接支持名义类型,但我们可以通过一种称为类型品牌的技术来模拟它们。类型品牌需要一些设置工作,在 TypeScript 中使用它并不像在具有内置支持名义类型别名的语言中那样顺畅。尽管如此,品牌类型可以显著提高程序的安全性。
注意
根据您的应用程序和工程团队的规模(团队越大,使用此技术防止错误的可能性就越大),您可能不需要这样做。
首先为每个名义类型创建一个合成的类型品牌:
`type` `CompanyID` `=` `string` `&` `{``readonly` `brand``:` `unique` `symbol``}`
`type` `OrderID` `=` `string` `&` `{``readonly` `brand``:` `unique` `symbol``}`
`type` `UserID` `=` `string` `&` `{``readonly` `brand``:` `unique` `symbol``}`
type ID = CompanyID | OrderID | UserID
string 和 {readonly brand: unique symbol} 的交集当然是无意义的。我选择它是因为不可能自然地构造出这种类型,而唯一创建这种类型值的方法是通过断言。这就是品牌类型的关键特性:它们使得在其位置意外使用错误类型变得困难。我将unique symbol用作“品牌”,因为它是 TypeScript 中两种真正的名义类型之一(另一种是enum);我将其与string的交集,以便我们可以断言给定的string是给定的品牌类型。
现在我们需要一种方法来创建CompanyID、OrderID和UserID类型的值。为此,我们将使用伴生对象模式(介绍见“伴生对象模式”)。我们将为每个品牌类型制作一个构造函数,并使用类型断言来构造我们各种虚构类型的值:
function CompanyID(id: string) {
return id as CompanyID
}
function OrderID(id: string) {
return id as OrderID
}
function UserID(id: string) {
return id as UserID
}
最后,让我们看看使用这些类型是什么感觉:
function queryForUser(id: UserID) {
// ...
}
let companyId = CompanyID('8a6076cf')
let orderId = OrderID('9994acc1')
let userId = UserID('d21b1dbf')
queryForUser(userId) // OK
queryForUser(companyId) // Error TS2345: Argument of type 'CompanyID' is not
// assignable to parameter of type 'UserID'.
这种方法的优点在于它几乎没有运行时开销:每个 ID 构造只需一个函数调用,这可能会被你的 JavaScript 虚拟机内联处理。在运行时,每个 ID 简单地就是一个 string —— 品牌纯粹是一个编译时的构造。
对大多数应用程序来说,这种方法可能有些过头了。但是对于大型应用程序,尤其是在处理易混淆的类型(如不同类型的 ID)时,品牌类型可能是一个非常有效的安全特性。
安全地扩展原型
在构建 JavaScript 应用程序时,传统观点认为扩展内置类型的原型是不安全的。这条经验法则可以追溯到 jQuery 之前的时代,当时聪明的 JavaScript 法师们构建了像 MooTools 这样的库,直接扩展和重写内置原型方法。但是,当太多法师同时扩展原型时,就会出现冲突。没有静态类型系统,你只能在运行时从愤怒的用户那里找到这些冲突。
如果你不是从 JavaScript 转过来的,你可能会惊讶地发现在 JavaScript 中,你可以在运行时修改任何内置方法(比如 [].push、'abc'.toUpperCase 或 Object.assign)。因为它是一种如此动态的语言,JavaScript 允许你直接访问每个内置对象的原型 —— Array.prototype、Function.prototype、Object.prototype 等等。
回到过去,扩展这些原型是不安全的。但是,如果你的代码受到像 TypeScript 这样的静态类型系统的保护,那么现在你可以安全地这样做了。^(6)
例如,我们将向 Array 原型添加一个 zip 方法。要安全地扩展原型,需要两步。首先,在一个 .ts 文件中(比如 zip.ts),我们扩展 Array 原型的类型;然后,我们用我们的新 zip 方法增强原型:
// Tell TypeScript about .zip interface Array<T> { 
zip<U>(list: U[]): [T, U][]
}
// Implement .zip Array.prototype.zip = function<T, U>(
this: T[], 
list: U[]
): [T, U][] {
return this.map((v, k) =>
tuple(v, list[k]) 
)
}
我们首先告诉 TypeScript 我们要将 zip 添加到 Array 上。我们利用接口合并(“声明合并”)来扩展全局的 Array<T> 接口,将我们自己的 zip 方法添加到已经全局定义的接口中。
由于我们的文件没有任何显式的导入或导出 —— 这意味着它处于脚本模式,如 “模块模式与脚本模式” 中所述 —— 我们能够通过声明一个与现有的 Array<T> 接口同名的接口来直接扩展全局的 Array 接口,并让 TypeScript 帮我们合并这两者。如果我们的文件处于模块模式(例如,如果我们需要为我们的 zip 实现 import 一些内容),我们将不得不在一个 declare global 类型声明中包装我们的全局扩展(参见 “类型声明”):
`declare` `global` `{`
interface Array<T> {
zip<U>(list: U[]): [T, U][]
}
`}`
global 是一个特殊的命名空间,包含所有全局定义的值(即在模块模式文件中可以直接使用而不需要首先 import 的任何内容;参见第十章),允许你从模块模式中的文件中增加全局作用域中的名称。
然后我们在 Array 的原型上实现 zip 方法。我们使用 this 类型,以便 TypeScript 正确推断我们调用 .zip 的数组的 T 类型。
因为 TypeScript 推断映射函数的返回类型为 (T | U)[](TypeScript 没有足够智能以意识到实际上它总是一个包含 T 在零索引和 U 在第一索引的元组),我们使用我们的 tuple 实用工具(来自“改进元组类型推断”)创建一个元组类型,而不是使用类型断言。
请注意,当我们声明 interface Array<T> 时,我们为整个 TypeScript 项目增加了全局的 Array 命名空间 —— 这意味着即使我们不从我们的文件导入 zip.ts,TypeScript 仍然认为 [].zip 可用。但是为了增加 Array.prototype,我们必须确保任何使用 zip 的文件首先加载 zip.ts,以便在 Array.prototype 上安装 zip 方法。我们如何确保任何使用 zip 的文件都首先加载 zip.ts 呢?
简单来说:我们更新我们的 tsconfig.json 文件,明确排除项目中的 zip.ts,这样消费者必须首先显式 import 它:
{
*exclude*: [
"./zip.ts"
]
}
现在我们可以安全地随心所欲地使用 zip:
import './zip'
[1, 2, 3]
.map(n => n * 2) // number[]
.zip(['a', 'b', 'c']) // [number, string][]
运行这个代码会给我们第一次映射的结果,然后再对数组进行打包:
[
[2, 'a'],
[4, 'b'],
[6, 'c']
]
摘要
在本章中,我们涵盖了 TypeScript 类型系统中最先进的特性:从变异性的内外到基于流的类型推断、精炼、类型扩展、全面性以及映射和条件类型。然后,我们推导了一些高级类型处理模式:使用类型品牌模拟名义类型,利用条件类型的分配属性在类型级别上操作类型,以及安全地扩展原型。
如果你没有理解或者记不住所有内容,没关系 —— 稍后再回到本章,当你在如何更安全地表达某些内容时遇到困难时,可以将其用作参考。
练习
-
对于以下每一对类型,请决定第一个类型是否可分配给第二个类型,以及为什么或为什么不可。考虑这些问题涉及子类型和变异性,并在需要时参考本章开头的规则(如果你仍然不确定,只需在代码编辑器中输入以检查!):
-
1和number -
number和1 -
string和number | string -
boolean和number -
number[]和(number | string)[] -
(number | string)[]和number[] -
{a: true}和{a: boolean} -
{a: {b: [string]}}和{a: {b: [number | string]}} -
(a: number) => string和(b: number) => string -
(a: number) => string和(a: string) => string -
(a: number | string) => string和(a: string) => string -
E.X(定义在枚举enum E {X = 'X'}中)和F.X(定义在枚举enum F {X = 'X'}中)
-
-
如果您有一个对象类型
type O = {a: {b: {c: string}}},那么keyof O的类型是什么?O['a']['b']的类型又是什么? -
编写一个
Exclusive<T, U>类型,计算既属于T又属于U中的类型,但不包括两者都有的类型。例如,Exclusive<1 | 2 | 3, 2 | 3 | 4>应解析为1 | 4。逐步写出类型检查器如何评估Exclusive<1 | 2, 2 | 4>。 -
重新编写示例(来自“明确赋值断言”),以避免明确赋值断言。
^(1) 符号执行是一种程序分析形式,您可以使用特殊的程序(称为符号评估器)运行您的程序,就像运行时运行一样,但不为变量分配确定值;而是将每个变量建模为一个符号,其值在程序运行时被约束。符号执行使您可以说出像“这个变量从未被使用”,“这个函数从不返回”,或者“在第 102 行的 if 语句的正分支中,变量x保证不是null”这样的话语。
^(2) 基于流的类型推断受到少数语言的支持,包括 TypeScript、Flow、Kotlin 和 Ceylon。这是一种在代码块内细化类型的方法,是 C/Java 风格显式类型注解和 Haskell/OCaml/Scala 风格模式匹配的替代方法。其思想是将符号执行引擎嵌入到类型检查器中,以便向类型检查器提供反馈,并以更接近人类程序员思维方式来推理程序。
^(3) JavaScript 有七个假值:null、undefined、NaN、0、-0、"",当然还有false。其他一切皆为真值。
^(4) DefinitelyTyped 是第三方 JavaScript 类型声明的开源存储库。要了解更多信息,请跳到“在 DefinitelyTyped 上具有类型声明的 JavaScript”。
^(5) 在某些语言中,这些也称为不透明类型。
^(6) 还有其他原因可能会让您希望避免扩展原型,例如代码的可移植性、使依赖关系图更加明确,或通过仅加载实际使用的方法来提高性能。然而,安全性不再是这些原因之一。
第七章:处理错误
一名物理学家、一名结构工程师和一名程序员在驾驶一辆汽车越过陡峭的高山通道时,刹车失灵了。汽车越来越快,他们挣扎着转过弯道,有时脆弱的防护栏救了他们一两次免于滚落山坡。他们确信自己都要死了,突然他们发现了一个逃生车道。他们驶入逃生车道,安全地停了下来。
物理学家说:“我们需要建模制动片的摩擦和由此产生的温升,并看看为什么它们失灵了。”
结构工程师说:“我想我后面有几把扳手。我来看看能不能找出问题所在。”
程序员说:“为什么我们不看看它是否可以重现?”
匿名
TypeScript 尽其所能将运行时异常转移到编译时:从其提供的丰富类型系统到执行的强大静态和符号分析,它努力让你不必在周五夜晚调试拼写错误的变量和空指针异常(也让你的值班同事不必因此迟到参加他们的大姨妈生日派对)。
不幸的是,无论您使用什么语言编写,有时运行时异常都会偷偷溜走。TypeScript 在防止它们方面做得非常好,但即使是它也无法防止诸如网络和文件系统故障、用户输入解析错误、堆栈溢出和内存耗尽等问题。它所做的——得益于其丰富的类型系统——是为您提供了处理最终导致这些运行时错误的多种方式。
在本章中,我将为您介绍 TypeScript 中表示和处理错误的最常见模式:
-
返回
null -
抛出异常
-
返回异常
-
Option类型
您使用哪种机制取决于您的应用程序。在我讨论每种错误处理机制时,我会谈论它的优缺点,以便您可以为自己做出正确选择。
返回null
我们将编写一个程序,询问用户的生日,然后将其解析为Date对象:
function ask() {
return prompt('When is your birthday?')
}
function parse(birthday: string): Date {
return new Date(birthday)
}
let date = parse(ask())
console.info('Date is', date.toISOString())
我们可能应该验证用户输入的日期——毕竟只是一个文本提示:
*`// ...`*
function parse(birthday: string): Date `|` `null` {
`let` `date` `=` `new` `Date``(``birthday``)`
`if` `(``!``isValid``(``date``)``)` `{`
`return` `null`
`}`
return `date`
}
// *`Checks if the given date is valid`*
`function` `isValid``(``date``:` `Date``)` `{`
`return` `Object``.``prototype``.``toString``.``call``(``date``)` `===` `'[object Date]'`
`&&` `!``Number``.``isNaN``(``date``.``getTime``(``)``)`
`}`
当我们使用时,我们被迫首先检查结果是否为null,然后才能使用它:
// ... let date = parse(ask())
`if` `(``date``)` `{`
console.info('Date is', date.toISOString())
`}` `else` `{`
`console``.``error``(``'Error parsing date for some reason'``)`
`}`
返回null是一种在类型安全的方式下处理错误的最轻量级方法。有效的用户输入结果是一个Date,无效的用户输入是一个null,而类型系统会检查我们是否处理了这两种情况。
然而,通过这种方式我们会丢失一些信息 parse并没有告诉我们确切的操作失败原因,这对于不得不查阅我们的日志来调试的工程师和接收到弹出窗口显示“由于某些原因解析日期出错”的用户来说都很糟糕,而不是一个具体的、可操作的错误,比如“请输入形式为 YYYY/MM/DD 的日期”。
返回 null 也很难组合:在每次操作后都必须检查 null,随着嵌套和链接操作的增加,这可能会变得冗长。
抛出异常
让我们抛出一个异常,而不是返回 null,这样我们就可以处理特定的失败模式,并且可以获得一些关于失败的元数据,以便更容易地调试它。
*`// ...`*
function parse(birthday: string): `Date` {
let date = new Date(birthday)
if (!isValid(date)) {
`throw` `new` `RangeError``(``'Enter a date in the form YYYY/MM/DD'``)`
}
return date
}
现在当我们使用这段代码时,我们需要小心捕获异常,以便我们可以优雅地处理它,而不会使整个应用程序崩溃:
*`// ...`*
`try` `{`
let date = parse(ask())
console.info('Date is', date.toISOString())
`}` `catch` `(``e``)` `{`
`console``.``error``(``e``.``message``)`
`}`
我们可能希望小心地重新抛出其他异常,以免无声地吞咽每一个可能的错误:
*`// ...`*
try {
let date = parse(ask())
console.info('Date is', date.toISOString())
} catch (e) {
`if` `(``e` `instanceof` `RangeError``)` `{`
console.error(e.message)
`}` `else` `{`
`throw` `e`
`}`
}
我们可能希望为某些更具体的内容创建错误的子类,以便当另一个工程师更改 parse 或 ask 以抛出其他 RangeError 时,我们可以区分我们的错误和他们添加的错误:
*`// ...`*
*`// Custom error types`* `class` `InvalidDateFormatError` `extends` `RangeError` `{``}`
`class` `DateIsInTheFutureError` `extends` `RangeError` `{``}`
function parse(birthday: string): Date {
let date = new Date(birthday)
if (!isValid(date)) {
throw `new` `InvalidDateFormatError`('Enter a date in the form YYYY/MM/DD')
}
`if` `(``date``.``getTime``(``)` `>` `Date``.``now``(``)``)` `{`
`throw` `new` `DateIsInTheFutureError``(``'Are you a timelord?'``)`
`}`
return date
}
try {
let date = parse(ask())
console.info('Date is', date.toISOString())
} catch (e) {
if (e instanceof `InvalidDateFormatError`) {
console.error(e.message)
} `else` `if` `(``e` `instanceof` `DateIsInTheFutureError``)` `{`
`console``.``info``(``e``.``message``)`
} else {
throw e
}
}
看起来不错。现在我们不仅可以表示某些事情失败了,而且可以使用自定义错误来指示为什么它失败了。当通过服务器日志调试问题时,这些错误可能会派上用场,或者我们可以将它们映射到特定的错误对话框,向用户提供有关他们做错了什么以及如何修复的可操作反馈。我们还可以通过在单个 try/catch 中包装任意数量的操作来有效地链接和嵌套操作(我们不必像返回 null 时那样检查每个操作的失败)。
使用这段代码感觉如何?假设大的 try/catch 在一个文件中,其余的代码在从其他地方导入的库中。工程师如何知道要捕获这些特定类型的错误(InvalidDateFormatError 和 DateIsInTheFutureError),或者甚至只是检查普通的 RangeError?(请记住,TypeScript 不会将异常编码为函数签名的一部分。)我们可以在函数名中指示它(parseThrows),或者在文档块中包含它:
`/** * @throws {InvalidDateFormatError} The user entered their birthday incorrectly. * @throws {DateIsInTheFutureError} The user entered a birthday in the future. */`
function parse(birthday: string): Date {
*`// ...`*
但在实际操作中,工程师可能根本不会在 try/catch 中包装此代码并检查异常,因为工程师都是懒惰的(至少我是),而且类型系统并未告诉他们他们错过了一个案例并应该处理它。然而,有时候——就像这个例子一样——错误是如此预期,以至于下游代码确实应该处理它们,以免导致程序崩溃。
我们还能以何种方式向使用者指示他们应该处理成功和错误的情况?
返回异常
TypeScript 不是 Java,不支持 throws 子句。^(1) 但我们可以使用联合类型实现类似的效果:
// ... function parse(
birthday: string
): Date `|` `InvalidDateFormatError` `|` `DateIsInTheFutureError` {
let date = new Date(birthday)
if (!isValid(date)) {
`return` new InvalidDateFormatError('Enter a date in the form YYYY/MM/DD')
}
if (date.getTime() > Date.now()) {
`return` new DateIsInTheFutureError('Are you a timelord?')
}
return date
}
现在,消费者被迫处理所有三种情况——InvalidDateFormatError、DateIsInTheFutureError 和成功的解析——否则他们将在编译时获得 TypeError:
// ...
let result = parse(ask()) // Either a date or an error
if (result instanceof InvalidDateFormatError) {
console.error(result.message)
} else if (result instanceof DateIsInTheFutureError) {
console.info(result.message)
} else {
console.info('Date is', result.toISOString())
}
在这里,我们成功地利用了 TypeScript 的类型系统来:
-
在
parse的签名中编码可能的异常。 -
向使用者传达可能会抛出的具体异常。
-
强制使用者处理(或重新抛出)每个异常。
一个懒惰的消费者可以避免单独处理每个错误。但他们必须显式地这样做:
*`// ...`*
let result = parse(ask()) // Either a date or an error if (result instanceof `Error`) {
console.error(result.message)
} else {
console.info('Date is', result.toISOString())
}
当然,你的程序可能因为内存不足或堆栈溢出异常而崩溃,但我们对此无能为力。
这种方法轻巧且不需要复杂的数据结构,但它也足够信息化,以便消费者知道错误代表的失败类型以及要搜索的更多信息。
一个缺点是,链式和嵌套的可能引发错误的操作可能会变得冗长。如果一个函数返回 T | Error1,那么消费该函数的任何函数都有两个选项:
-
显式处理
Error1。 -
处理
T(成功情况)并将Error1传递给其消费者进行处理。如果您做得足够多,消费者必须处理的可能错误列表会迅速增长:function x(): T | Error1 { // ... } function y(): U | Error1 | Error2 { let a = x() if (a instanceof Error) { return a } // Do something with a } function z(): U | Error1 | Error2 | Error3 { let a = y() if (a instanceof Error) { return a } // Do something with a }
这种方法冗长,但确保了我们的安全性。
选项类型
你也可以使用专用的数据类型来描述异常。与返回值和错误的联合相比,这种方法有一些缺点(特别是与不使用这些数据类型的代码的互操作性),但它确实使您能够在可能出错的计算中链式操作。三种最受欢迎的选项(哈!)是 Try、Option、^(2) 和 Either 类型。在本章中,我们只涵盖 Option 类型;^(3) 另外两种在精神上类似。
注意
请注意,Try、Option 和 Either 数据类型不像 Array、Error、Map 或 Promise 那样内置于 JavaScript 环境中。如果您想使用这些类型,您必须在 NPM 上找到实现,或者自己编写。
Option 类型源自像 Haskell、OCaml、Scala 和 Rust 这样的语言。其想法是,不返回一个值,而是返回一个容器,该容器可能包含或不包含值。容器上定义了几种方法,这让您能够链式操作,即使容器内实际上可能没有值。容器可以是几乎任何数据结构,只要它能保存一个值。例如,您可以使用数组作为容器:
*`// ...`*
function parse(birthday: string): `Date``[``]` {
let date = new Date(birthday)
if (!isValid(date)) {
`return` `[``]`
}
`return` `[``date``]`
}
let date = parse(ask())
`date`
`.``map``(``_` `=``>` `_``.``toISOString``(``)``)`
`.``forEach``(``_` `=``>` `console``.``info``(``'Date is'``,` `_``)``)`
注意
正如您可能已经注意到的那样,Option 的一个缺点是,就像我们最初的返回 null 的方法一样,它并不告诉消费者错误发生的原因;它只是表明出了问题。
Option 真正发光的时候是当您需要连续执行多个操作,每个操作都可能失败时。
例如,在之前我们假设 prompt 总是成功的,而 parse 可能失败。但如果 prompt 也可能失败呢?如果用户取消了生日提示,那就是一个错误,我们不应该继续计算。我们可以用另一个 Option 来建模!
function ask() {
`let` `result` `=` prompt('When is your birthday?')
`if` `(``result` `===` `null``)` `{`
`return` `[``]`
`}`
`return` `[``result``]`
}
*`// ...`*
`ask``(``)`
`.``map``(``parse``)`
.map(date => date.toISOString())
*`// Error TS2339: Property 'toISOString' does not exist on type 'Date[]'.`*
.forEach(date => console.info('Date is', date))
哎呀——那行不通。因为我们将Date数组(Date[])映射为Date数组的数组(Date[][]),所以我们需要将其展平为Date数组,然后才能继续:
`flatten``(`ask()
.map(parse)`)`
.map(date => date.toISOString())
.forEach(date => console.info('Date is', date))
*`// Flattens an array of arrays into an array`*
`function` `flatten``<``T``>``(``array``:` `T``[``]``[``]``)``:` `T``[``]` `{`
`return` `Array``.``prototype``.``concat``.``apply``(``[``]``,` `array``)`
`}`
这一切看起来有点笨重。因为类型没有告诉你太多(一切都是常规数组),一眼看不出代码的运行情况。为了解决这个问题,让我们将我们正在做的事情——将一个值放入容器中,公开操作该值的方法,并公开从容器中获取结果的方法——封装在一个特殊的数据类型中,以帮助文档化我们的方法。一旦我们完成了实现,您将能够像这样使用数据类型:
ask()
.flatMap(parse)
.flatMap(date => new Some(date.toISOString()))
.flatMap(date => new Some('Date is ' + date))
.getOrElse('Error parsing date for some reason')
我们将如下定义我们的Option类型:
-
Option是一个接口,由两个类实现:Some<T>和None(参见图 7-1)。它们是两种Option。Some<T>是包含类型为T的值的Option,而None是没有值的Option,表示失败。 -
Option既是一个类型,也是一个函数。它的类型是一个接口,仅作为Some和None的超类型。它的函数是创建类型为Option的新值的方法。

图 7-1. Option<T>有两种情况:Some<T>和None
让我们从草图开始:
interface Option<T> {} 
class Some<T> implements Option<T> { 
constructor(private value: T) {}
}
class None implements Option<never> {} 
Option<T>是一个接口,我们将在Some<T>和None之间共享。
Some<T>代表成功的操作结果值。像我们之前使用的数组一样,Some<T>是该值的容器。
None表示操作失败,不包含值。
在我们基于数组的Option实现中,这些类型等同于以下内容:
-
Option<T>是[T] | []。 -
Some<T>是[T]。 -
None是[]。
您可以如何处理Option?对于我们的基本实现,我们将仅定义两个操作:
flatMap
用于链式操作可能为空的Option的方法。
getOrElse
从Option中检索值的方法。
我们将首先在我们的Option接口上定义这些操作,这意味着Some<T>和None将需要为它们提供具体的实现:
interface Option<T> {
`flatMap``<``U``>``(``f``:` `(``value``:` `T``)` `=``>` `Option``<``U``>``)``:` `Option``<``U``>`
`getOrElse``(``value``:` `T``)``:` `T`
}
class Some<T> extends Option<T> {
constructor(private value: T) {}
}
class None extends Option<never> {}
即:
-
flatMap接受一个函数f,该函数接受类型为T的值(Option包含的值的类型),并返回类型为U的Option。flatMap调用f与Option的值,并返回一个新的Option<U>。 -
getOrElse接受与Option包含的值相同类型的默认值T,并返回该默认值(如果Option为空的None)或Option的值(如果Option是Some<T>)。
在类型的指导下,让我们为Some<T>和None实现这些方法:
interface Option<T> {
flatMap<U>(f: (value: T) => Option<U>): Option<U>
getOrElse(value: T): T
}
class Some<T> implements Option<T> {
constructor(private value: T) {}
`flatMap``<``U``>``(``f``:` `(``value``:` `T``)` `=``>` `Option``<``U``>``)``:` `Option``<``U``>` `{` 
`return` `f``(``this``.``value``)`
`}`
`getOrElse``(``)``:` `T` `{` 
`return` `this``.``value`
`}`
}
class None implements Option<never> {
`flatMap``<``U``>``(``)``:` `Option``<``U``>` `{` 
`return` `this`
`}`
`getOrElse``<``U``>``(``value``:` `U``)``:` `U` `{` 
`return` `value`
`}`
}
当我们在Some<T>上调用flatMap时,我们传入一个函数f,flatMap会使用Some<T>的值调用它,以产生一个新类型的Option。
在Some<T>上调用getOrElse只会返回Some<T>的值。
因为None表示一个失败的计算,调用它的flatMap总是返回None:一旦计算失败,我们无法从这个失败中恢复(至少在我们特定的Option实现中是这样)。
在None上调用getOrElse总是返回我们传递给getOrElse的值。
实际上,我们可以进一步完善这个天真的实现,并更好地指定我们的类型。如果你只知道你有一个Option和一个从T到Option<U>的函数,那么Option<T>总是flatMap到Option<U>。但当你知道你有一个Some<T>或None时,你可以更具体地指定。
表 7-1 展示了在两种Option类型上调用flatMap时我们想要的结果类型。
表 7-1. 在Some<T>和None上调用.flatMap(f)的结果
来自 Some<T> |
来自 None |
|
|---|---|---|
对于 Some<U> |
Some<U> |
None |
对于 None |
None |
None |
换句话说,我们知道在None上映射总是导致None,而在Some<T>上映射则会导致Some<T>或None,具体取决于调用f返回什么。我们将利用这一点并使用重载签名来为flatMap提供更具体的类型:
interface Option<T> {
`flatMap``<``U``>``(``f``:` `(``value``:` `T``)` `=``>` `None``)``:` `None`
flatMap<U>(f: (value: T) => Option<U>): Option<U>
getOrElse(value: T): T
}
class Some<T> implements Option<T> {
constructor(private value: T) {}
`flatMap``<``U``>``(``f``:` `(``value``:` `T``)` `=``>` `None``)``:` `None`
`flatMap``<``U``>``(``f``:` `(``value``:` `T``)` `=``>` `Some``<``U``>``)``:` `Some``<``U``>`
flatMap<U>(f: (value: T) => Option<U>): Option<U> {
return f(this.value)
}
getOrElse(): T {
return this.value
}
}
class None implements Option<never> {
`flatMap``(``)``:` `None` {
return this
}
getOrElse<U>(value: U): U {
return value
}
}
我们快完成了。剩下的就是实现Option函数,我们将用它来创建新的Option。我们已经将Option 类型 实现为一个接口;现在我们将实现一个同名的函数(请记住 TypeScript 为类型和值分别有两个独立的命名空间),用于创建类似于我们在“伴随对象模式”中所做的新Option。如果用户传入null或undefined,我们将返回None;否则,我们将返回Some。再次,我们将重载签名来实现这一点:
function Option<T>(value: null | undefined): None 
function Option<T>(value: T): Some<T> 
function Option<T>(value: T): Option<T> { 
if (value == null) {
return new None
}
return new Some(value)
}
如果消费者用null或undefined调用Option,我们会返回None。
否则,我们返回一个Some<T>,其中T是用户传入的值的类型。
最后,我们手动计算了两个重载签名的上界。null | undefined 和 T 的上界是 T | null | undefined,简化为 T。None 和 Some<T> 的上界是 None | Some<T>,我们已经有一个名称:Option<T>。
就这样。我们得到了一个完全工作的、最小的Option类型,让我们能够安全地处理可能为null的值。我们可以像这样使用它:
let result = Option(6) // Some<number>
.flatMap(n => Option(n * 3)) // Some<number>
.flatMap(n => new None) // None
.getOrElse(7) // 7
回到我们的生日提示示例,我们的代码现在按照我们的预期工作:
ask() // Option<string>
.flatMap(parse) // Option<Date>
.flatMap(date => new Some(date.toISOString())) // Option<string>
.flatMap(date => new Some('Date is ' + date)) // Option<string>
.getOrElse('Error parsing date for some reason') // string
Option是处理可能成功或可能失败的一系列操作的强大方式。它为你提供了优秀的类型安全性,并通过类型系统向使用者发出信号,表明特定操作可能会失败。
然而,Option并不是没有缺点。它们通过None表示失败,因此你无法获得更多关于失败及其原因的详细信息。它们也无法与不使用Option的代码互操作(你必须显式地包装那些 API 以返回Option)。
尽管如此,你在那里所做的事情非常棒。你添加的重载使你能够做一些在大多数语言中无法表达的事情,即使是那些依赖于Option类型处理可空值的语言也是如此。通过限制Option尽可能地只有Some或None,你使你的代码更加安全,让许多 Haskell 程序员都很羡慕。现在去拿一杯冷饮庆祝一下吧,你值得拥有。
摘要
在本章中,我们讨论了在 TypeScript 中信号和从错误中恢复的不同方法:返回null,抛出异常,返回异常以及Option类型。你现在拥有一系列安全处理可能失败的方法。你选择哪种方法取决于你,并且取决于:
-
无论你只是想简单地表明某些操作失败了(
null,Option),还是想提供更多关于失败原因的信息(抛出异常并返回)。 -
无论你是否想要强制消费者显式处理每一个可能的异常(返回异常),还是减少错误处理的样板代码(抛出异常)。
-
无论你需要一种组合错误的方式(
Option),还是仅仅在错误发生时处理它们(null,异常)。
练习
-
设计一种处理以下 API 错误的方法,使用本章中的一个模式。在这个 API 中,每个操作都可能失败——随意更新 API 的方法签名以允许失败(或者如果你喜欢的话,不要这样做)。考虑如何在处理出现的错误时执行一系列操作(例如,获取已登录用户的 ID,然后获取他们的朋友列表,然后获取每个朋友的名称):
class API { getLoggedInUserID(): UserID getFriendIDs(userID: UserID): UserID[] getUserName(userID: UserID): string }
^(1) 如果你之前没有使用过 Java,throws子句表示方法可能会抛出哪些类型的运行时异常,因此使用者必须处理这些异常。
^(2) 也称为Maybe类型。
^(3) 搜索“try 类型”或“either 类型”获取更多关于这些类型的信息。
第八章:异步编程、并发和并行
到目前为止,在本书中,我们主要处理了同步程序 —— 程序接受一些输入,完成一些任务,并在单次执行中运行完成。但真正有趣的程序 —— 现实世界应用的构建模块,它们发起网络请求、与数据库和文件系统交互、响应用户交互、将 CPU 密集型工作转移到单独的线程 —— 都利用了像回调、Promise 和流这样的异步 API。
这些异步任务正是 JavaScript 真正闪耀并与其他主流多线程语言如 Java 和 C++ 区分开来的地方。像 V8 和 SpiderMonkey 这样的流行 JavaScript 引擎利用单线程做多线程的事情,通过巧妙地将任务多路复用到单线程,而其他任务则处于空闲状态。这种 事件循环 是 JavaScript 引擎的标准线程模型,也是我们假设你正在使用的模型。从最终用户的角度来看,你的引擎使用事件循环模型还是多线程模型通常并不重要,但它影响了我对事物如何工作以及我们设计事物方式的解释。
这种事件循环并发模型是 JavaScript 避免多线程编程中所有常见陷阱的方式,同时也避免了同步数据类型、互斥锁、信号量以及多线程术语的所有开销。当你在多线程上运行 JavaScript 时,很少使用共享内存;典型模式是使用消息传递,在发送数据时对其进行序列化。这种设计让人想起了 Erlang、actor 系统和其他纯函数并发模型,并且正是这种设计使得 JavaScript 中的多线程编程变得无懈可击。
尽管如此,异步编程确实使得程序更难理解,因为你不能再逐行 mentally 通过程序;你必须知道何时暂停并将执行移至其他地方,以及何时恢复执行。
TypeScript 为我们提供了理解异步程序的工具:类型让我们追踪异步工作,而内置对 async/await 的支持则让我们将熟悉的同步思维应用到异步程序中。我们还可以使用 TypeScript 为多线程程序指定严格的消息传递协议(比听起来简单得多)。如果所有其他方法都失败了,当你的同事的异步代码变得过于复杂而你不得不加班调试时,TypeScript 可以给你一个后背按摩(当然,前提是启用了编译器标志)。
但在我们开始处理异步程序之前,让我们再谈谈现代 JavaScript 引擎中异步性是如何工作的 —— 我们如何在看似单线程的情况下暂停和恢复执行?
JavaScript 的事件循环
让我们以一个例子开始。我们将设置几个定时器,一个在一毫秒后触发,另一个在两毫秒后触发:
setTimeout(() => console.info('A'), 1)
setTimeout(() => console.info('B'), 2)
console.info('C')
现在,控制台会输出什么呢?是A,B,C吗?
如果你是 JavaScript 程序员,你本能地知道答案是否定的——实际的触发顺序是C,A,然后是B。如果你之前没有使用过 JavaScript 或 TypeScript,这种行为可能看起来神秘和不直观。实际上,这很简单;只是它不遵循 C 中的sleep或 Java 中调度工作的并发模型。
在高层次上,JavaScript 虚拟机以这种方式模拟并发(见图 8-1):
-
主 JavaScript 线程调用本地的异步 API,例如
XMLHTTPRequest(用于 AJAX 请求),setTimeout(用于延时),readFile(用于从磁盘读取文件)等等。这些 API 是由 JavaScript 平台提供的 —— 你不能自己创建它们。^(1) -
一旦调用了本地的异步 API,控制会返回到主线程,并且执行会继续,就好像从未调用过 API 一样。
-
一旦异步操作完成,平台会将一个任务放入其事件队列中。每个线程都有自己的队列,用于将异步操作的结果传递回主线程。任务包括有关调用的一些元信息,以及来自主线程的回调函数的引用。
-
每当主线程的调用栈清空时,平台就会检查其事件队列中是否有待处理的任务。如果有等待的任务,平台会执行它;这会触发一个函数调用,并且控制会返回到主线程函数。当由该函数调用产生的调用栈再次为空时,平台会再次检查事件队列,查看是否有准备就绪的任务。这个循环重复进行,直到调用栈和事件队列都为空,并且所有异步本地 API 调用都已完成。

图 8-1. JavaScript 的事件循环:当调用异步 API 时会发生什么
有了这些信息,现在是时候回到我们的setTimeout示例了。以下是发生的事情:
-
我们调用
setTimeout,它调用一个带有我们传入的回调函数和参数1的本地超时 API。 -
我们再次调用
setTimeout,它再次使用我们传入的第二个回调函数和参数2调用本地的超时 API。 -
我们将
C记录到控制台中。 -
在后台,在至少一毫秒后,我们的 JavaScript 平台会向其事件队列中添加一个任务,指示第一个
setTimeout的超时已经过去,并且它的回调现在可以被调用了。 -
再过一毫秒,平台会为第二个
setTimeout的回调添加第二个任务到事件队列中。 -
自从调用栈为空后,在步骤 3 完成后,平台会查看其事件队列,看看是否有任何任务。如果步骤 4 和/或 5 完成,它将找到一些任务。对于每个任务,它将调用相应的回调函数。
-
一旦两个定时器都已经超时,并且事件队列和调用堆栈为空,程序就会退出。
这就是为什么我们记录了 C、A、B,而不是 A、B、C。有了这个基础,我们可以开始讨论如何安全地为异步代码编写类型。
使用回调函数
异步 JavaScript 程序的基本单元是 回调函数。回调函数是你作为参数传递给另一个函数的普通函数。就像同步程序一样,当另一个函数完成其操作(如发出网络请求等),它会调用你的函数。被异步代码调用的回调函数只是普通的函数,在它们的类型签名中没有任何特殊标记表明它们是异步调用。
对于像 fs.readFile(用于从磁盘异步读取文件内容)和 dns.resolveCname(用于异步解析 CNAME 记录)这样的 NodeJS 原生 API,回调函数的约定是第一个参数是错误或 null,第二个参数是结果或 null。
readFile 的类型签名如下所示:
function readFile(
path: string,
options: {encoding: string, flag?: string},
callback: (err: Error | null, data: string | null) => void
): void
注意,readFile 的类型或 callback 的类型都没有任何特殊之处:它们都是普通的 JavaScript 函数。从签名上看,看不出 readFile 是异步的,以及在调用 readFile 后控制将传递到下一行(不等待其结果)。
注意
要自行运行以下示例,请确保首先安装 NodeJS 的类型声明:
npm install @types/node --save-dev
要了解更多关于第三方类型声明的信息,请跳到“在 DefinitelyTyped 上有类型声明的 JavaScript”。
例如,让我们编写一个 NodeJS 程序,读取并写入到你的 Apache 访问日志中:
import * as fs from 'fs'
// Read data from an Apache server's access log
fs.readFile(
'/var/log/apache2/access_log',
{encoding: 'utf8'},
(error, data) => {
if (error) {
console.error('error reading!', error)
return
}
console.info('success reading!', data)
}
)
// Concurrently, write data to the same access log
fs.appendFile(
'/var/log/apache2/access_log',
'New access log entry',
error => {
if (error) {
console.error('error writing!', error)
}
})
除非你是 TypeScript 或 JavaScript 工程师,并且熟悉 NodeJS 的内置 API 如何工作,并且知道它们是异步的,并且不能依赖 API 调用在代码中出现的顺序来决定文件系统操作实际发生的顺序,否则你不会知道我们刚刚引入了一个微妙的 bug,即第一个 readFile 调用可能会或可能不会返回访问日志及其新行添加情况,这取决于文件系统在代码运行时的繁忙程度。
你可能知道 readFile 是异步的是因为经验,或者因为你在 NodeJS 的文档中看到过,或者因为你知道 NodeJS 通常遵循的约定:如果一个函数的最后一个参数是一个接受两个参数的函数——一个 Error | null 和一个 T | null,那么通常这个函数是异步的,或者因为你跑到邻居家借了一杯糖,结果聊了一会,然后不知怎么地谈到了 NodeJS 中的异步编程,他们告诉了你几个月前他们遇到类似问题及其如何解决的经历。
无论是什么,类型肯定不会帮助你到达那里。
除了你不能使用类型来帮助引导你对函数同步性质的直觉之外,回调在排序上也很难,这可能导致一些人所说的“回调金字塔”:
async1((err1, res1) => {
if (res1) {
async2(res1, (err2, res2) => {
if (res2) {
async3(res2, (err3, res3) => {
// ...
})
}
})
}
})
在排序操作时,通常希望在操作成功时继续向下执行,一旦遇到错误就退出。使用回调时,你必须手动处理这些情况;如果还要考虑同步错误(例如,当你给 NodeJS 一个类型错误的参数时,它的惯例是throw而不是调用你提供的回调函数来处理Error对象),正确地排序回调可能会变得容易出错。
而排序只是你可能想要在异步任务上运行的一种操作——你可能还想要并行运行函数以知晓它们何时全部完成,或者比赛以获取第一个完成的结果等等。
这是纯回调的一个局限。如果没有更复杂的用于处理异步任务的抽象,使用多个彼此依赖的回调可能会很快变得混乱。
总结一下:
-
使用回调执行简单的异步任务。
-
虽然回调很适合建模简单任务,但在尝试处理大量异步任务时,它们很快变得棘手。
用 Promises 恢复理智
幸运的是,我们不是第一个遇到这些限制的程序员。在本节中,我们将开发promises的概念,这是一种抽象异步工作的方式,使我们可以组合、排序等。即使你之前有过使用 promises 或 futures 的经验,这也将是一个有益的练习来理解它们的工作原理。
注意
大多数现代 JavaScript 平台都内置支持 Promise。在本节中,我们将开发自己的部分Promise实现作为练习,但实际上,你应该使用内置的或现成的实现。检查你喜欢的平台是否支持 Promise,请点击这里,或跳转到“lib”了解如何在不支持的平台上使用 polyfill 填充 promises。
我们将从一个示例开始,展示我们如何使用Promise首先向文件追加内容,然后读回结果:
function appendAndReadPromise(path: string, data: string): Promise<string> {
return appendPromise(path, data)
.then(() => readPromise(path))
.catch(error => console.error(error))
}
注意这里没有回调金字塔——我们已经有效地将我们想要做的事情线性化成了一个简单易懂的异步任务链。一个成功后,下一个就运行;如果失败,我们跳转到catch子句。使用基于回调的 API,这可能看起来更像:
function appendAndRead(
path: string,
data: string
cb: (error: Error | null, result: string | null) => void
) {
appendFile(path, data, error => {
if (error) {
return cb(error, null)
}
readFile(path, (error, result) => {
if (error) {
return cb(error, null)
}
cb(null, result)
})
})
}
让我们设计一个Promise API,让我们可以做到这一点。
Promise 从一开始就很谦逊:
class Promise {
}
一个new Promise接受一个我们称为executor的函数,Promise实现将调用该函数,并传入两个参数,一个resolve函数和一个reject函数:
type Executor = (
resolve: Function,
reject: Function
) => void
class Promise {
constructor(f: Executor) {}
}
resolve和reject如何工作?让我们通过思考如何手动将基于回调的 NodeJS API,如fs.readFile,包装成基于Promise的 API 来演示它。我们像这样使用 NodeJS 的内置fs.readFile API:
import {readFile} from 'fs'
readFile(path, (error, result) => {
// ...
})
将该 API 包装在我们的Promise实现中,现在看起来像这样:
import {readFile} from 'fs'
function readFilePromise(path: string): Promise<string> {
return new Promise((resolve, reject) => {
readFile(path, (error, result) => {
if (error) {
reject(error)
} else {
resolve(result)
}
})
})
}
因此,resolve参数的类型取决于我们使用的特定 API(在本例中,其参数类型将是result的类型),而reject参数的类型始终是某种Error类型。回到我们的实现,让我们通过用更具体的类型替换我们不安全的Function类型来更新我们的代码:
type Executor`<``T``,` `E` `extends` `Error``>` = (
resolve: `(``result``:` `T``)` `=``>` `void`,
reject: `(``error``:` `E``)` `=``>` `void`
) => void
*`// ...`*
因为我们希望能够通过查看Promise来了解它将解析为的类型(例如,Promise<number>表示一个异步任务,其结果是一个number),我们将使Promise成为泛型,并将其类型参数传递给其构造函数中的Executor类型:
// ...
class Promise<T, E extends Error> {
constructor(f: Executor<T, E>) {}
}
到目前为止,我们已经定义了Promise的构造函数 API,并了解了所涉及的类型。现在,让我们考虑链式调用 - 我们想要公开哪些操作来运行一系列Promise,传播它们的结果并捕获它们的异常?如果回顾一下本节开头的初始代码示例,then和catch的作用就是这样。让我们将它们添加到我们的Promise类型中:
*`// ...`*
class Promise<T, E extends Error> {
constructor(f: Executor<T, E>) {}
`then``<``U``,` `F` `extends` `Error``>``(``g``:` `(``result``:` `T``)` `=``>` `Promise``<``U``,` `F``>``)``:` `Promise``<``U``,` `F``>`
`catch``<``U``,` `F` `extends` `Error``>``(``g``:` `(``error``:` `E``)` `=``>` `Promise``<``U``,` `F``>``)``:` `Promise``<``U``,` `F``>`
}
then和catch是顺序执行Promise的两种方式:then将Promise的成功结果映射到一个新的Promise,^(2) 而catch通过将错误映射到一个新的Promise来从拒绝中恢复。
使用then看起来像这样:
let a: () => Promise<string, TypeError> = // ...
let b: (s: string) => Promise<number, never> = // ...
let c: () => Promise<boolean, RangeError> = // ...
a()
.then(b)
.catch(e => c()) // b won't error, so this is if a errors
.then(result => console.info('Done', result))
.catch(e => console.error('Error', e))
因为b的第二个类型参数的类型是never(意味着b永远不会抛出错误),如果a出错,第一个catch子句才会被调用。但请注意,当我们使用Promise时,我们不需要关心a可能会抛出异常而b不会 - 如果a成功,则将Promise映射到b,否则跳转到第一个catch子句并映射Promise到c。如果c成功,则记录Done,如果拒绝,则再次catch。这模仿了常规的try/catch语句的工作方式,并且对异步任务执行了try/catch对同步任务的操作(参见 Figure 8-2)。

图 8-2. Promise 状态机
我们还必须处理抛出实际异常的Promise的情况(例如,throw Error('foo'))。在我们实现then和catch时,我们将通过使用try/catch包装代码并在catch子句中拒绝来处理这种情况。不过,这确实有一些影响。这意味着:
-
每个
Promise都有可能被拒绝,并且我们无法静态检查这一点(因为 TypeScript 不支持在函数签名中指示函数可能抛出哪些异常)。 -
Promise不一定会被Error拒绝。因为 TypeScript 别无选择,只能继承 JavaScript 的行为,而在 JavaScript 中,当你throw时,可以抛出任何东西——一个字符串、一个函数、一个数组、一个Promise,并且不一定是Error的子类型。这很不幸,但这是我们不强迫消费者在每个可能跨多个文件或模块的Promise链中使用try/catch的牺牲。
考虑到这一点,我们可以通过不对错误进行类型化来稍微放宽我们的Promise类型:
type Executor<T> = (
resolve: (result: T) => void,
reject: (error: unknown) => void
) => void
class Promise<T> {
constructor(f: Executor<T>) {}
then<U>(g: (result: T) => Promise<U>): Promise<U> {
// ...
}
catch<U>(g: (error: unknown) => Promise<U>): Promise<U> {
// ...
}
}
现在我们有了一个完全成熟的Promise接口。
我会让你练习把所有内容都用then和catch实现。Promise的实现非常棘手,正确书写起来非常困难——如果你雄心勃勃并且有几个小时空闲时间,可以去ES2015 规范了解一下Promise的状态机应该如何在底层工作。
异步和等待
Promise是处理异步代码的非常强大的抽象。它们是如此流行的一种模式,以至于它们甚至有了自己的 JavaScript(因此也是 TypeScript)语法:async和await。这种语法让你以与同步操作相同的方式与异步操作进行交互。
提示
把await想象成语言级别的语法糖,用于.then。当你await一个Promise时,必须在一个async块中这样做。而且,你可以用普通的try/catch块来包装你的await,而不是用.catch。
假设你有以下的Promise(我们在前面的章节中没有涵盖finally,但它的行为和你想象的一样,在then和catch都有机会执行之后才会执行):
function getUser() {
getUserID(18)
.then(user => getLocation(user))
.then(location => console.info('got location', location))
.catch(error => console.error(error))
.finally(() => console.info('done getting location'))
}
要将此代码转换为async和await,首先将其放入一个async函数中,然后await该Promise的结果:
`async` function getUser() {
try {
let user = `await` getUserID(18)
let location = `await` getLocation(user)
console.info('got location', user)
} catch(error) {
console.error(error)
} finally {
console.info('done getting location')
}
}
由于async和await是 JavaScript 的特性,我们在这里不会深入讨论它们——简单来说,TypeScript 完全支持它们,并且它们是完全类型安全的。在处理Promise时,请使用它们,以便更轻松地推理链式操作,并避免大量的then。要了解更多关于async和await的信息,请访问它们在MDN上的文档。
异步流
虽然 promises 在建模、顺序化和组合未来值方面表现出色,但如果您有多个值将在未来的多个时间点可用,该怎么办呢?这并不像听起来那么奇特——想象一下从文件系统读取文件的一部分位、从 Netflix 服务器通过互联网流式传输到您的笔记本电脑的视频像素、填写表单时的一堆按键、朋友们来到您家参加晚餐聚会,或者在超级星期二期间向选票箱投票。尽管这些事情在表面上看起来很不同,但你可以把它们都看作是异步流;它们都是一系列东西,其中每一件东西都将在未来的某个时间点到来。
有几种建模这种行为的方法,最常见的是使用事件发射器(例如 NodeJS 的EventEmitter)或使用响应式编程库(如RxJS)。它们之间的区别就像回调和 promises 之间的区别:事件快速且轻量,而响应式编程库更加强大,可以组合和顺序化事件流。
我们将在下一节介绍事件发射器。要了解更多关于响应式编程的信息,请查看您喜爱的响应式编程库的文档,例如RxJS、MostJS或xstream。
事件发射器
在高层次上,事件发射器提供了支持在通道上发出事件并监听该通道上事件的 API:
interface Emitter {
// Send an event
emit(channel: string, value: unknown): void
// Do something when an event is sent
on(channel: string, f: (value: unknown) => void): void
}
事件发射器是 JavaScript 中流行的设计模式。你可能在使用 DOM 事件、JQuery 事件或 NodeJS 的EventEmitter模块时遇到过它们。
大多数语言中,像这样的事件发射器是不安全的。这是因为value的类型取决于特定的channel,并且在大多数语言中,你无法使用类型来表示这种关系。除非你的语言支持重载函数签名和字面类型,否则你将很难说“这是在这个通道上发出的事件的类型”。生成用于发出事件和监听每个通道的方法的宏是解决这个问题的常见方法,但在 TypeScript 中,你可以通过类型系统自然而安全地表达这一点。
例如,假设我们正在使用NodeRedis 客户端,这是一个流行的 Redis 内存数据存储的 Node API。它的工作方式如下:
import Redis from 'redis'
// Create a new instance of a Redis client
let client = redis.createClient()
// Listen for a few events emitted by the client
client.on('ready', () => console.info('Client is ready'))
client.on('error', e => console.error('An error occurred!', e))
client.on('reconnecting', params => console.info('Reconnecting...', params))
作为使用 Redis 库的程序员,我们希望在使用onAPI 时知道在回调中可以期望哪些类型的参数。但是由于每个参数的类型取决于 Redis 发布的通道,单一类型将无法满足要求。如果我们是这个库的作者,实现安全的最简单方法是使用重载类型:
type RedisClient = {
on(event: 'ready', f: () => void): void
on(event: 'error', f: (e: Error) => void): void
on(event: 'reconnecting',
f: (params: {attempt: number, delay: number}) => void): void
}
这样做效果不错,但有些啰嗦。让我们用一个映射类型的术语来表达它(参见“映射类型”),将事件定义提取到它们自己的类型 Events 中:
type Events = { 
ready: void
error: Error
reconnecting: {attempt: number, delay: number}
}
type RedisClient = { 
on<E extends keyof Events>(
event: E,
f: (arg: Events[E]) => void
): void
}
我们首先定义一个单一对象类型,枚举了 Redis 客户端可能发出的每个事件,以及该事件的参数。
我们对我们的 Events 类型进行映射,告诉 TypeScript 可以使用我们定义的任何事件调用 on。
然后,我们可以使用此类型使 Node-Redis 库更安全,尽可能安全地为其两种方法—emit 和 on—进行类型化:
// ...
type RedisClient = {
on<E extends keyof Events>(
event: E,
f: (arg: Events[E]) => void
): void
emit<E extends keyof Events>(
event: E,
arg: Events[E]
): void
}
将事件名称和参数提取到一个形状中,并在该形状上进行映射以生成监听器和发射器,这种模式在真实的 TypeScript 代码中很常见。它也很简洁,非常安全。当发射器以这种方式类型化时,您不会拼错键,误输入参数,或者忘记传入参数。它还为使用您代码的工程师提供文档,因为他们的代码编辑器将为他们建议可能监听的事件及其回调中的参数类型。
类型安全的多线程
到目前为止,我们一直在讨论可能在单个 CPU 线程上运行的异步程序,这是大多数您编写的 JavaScript 和 TypeScript 程序可能属于的类别。但是有时,在执行 CPU 密集型任务时,您可能选择真正的并行性:在多个线程间分配工作,以便更快地执行或使主线程保持空闲和响应性。在本节中,我们将探讨一些在浏览器和服务器上编写安全并行程序的模式。
在浏览器中:使用 Web Workers
Web Workers 是在浏览器中进行多线程的广泛支持方式。您可以从主 JavaScript 线程中启动一些特殊的受限后台线程,并使用它们执行本来会阻塞主线程并使用户界面无响应的任务(即 CPU 绑定任务)。Web Workers 是在浏览器中以真正并行的方式运行代码的一种方式;而像 Promise 和 setTimeout 这样的异步 API 是并发运行代码的方式,Workers 则使您能够在另一个 CPU 线程上并行运行代码。Web Workers 可以发送网络请求,写入文件系统等,只有一些小的限制。
因为 Web Workers 是浏览器提供的 API,其设计者非常强调安全性——不是我们熟悉和喜爱的类型安全,而是 内存安全。任何写过 C、C++、Objective C 或多线程 Java 或 Scala 的人都知道并发操作共享内存的陷阱。当你有多个线程读写同一块内存时,很容易遇到各种并发问题,如非确定性、死锁等。
因为浏览器代码必须特别安全,并且尽量减少崩溃浏览器并造成不良用户体验的机会,主线程与 Web Workers 之间以及 Web Workers 之间的主要通信方式是 消息传递。
注意
要跟着本节中的示例操作,请确保通过在你的 tsconfig.json 中启用 dom 库来告诉 TSC 你打算在浏览器中运行这段代码:
{
"compilerOptions": {
"lib": ["dom", "es2015"]
}
}
对于在 Web Worker 中运行的代码,请使用 webworker 库:
{
"compilerOptions": {
"lib": ["webworker", "es2015"]
}
}
如果你在同一个 tsconfig.json 文件中同时使用 Web Worker 脚本和主线程脚本,请一起启用它们。
消息传递 API 的工作方式如下。首先从一个线程生成一个 Web Worker:
// MainThread.ts
let worker = new Worker('WorkerScript.js')
然后,你可以向该工作线程传递消息:
*`// MainThread.ts`*
let worker = new Worker('WorkerScript.js')
`worker``.``postMessage``(``'some data'``)`
你可以使用 postMessage API 将几乎任何类型的数据传递给另一个线程。^(4)
主线程会在交给工作线程之前克隆你传递的数据。^(5) 在 Web Worker 方面,你可以使用全局可用的 onmessage API 监听传入的事件:
// WorkerScript.ts
onmessage = e => {
console.log(e.data) // Logs out 'some data'
}
要在反方向——从工作线程回到主线程——进行通信,你可以使用全局可用的 postMessage 发送消息到主线程,并在主线程中使用 .onmessage 方法监听传入的消息。将所有内容整合起来:
// MainThread.ts
let worker = new Worker('WorkerScript.js')
worker.onmessage = e => {
console.log(e.data) // Logs out 'Ack: "some data"'
}
worker.postMessage('some data')
// WorkerScript.ts
onmessage = e => {
console.log(e.data) // Logs out 'some data'
postMessage(Ack: "${e.data}")
}
这个 API 很像我们在 “事件发射器” 中看到的事件发射器 API。这是一种简单的消息传递方式,但没有类型信息,我们无法确保我们已经正确处理了可能发送的所有消息类型。
由于这个 API 实际上只是一个事件发射器,我们可以像对待常规事件发射器一样对待它。例如,让我们为聊天客户端构建一个简单的消息层,在工作线程中运行。消息层将向主线程推送更新,我们不必担心诸如错误处理、权限等问题。我们首先定义一些传入和传出的消息类型(主线程向工作线程发送 Commands,工作线程向主线程发送 Events):
// MainThread.ts
type Message = string
type ThreadID = number
type UserID = number
type Participants = UserID[]
type Commands = {
sendMessageToThread: [ThreadID, Message]
createThread: [Participants]
addUserToThread: [ThreadID, UserID]
removeUserFromThread: [ThreadID, UserID]
}
type Events = {
receivedMessage: [ThreadID, UserID, Message]
createdThread: [ThreadID, Participants]
addedUserToThread: [ThreadID, UserID]
removedUserFromThread: [ThreadID, UserID]
}
我们如何将这些类型应用到 Web Worker 消息传递 API?最简单的方法可能是定义所有可能消息类型的联合,然后根据 Message 类型进行切换。但这可能会变得非常乏味。对于我们的 Command 类型,可能看起来像这样:
// WorkerScript.ts type Command = 
| {type: 'sendMessageToThread', data: [ThreadID, Message]} 
| {type: 'createThread', data: [Participants]}
| {type: 'addUserToThread', data: [ThreadID, UserID]}
| {type: 'removeUserFromThread', data: [ThreadID, UserID]}
onmessage = e => 
processCommandFromMainThread(e.data)
function processCommandFromMainThread( 
command: Command
) {
switch (command.type) { 
case 'sendMessageToThread':
let [threadID, message] = command.data
console.log(message)
// ...
}
}
我们定义了主线程可能发送到工作线程的所有可能命令的联合,并附带每个命令的参数。
这只是一个常规的联合类型。在定义长联合类型时,以管道符号(|)开头可以使这些类型更易于阅读。
我们接收通过未类型化的onmessage API 发送的消息,并委托处理它们给我们的已类型化processCommandFromMainThread API。
processCommandFromMainThread 负责处理主线程发送的所有传入消息。它是未类型化onmessage API 的安全、类型化包装器。
由于Command类型是一个区分联合类型(参见[[discriminated unions]]),我们使用switch来详尽处理主线程可能发送给我们的每一种消息类型。
让我们将 Web Workers 的雪花 API 抽象到一个熟悉的基于EventEmitter的 API 后面。这样我们可以减少我们传入和传出的消息类型的冗长。
我们将从构建 NodeJS 的EventEmitter API 的类型安全包装器开始(该 API 在 NPM 上的 events package 中也适用于浏览器):
import EventEmitter from 'events'
class SafeEmitter<
Events extends Record<PropertyKey, unknown[]> 
> {
private emitter = new EventEmitter 
emit<K extends keyof Events>( 
channel: K,
...data: Events[K]
) {
return this.emitter.emit(channel, ...data)
}
on<K extends keyof Events>( 
channel: K,
listener: (...data: Events[K]) => void
) {
return this.emitter.on(channel, listener)
}
}
SafeEmitter 声明一个泛型类型Events,一个从PropertyKey(TypeScript 中有效对象键的内置类型:string、number 或 Symbol)到参数列表的映射。
我们将emitter声明为SafeEmitter上的私有成员。我们这样做是因为我们的emit和on的签名比它们在EventEmitter中重载的对应签名更严格,而且由于函数在它们的参数中是逆变的(记住,对于一个函数a能够分配给另一个函数b,它的参数必须是b中对应参数的超类型),TypeScript 不会让我们声明这些重载。
emit 接受一个channel以及与我们在Events类型中定义的参数列表对应的参数。
同样,on接受一个channel和一个listener。listener接受与我们在Events类型中定义的参数列表相对应的可变数量的参数。
我们可以使用SafeEmitter大大减少安全实现监听层所需的样板代码。在工作线程上,我们将所有onmessage调用委托给我们的发射器,并为消费者暴露一个方便且安全的监听器 API:
// WorkerScript.ts
type Commands = {
sendMessageToThread: [ThreadID, Message]
createThread: [Participants]
addUserToThread: [ThreadID, UserID]
removeUserFromThread: [ThreadID, UserID]
}
type Events = {
receivedMessage: [ThreadID, UserID, Message]
createdThread: [ThreadID, Participants]
addedUserToThread: [ThreadID, UserID]
removedUserFromThread: [ThreadID, UserID]
}
// Listen for events coming from the main thread
let commandEmitter = new SafeEmitter <Commands>()
// Emit events back to the main thread
let eventEmitter = new SafeEmitter <Events>()
// Wrap incoming commands from the main thread
// using our typesafe event emitter
onmessage = command =>
commandEmitter.emit(
command.data.type,
...command.data.data
)
// Listen for events issued by the worker, and send them to the main thread
eventEmitter.on('receivedMessage', data =>
postMessage({type: 'receivedMessage', data})
)
eventEmitter.on('createdThread', data =>
postMessage({type: 'createdThread', data})
)
// etc.
// Respond to a sendMessageToThread command from the main thread
commandEmitter.on('sendMessageToThread', (threadID, message) =>
console.log(OK, I will send a message to threadID ${threadID})
)
// Send an event back to the main thread
eventEmitter.emit('createdThread', 123, [456, 789])
另一方面,我们也可以使用基于EventEmitter的 API,将命令从主线程发送回工作线程。请注意,如果您在自己的代码中使用此模式,您可能考虑使用更全面的发射器(例如 Paolo Fragomeni 的优秀EventEmitter2),它支持通配符监听器,这样您就不必为每种事件类型手动添加监听器:
// MainThread.ts
type Commands = {
sendMessageToThread: [ThreadID, Message]
createThread: [Participants]
addUserToThread: [ThreadID, UserID]
removeUserFromThread: [ThreadID, UserID]
}
type Events = {
receivedMessage: [ThreadID, UserID, Message]
createdThread: [ThreadID, Participants]
addedUserToThread: [ThreadID, UserID]
removedUserFromThread: [ThreadID, UserID]
}
let commandEmitter = new SafeEmitter <Commands>()
let eventEmitter = new SafeEmitter <Events>()
let worker = new Worker('WorkerScript.js')
// Listen for events coming from our worker,
// and re-emit them using our typesafe event emitter
worker.onmessage = event =>
eventEmitter.emit(
event.data.type,
...event.data.data
)
// Listen for commands issues by this thread, and send them to our worker
commandEmitter.on('sendMessageToThread', data =>
worker.postMessage({type: 'sendMessageToThread', data})
)
commandEmitter.on('createThread', data =>
worker.postMessage({type: 'createThread', data})
)
// etc.
// Do something when the worker tells us a new thread was created
eventEmitter.on('createdThread', (threadID, participants) =>
console.log('Created a new chat thread!', threadID, participants)
)
// Send a command to our worker
commandEmitter.emit('createThread', [123, 456])
就这样!我们已经创建了一个简单的类型安全包装器,用于熟悉的事件发射器抽象,可以在各种设置中使用,从浏览器中的光标事件到线程间的通信,使得线程间消息传递变得安全。这是 TypeScript 中的常见模式:即使某些内容不安全,您通常也可以将其包装在类型安全的 API 中。
类型安全的协议
到目前为止,我们已经看到如何在两个线程之间传递消息。如果要扩展技术以确保特定命令始终接收特定事件作为响应,需要做些什么?
让我们构建一个简单的调用-响应协议,用于将函数评估移动到线程之间。我们无法轻松地在线程之间传递函数,但是我们可以在工作线程中定义函数并将参数发送到它们,然后将结果发送回来。例如,假设我们正在构建一个支持三个操作的矩阵数学引擎:查找矩阵的行列式、计算两个矩阵的点积以及求逆矩阵。
你知道怎么做了——让我们首先勾画出这三个操作的类型:
type Matrix = number[][]
type MatrixProtocol = {
determinant: {
in: [Matrix]
out: number
}
'dot-product': {
in: [Matrix, Matrix]
out: Matrix
}
invert: {
in: [Matrix]
out: Matrix
}
}
我们在主线程中定义矩阵,并在工作线程中运行所有计算。再次强调,其思想是用安全的操作包装一个不安全的操作(向工作线程发送和接收未类型化的消息),为消费者暴露一个定义良好、类型化的 API。在这个简单的实现中,我们首先定义了一个简单的请求-响应协议Protocol,列出了工作线程可以执行的操作及其预期的输入和输出类型。^(6) 然后,我们定义了一个通用的createProtocol函数,该函数接受一个Protocol和一个指向 Worker 的文件路径,并返回一个函数,该函数接受该协议中的一个command,并返回一个我们可以调用以实际评估该command的最终函数的函数。好的,我们开始吧:
type Protocol = { 
[command: string]: {
in: unknown[]
out: unknown
}
}
function createProtocol<P extends Protocol>(script: string) { 
return <K extends keyof P>(command: K) => 
(...args: P[K]['in']) => 
new Promise<P[K]['out']>((resolve, reject) => { 
let worker = new Worker(script)
worker.onerror = reject
worker.onmessage = event => resolve(event.data.data)
worker.postMessage({command, args})
})
}
我们首先定义了一个通用的Protocol类型,该类型不特定于我们的MatrixProtocol。
当我们调用createProtocol时,我们传入了一个工作脚本的文件路径,以及一个特定的Protocol。
createProtocol返回一个匿名函数,然后我们可以用一个command来调用它,该command是我们在中绑定的
Protocol的键。
然后,我们使用传入的命令的具体in类型调用该函数在。
这将为我们返回一个Promise,该Promise是我们特定协议中特定out类型的。请注意,我们必须显式地将类型参数绑定到Promise,否则它将默认为{}。
现在让我们应用我们的MatrixProtocol类型加上我们的 Web Worker 脚本的路径到createProtocol(我们不会深入探讨如何计算行列式的细节,我会假设你已经在MatrixWorkerScript.ts中实现了它)。我们将得到一个函数,我们可以用来运行该协议中的特定命令:
let runWithMatrixProtocol = createProtocol<MatrixProtocol>(
'MatrixWorkerScript.js'
)
let parallelDeterminant = runWithMatrixProtocol('determinant')
parallelDeterminant([[1, 2], [3, 4]])
.then(determinant =>
console.log(determinant) // -2
)
酷,对吧?我们已经拿到了一些完全不安全的东西——线程间的无类型消息传递,并用完全类型安全的请求-响应协议进行了抽象。所有使用该协议运行的命令都集中在一个地方(MatrixProtocol),而我们的核心逻辑(createProtocol)则与我们的具体协议实现(runWithMatrixProtocol)分开。
任何时候您需要在两个进程之间进行通信——无论是在同一台机器上还是在网络上的多台计算机之间——类型安全的协议都是确保通信安全的好工具。虽然本节帮助开发了一些关于协议解决问题的直觉,但对于真实世界的应用,您可能希望使用现有的工具,如 Swagger、gRPC、Thrift 或 GraphQL——关于概述,请访问“类型安全的 API”。
在 NodeJS 中:使用子进程
注意
要跟随本节中的示例,请确保从 NPM 安装 NodeJS 的类型声明:
npm install @types/node --save-dev
要了解更多关于使用类型声明的信息,请跳转到“在 DefinitelyTyped 上有类型声明的 JavaScript”。
在 NodeJS 中,类型安全的并行性与 Web Worker 线程中的工作方式相同(参见“类型安全协议”)。虽然消息传递层本身不安全,但很容易在其上构建一个类型安全的 API。NodeJS 的子进程 API 如下所示:
// MainThread.ts import {fork} from 'child_process'
let child = fork('./ChildThread.js') 
child.on('message', data => 
console.info('Child process sent a message', data)
)
child.send({type: 'syn', data: [3]}) 
我们使用 NodeJS 的fork API 来生成一个新的子进程。
我们使用on API 监听来自子进程的传入消息。NodeJS 子进程可能向其父进程发送几条消息;在这里,我们只关心'message'消息。
我们使用send API 向子进程发送消息。
在我们的子线程中,我们使用process.on API 监听主线程发送过来的消息,并使用process.send发送消息回去:
// ChildThread.ts process.on('message', data => 
console.info('Parent process sent a message', data)
)
process.send({type: 'ack', data: [3]}) 
我们使用全局定义的process上的on API 来监听来自父线程的传入消息。
我们使用process的send API 向父进程发送消息。
因为机制与 Web Workers 如此相似,我将其作为一个练习留给你,以实现一个类型安全协议来抽象 NodeJS 中的进程间通信。
总结
在本章中,我们从 JavaScript 事件循环的基础开始讨论,继续探讨了 JavaScript 异步代码的构建块以及如何在 TypeScript 中安全地表达它们:回调函数、Promises、async/await和事件发射器。然后我们讨论了多线程,探索了在浏览器和服务器上传递消息以及构建完整的线程间通信协议。
与第七章中使用的技术相似,你可以自行选择:
-
对于简单的异步任务,回调函数是最直接的方法。
-
对于需要按顺序和并行执行的更复杂任务,Promises 和
async/await是你的好帮手。 -
当一个 Promise 不够用(例如,如果你需要多次触发事件),可以使用事件发射器或类似 RxJS 的响应式流库。
-
要将这些技术扩展到多线程,可以使用事件发射器、类型安全协议或类型安全的 API(参见“类型安全 API”)。
练习
-
实现一个通用的
promisify函数,它接受一个接受一个参数和一个回调函数的函数,并将其包装在一个返回 Promise 的函数中。完成后,你应该能像这样使用promisify(首先安装 NodeJS 的类型声明,使用npm install @types/node --save-dev):import {readFile} from 'fs' let readFilePromise = promisify(readFile) readFilePromise('./myfile.ts') .then(result => console.log('success reading file', result.toString())) .catch(error => console.error('error reading file', error)) -
在“类型安全协议”部分,我们推导了一半类型安全矩阵数学协议。给定在主线程中运行的协议的一半,请实现在 Web Worker 线程中运行的另一半。
-
使用映射类型(如“在浏览器中:使用 Web Workers”)来为 NodeJS 的
child_process实现类型安全的消息传递协议。
^(1) 好吧,如果你分叉你的浏览器平台,或者构建一个 C++ NodeJS 扩展,你就可以做到。
^(2) 用心的读者会注意到这个 API 如何与我们在“Option 类型”中开发的 flatMap API 相似。这种相似性并非偶然!Promise 和 Option 都受到了函数式编程语言 Haskell 中流行的 Monad 设计模式的启发。
^(3) Observables 是响应式编程对随时间做值操作的基本构建块。正在进行的提案是在 Observable 提案 中标准化 Observables。期待在本书的将来版本中深入探讨 Observables。
^(4) 除了函数、错误、DOM 节点、属性描述符、getter 和 setter,以及原型方法和属性之外。欲了解更多信息,请访问HTML5 规范。
^(5) 你也可以使用 Transferable API 在线程之间通过引用传递某些类型的数据(比如 ArrayBuffer)。在本节中,我们不会使用 Transferable 明确地在线程之间传递对象所有权,但这是一个实现细节。如果你的用例使用 Transferable,从类型安全的角度来看,方法是相同的。
^(6) 这个实现很简单,因为它每次发出命令时都会生成一个新的 worker;在实际世界中,你可能希望有一个保持热池的池化机制,并回收已释放的 workers。
第九章:前端和后端框架
虽然你可以从头开始构建应用程序的每个部分——在服务器上的网络和数据库层,前端的用户界面框架和状态管理解决方案——但你可能不应该这样做。很难把细节做好,幸运的是,许多这些前端和后端的难题已经被其他工程师解决了。通过利用现有的工具、库和框架来构建前端和后端的东西,我们可以在构建自己的应用程序时快速迭代并站在稳定的基础上。
在本章中,我们将介绍一些在客户端和服务器上解决常见问题的最流行的工具和框架。我们将讨论您可能会使用每个框架以及如何安全地将其集成到您的 TypeScript 应用程序中。
前端框架
TypeScript 自然适合于前端应用程序的世界。凭借其对 JSX 的丰富支持以及安全建模可变性的能力,TypeScript 为您的应用程序提供结构和安全性,并使得在快节奏的前端开发环境中编写正确、可维护的代码变得更加容易。
当然,所有内置的 DOM API 都是类型安全的。要从 TypeScript 中使用它们,只需在项目的 tsconfig.json 中包含它们的类型声明:
{
"compilerOptions": {
"lib": ["dom", "es2015"]
}
}
这将告诉 TypeScript 在检查代码时包含lib.dom.d.ts——其内置的浏览器和 DOM 类型声明。
注意
lib tsconfig.json 选项只是告诉 TypeScript 在处理项目中的代码时包含一组特定的类型声明;它不会生成任何额外的代码,或者生成任何在运行时存在的 JavaScript。例如,它不会让 DOM 在 NodeJS 环境中奇迹般地工作(您的代码会编译,但在运行时会失败)——您需要确保您的类型声明与您的 JavaScript 环境在运行时实际支持的内容匹配。跳到 “构建您的 TypeScript 项目” 了解更多信息。
启用 DOM 类型声明后,您可以安全地使用 DOM 和浏览器 API 来执行诸如以下操作:
// Read properties from the global window object
let model = {
url: window.location.href
}
// Create an <input /> element
let input = document.createElement('input')
// Give it some CSS classes
input.classList.add('Input', 'URLInput')
// When the user types, update the model
input.addEventListener('change', () =>
model.url = input.value.toUpperCase()
)
// Inject the <input /> into the DOM
document.body.appendChild(input)
当然,所有这些代码都经过类型检查,并且具有像编辑器中的自动完成之类的常规好处。例如,考虑类似于这样的内容:
document.querySelector('.Element').value // Error TS2339: Property 'value' does
// not exist on type 'Element'.
TypeScript 将抛出一个错误,因为querySelector的返回类型是可空的。
对于简单的前端应用程序,这些低级 DOM API 已经足够,并且将为您提供所需的内容,以便在浏览器中进行安全的、类型引导的编程。大多数真实世界的前端应用程序使用框架来抽象掉 DOM 渲染和重新渲染、数据绑定和事件处理的工作方式。以下部分将为您提供如何有效地使用 TypeScript 与一些最流行的浏览器框架的一些指针。
React
React 是当今最受欢迎的前端框架之一,在类型安全方面非常出色。
React 如此安全的原因在于 React 组件——React 应用程序的基本构建块——在 TypeScript 中既定义又消费。这种属性在前端框架中很难找到,意味着组件定义和消费者都经过了类型检查。您可以使用类型来表达诸如“此组件接受用户 ID 和颜色”或“此组件只能具有列表项作为子元素”的内容。然后,TypeScript 会强制执行这些约束,验证您的组件是否按照其所说的方式运行。
在前端应用程序的视图层中,关于组件定义和消费者的这种安全性是致命的。传统上,视图是程序员们花费数千小时挠头并愤怒地刷新浏览器的地方,因为错别字、遗漏的属性、错误输入的参数和不正确的嵌套元素。当您开始使用 TypeScript 和 React 编写视图时,您和您的团队在前端的生产力将翻倍。
JSX 入门指南
使用 React 时,您可以使用称为JavaScript XML(JSX)的特殊 DSL 来定义视图,直接嵌入到 JavaScript 代码中。它在您的 JavaScript 中看起来像 HTML。然后,您可以运行您的 JavaScript 通过 JSX 编译器,将那些奇特的 JSX 语法重写为常规的 JavaScript 函数调用。
这个过程看起来像这样。假设您正在为朋友的餐厅构建一个菜单应用程序,并列出早午餐菜单上的一些项目,使用以下 JSX:
<ul class='list'>
<li>Homemade granola with yogurt</li>
<li>Fantastic french toast with fruit</li>
<li>Tortilla Española with salad</li>
</ul>
在像 Babel 的transform-react-jsx插件这样的 JSX 编译器中运行该代码后,您将得到以下输出:
React.createElement(
'ul',
{'class': 'list'},
React.createElement(
'li',
null,
'Homemade granola with yogurt'
),
React.createElement(
'li',
null,
'Fantastic French toast with fruit'
),
React.createElement(
'li',
null,
'Tortilla Española with salad'
)
);
TSC 标志:esModuleInterop
因为 JSX 编译为对React.createElement的调用,请确保在每个使用 JSX 的文件中导入 React 库,以便在作用域中有名为React的变量:
import React from 'react'
别担心——如果您忘记了,TypeScript 会提醒您:
<ul /> // Error TS2304: Cannot find name 'React'.
还请注意,我已在我的tsconfig.json中设置了{"esModuleInterop": true}以支持导入React而无需通配符(*)导入。如果您在跟进,请在您自己的tsconfig.json中启用esModuleInterop,或者改用通配符导入:
import * as React from 'react'
JSX 的好处在于,您可以编写看起来非常像普通 HTML 的内容,然后自动将其编译为友好于 JavaScript 引擎的格式。作为一名工程师,您只需使用一种熟悉的、高级的、声明性的 DSL,而无需处理实现细节。
您不需要 JSX 来使用 React(您可以直接编写已编译的代码,它也能正常工作),并且您可以在没有 React 的情况下使用 JSX(JSX 标签编译为的特定函数调用——在前面的示例中是React.createElement——是可以配置的),但是 React 与 JSX 的组合是神奇的,让编写视图非常有趣,也非常安全。
TSX = JSX + TypeScript
包含 JSX 的文件使用扩展名 .jsx。而包含 JSX 的 TypeScript 文件使用扩展名 .tsx。TSX 对 JSX 就像 TypeScript 对 JavaScript 一样—是一个编译时的安全和辅助层,帮助你更高效地生产代码,减少错误。要为你的项目启用 TSX 支持,请在你的 tsconfig.json 文件中添加以下行:
{
"compilerOptions": {
"jsx": "react"
}
}
jsx 指令在撰写时有三种模式:
react
使用 JSX pragma 编译 JSX 到一个扩展名为 .js 的文件(默认为 React.createElement)。
react-native
不编译 JSX,但生成一个扩展名为 .js 的文件。
preserve
对 JSX 进行类型检查,但不要将其编译掉,并生成一个扩展名为 .jsx 的文件。
在幕后,TypeScript 以一种可插拔的方式暴露了几个用于 TSX 类型的钩子。这些是 global.JSX 命名空间上的特殊类型,TypeScript 在整个程序中查看它们作为 TSX 类型的真实来源。如果你只是使用 React,你不需要深入到这个低级别;但是如果你正在构建自己的 TypeScript 库,该库使用 TSX(而不是 React)—或者如果你好奇 React 类型声明是如何做到的—可以查看 附录 G。
使用 TSX 与 React
React 允许我们声明两种类型的组件:函数组件和类组件。无论是哪种类型的组件,都需要一些属性并渲染一些 TSX。从消费者的角度来看,它们是相同的。
声明和渲染一个函数组件看起来像这样:
import React from 'react' 
type Props = { 
isDisabled?: boolean
size: 'Big' | 'Small'
text: string
onClick(event: React.MouseEvent<HTMLButtonElement>): void 
}
export function FancyButton(props: Props) { 
const [toggled, setToggled] = React.useState(false) 
return <button
className={'Size-' + props.size}
disabled={props.isDisabled || false}
onClick={event => {
setToggled(!toggled)
props.onClick(event)
}}
>{props.text}</button>
}
let button = <FancyButton 
size='Big'
text='Sign Up Now'
onClick={() => console.log('Clicked!')}
/>
为了在使用 React 和 TSX 时进行类型检查,我们必须将 React 变量引入当前作用域。由于 TSX 被编译为 React.createElement 函数调用,这意味着我们需要导入 React,以便在运行时定义它。
我们首先声明可以传递给我们的 FancyButton 组件的具体 props 集合。按照约定,Props 总是一个对象类型,并且名为 Props。对于我们的 FancyButton 组件,isDisabled 是可选的,而其余的 props 都是必需的。
当使用 React 事件时,React 有自己的一套包装器类型用于 DOM 事件。请确保使用 React 的事件类型,而不是常规的 DOM 事件类型。
函数组件只是一个普通函数,最多有一个参数(props 对象),并返回一个可以被 React 渲染的类型。React 是宽松的,可以渲染多种类型:TSX、字符串、数字、布尔值、null 和 undefined。
我们使用 React 的useState钩子来为函数组件声明本地状态。useState是 React 中可用的几个钩子之一,您可以组合它们来创建自己的自定义钩子。请注意,因为我们将初始值false传递给useState,TypeScript 能够推断出该状态片段是一个boolean;如果我们使用了 TypeScript 无法推断的类型,例如数组,我们将需要显式地绑定类型(例如,使用 useState<number[]>;([]))。
我们使用 TSX 语法来创建FancyButton的实例。<FancyButton />语法与调用FancyButton几乎相同,但它让 React 来管理FancyButton的生命周期。
就是这样。TypeScript 强制执行以下规则:
-
JSX 格式良好。标签已关闭且嵌套正确,标签名称没有拼写错误。
-
当我们实例化一个
<FancyButton />时,我们将所有必需的——以及任何可选的——props 传递给FancyButton(size,text和onClick),并确保 props 的类型是正确的。 -
我们不会向
FancyButton传递任何多余的 props,只传递必需的 props。
类组件类似:
import React from 'react' 
import {FancyButton} from './FancyButton'
type Props = { 
firstName: string
userId: string
}
type State = { 
isLoading: boolean
}
class SignupForm extends React.Component<Props, State> { 
state = { 
isLoading: false
}
render() { 
return <> 
<h2>Sign up for a 7-day supply of our tasty
toothpaste now, {this.props.firstName}.</h2>
<FancyButton
isDisabled={this.state.isLoading}
size='Big'
text='Sign Up Now'
onClick={this.signUp}
/>
</>
}
private signUp = async () => { 
this.setState({isLoading: true})
try {
await fetch('/api/signup?userId=' + this.props.userId)
} finally {
this.setState({isLoading: false})
}
}
}
let form = <SignupForm firstName='Albert' userId='13ab9g3' /> 
和之前一样,我们导入React以将其引入作用域。
类似于之前,我们声明一个Props类型来定义在创建<SignupForm />实例时需要传递的数据。
我们声明一个State类型来模拟组件的本地状态。
要声明一个类组件,我们需要扩展React.Component基类。
我们使用属性初始化器来为本地状态声明默认值。
类似于函数组件,类组件的render方法返回可以由 React 渲染的内容:TSX,字符串,数字,布尔值,null或undefined。
TSX 支持使用特殊的<>...</>语法来进行片段处理。片段是一个无名称的 TSX 元素,用于包装其他 TSX 元素,可以避免在需要返回单个 TSX 元素的地方渲染额外的 DOM 元素。例如,React 组件的render方法需要返回单个 TSX 元素;为此,我们可以使用<div>或任何其他元素来包装我们的代码,但这样做会在渲染时产生不必要的开销。
我们使用箭头函数定义signUp,以确保函数中的this不会被重新绑定。
最后,我们实例化我们的 SignupForm。就像实例化函数组件一样,我们也可以直接用 new SignupForm({firstName: 'Albert', userId: '13ab9g3'}) 来实例化它,但这意味着 React 无法为我们管理 SignupForm 实例的生命周期。
注意在这个例子中我们如何混合匹配基于值的(FancyButton,SignupForm)和内置的(section,h2)组件。我们让 TypeScript 工作来验证以下几点:
-
所有必需的状态字段都在
state初始化器或构造函数中定义了。 -
我们在
props和state上访问的任何内容都确实存在,并且是我们认为的类型。 -
在 React 中,我们不直接写入
this.state,因为状态更新必须通过setStateAPI 进行。 -
调用
render确实返回一些 JSX。
使用 TypeScript,您可以使您的 React 代码更安全,从而成为一个更好、更快乐的人。
注意
我们没有使用 React 的 PropTypes 特性,这是一种在运行时声明和检查 props 类型的方式。因为 TypeScript 已经在编译时为我们检查了类型,所以我们不需要再次检查。
Angular 6/7
由 Shyam Seshadri 贡献
Angular 是一个比 React 更全面的前端框架,不仅支持渲染视图,还支持发送和管理网络请求、路由和依赖注入。它从头开始与 TypeScript 协作(事实上,框架本身就是用 TypeScript 编写的!)。
Angular 的核心工作方式是 Angular CLI 中内置的 Ahead-of-Time(AoT)编译器,这是 Angular 命令行实用程序,它获取您用 TypeScript 注解提供的类型信息,并使用该信息将您的代码编译为常规 JavaScript。Angular 不直接调用 TypeScript,而是在最终委托给 TypeScript 之前对您的代码应用一系列优化和转换。
让我们看看 Angular 如何使用 TypeScript 及其 AoT 编译器来确保编写前端应用的安全性。
脚手架
要初始化一个新的 Angular 项目,首先使用 NPM 全局安装 Angular CLI:
npm install @angular/cli --global
然后,使用 Angular CLI 初始化一个新的 Angular 应用程序:
ng new my-angular-app
按照提示操作,Angular CLI 将为您设置一个简易的 Angular 应用程序。
在本书中,我们不会深入讲解 Angular 应用程序的结构,或者如何配置和运行它。有关详细信息,请参阅官方 Angular 文档。
组件
让我们构建一个 Angular 组件。Angular 组件类似于 React 组件,并包括一种描述组件 DOM 结构、样式和控制器的方法。使用 Angular,您可以使用 Angular CLI 生成组件样板,然后手动填写详细信息。Angular 组件包含几个不同的文件:
-
描述组件渲染的 DOM 的模板
-
一组 CSS 样式
-
组件类,这是一个 TypeScript 类,用于定义你的组件业务逻辑。
让我们从组件类开始:
import {Component, OnInit} from '@angular/core'
@Component({
selector: 'simple-message',
styleUrls: ['./simple-message.component.css'],
templateUrl: './simple-message.component.html'
})
export class SimpleMessageComponent implements OnInit {
message: string
ngOnInit() {
this.message = 'No messages, yet'
}
}
总的来说,这是一个相当标准的 TypeScript 类,只是有几个不同点展示了 Angular 如何利用 TypeScript。具体来说:
-
Angular 的生命周期钩子作为 TypeScript 接口可用——只需声明你要
implement的哪些接口(ngOnChanges、ngOnInit等)。然后 TypeScript 强制要求你实现符合所需生命周期钩子的方法。在本例中,我们实现了OnInit接口,这要求我们实现ngOnInit方法。 -
Angular 大量使用 TypeScript 装饰器(见 “装饰器”)来声明与 Angular 组件、服务和模块相关的元数据。在这个例子中,我们使用了
selector来声明如何消费我们的组件,并使用了templateUrls和styleUrl来链接 HTML 模板和 CSS 样式表到我们的组件。
TSC 标志:fullTemplateTypeCheck
要为您的 Angular 模板启用类型检查(您应该!),请确保在您的 tsconfig.json 中启用 fullTemplateTypeCheck:
{
"angularCompilerOptions": {
"fullTemplateTypeCheck": true
}
}
注意 angularCompilerOptions 并非指定 TSC 的选项。相反,它定义了特定于 Angular 的 AoT 编译器的编译器标志。
服务
Angular 自带内置的依赖注入器(DI),这是框架实例化服务并将它们作为参数传递给依赖于它们的组件和服务的一种方式。这可以使得实例化和测试服务和组件更加容易。
让我们更新 SimpleMessageComponent 来注入一个依赖项 MessageService,负责从服务器获取消息:
import {Component, OnInit} from '@angular/core'
`import` `{``MessageService``}` `from` `'../services/message.service'`
@Component({
selector: 'simple-message',
templateUrl: './simple-message.component.html',
styleUrls: ['./simple-message.component.css']
})
export class SimpleMessageComponent implements OnInit {
message: string
`constructor``(`
`private` `messageService``:` `MessageService`
`)` `{``}`
ngOnInit() {
`this``.``messageService``.``getMessage``(``)``.``subscribe``(``response` `=``>`
`this``.``message` `=` `response``.``message`
`)`
}
}
Angular 的 AoT 编译器查看您的组件 constructor 接受的参数,提取它们的类型(例如 MessageService),并搜索相关的依赖注入器的依赖映射以查找该特定类型的依赖项。如果尚未实例化该依赖项,则实例化它(使用 new),并将其传递给 SimpleMessageComponent 实例的构造函数。所有这些依赖注入的内容相当复杂,但随着应用程序的增长以及根据应用程序配置(例如 ProductionAPIService 与 DevelopmentAPIService)或测试时使用的依赖项(MockAPIService)的不同,它可能非常方便。
现在让我们快速看一下如何定义一个服务:
import {Injectable} from '@angular/core'
import {HttpClient} from '@angular/common/http'
@Injectable({
providedIn: 'root'
})
export class MessageService {
constructor(private http: HttpClient) {}
getMessage() {
return this.http.get('/api/message')
}
}
每当我们在 Angular 中创建一个服务时,我们都会再次使用 TypeScript 装饰器将其注册为可注入的东西,并定义它是在应用程序的根级别提供还是在子模块中提供。在这里,我们注册了服务 MessageService,允许我们在应用程序的任何地方注入它。在任何组件或服务的构造函数中,我们只需请求一个 MessageService,Angular 将神奇地负责传递它。
既然我们已经讨论了如何安全地使用这两种流行的前端框架,现在让我们转向定义前端和后端之间的接口类型。
类型安全的 API
由 Nick Nance 贡献
不论你决定使用哪种前端和后端框架,你都需要一种安全的跨机器通信方式——从客户端到服务器、服务器到客户端、服务器到服务器,以及客户端到客户端。
在这个领域有几种竞争的工具和标准。但在我们探讨它们及其工作原理之前,让我们考虑一下如何构建我们自己的解决方案,以及它可能具有的优缺点(毕竟我们是工程师)。
我们要解决的问题是:尽管我们的客户端和服务器可能是 100%类型安全的——安全的堡垒——但它们在某个时候需要通过未经类型化的网络协议如 HTTP、TCP 或其他基于套接字的协议进行通信。我们如何使这种通信类型安全?
一个良好的起点可能是像我们在“类型安全协议”中开发的类型安全协议。它可能看起来像这样:
type Request =
| {entity: 'user', data: User}
| {entity: 'location', data: Location}
// client.ts
async function get<R extends Request>(entity: R['entity']): Promise<R['data']> {
let res = await fetch(/api/${entity})
let json = await res.json()
if (!json) {
throw ReferenceError('Empty response')
}
return json
}
// app.ts
async function startApp() {
let user = await get('user') // User
}
你可以构建对应的 post 和 put 函数来写回到你的 REST API,并为服务器支持的每种实体添加一个类型。在后端,你将为每种实体类型实现一组相应的处理程序,从数据库读取并返回客户端请求的实体。
但是,如果你的服务器不是用 TypeScript 编写的,或者你不能在客户端和服务器之间共享你的 Request 类型(导致它们随时间脱节),或者你不使用 REST(也许你使用的是 GraphQL)?或者如果你需要支持其他客户端,比如 iOS 上的 Swift 客户端或 Android 上的 Java 客户端?
这就是类型化、代码生成的 API 所能解决的问题。它们有许多不同的变体,每种都有库可以在许多语言中使用(包括 TypeScript)——例如:
-
Swagger 用于 RESTful APIs
-
gRPC 和 Apache Thrift 用于 RPC
这些工具依赖于一个共同的真实数据来源,用于服务器和客户端——Swagger 的数据模型、Apollo 的 GraphQL schema、gRPC 的 Protocol Buffers——然后将其编译成特定语言的绑定(在我们的案例中是 TypeScript)。
这种代码生成能防止你的客户端和服务器(或多个客户端)在互相不同步;因为每个平台共享一个公共模式,你不会遇到这样的情况,即你更新了 iOS 应用以支持一个字段,但忘记在你的拉取请求上按下合并以添加服务器对其的支持。
本书不涵盖每种框架的详细细节。为你的项目选择一个框架,然后转到其文档了解更多信息。
后端框架
当你构建一个与数据库交互的应用程序时,你可能从原始 SQL 或 API 调用开始,这些本质上是无类型的:
// PostgreSQL, using node-postgres
let client = new Client
let res = await client.query(
'SELECT name FROM users where id = $1',
[739311]
) // any
// MongoDB, using node-mongodb-native
db.collection('users')
.find({id: 739311})
.toArray((err, user) =>
// user is any
)
通过一点手工输入,你可以使这些 API 更安全,并摆脱大部分的any:
db.collection('users')
.find({id: 739311})
.toArray((err, user: User) =>
// user is any
)
然而,原始 SQL API 仍然相对较低级,并且很容易使用错误的类型,或者忘记类型而意外地得到 any。
这就是 对象关系映射(ORM)的用武之地。ORM 从你的数据库架构生成代码,为你提供高级 API 来表达查询、更新、删除等操作。在静态类型语言中,这些 API 是类型安全的,因此你不必担心正确地输入类型和手动绑定泛型类型参数。
在 TypeScript 中访问数据库时,考虑使用 ORM。在撰写本文时,Umed Khudoiberdiev 的优秀 TypeORM 是 TypeScript 最完整的 ORM,支持 MySQL、PostgreSQL、Microsoft SQL Server、Oracle 甚至 MongoDB。使用 TypeORM,获取用户名字的查询可能像这样:
let user = await UserRepository
.findOne({id: 739311}) // User | undefined
注意高级 API,它既安全(防止诸如 SQL 注入攻击之类的问题),默认情况下也是类型安全的(我们知道 findOne 返回的类型,而不必手动注释)。在处理数据库时,始终使用 ORM——这更方便,可以避免你因为在凌晨四点更新 saleAmount 字段为 orderAmount 而被叫醒,而你的同事决定在你不在时为你运行数据库迁移,但是半夜你的拉取请求失败了,即使迁移成功,你的纽约销售团队也意识到所有客户的订单金额都为 null 美元(这发生在... 朋友身上)。
总结
在本章中,我们涵盖了很多内容:直接操作 DOM;使用 React 和 Angular;使用类似 Swagger、gRPC 和 GraphQL 的工具为你的 API 添加类型安全性;以及使用 TypeORM 安全地与数据库交互。
JavaScript 框架变化迅速,当你阅读本文时,这里描述的具体 API 和框架可能已经成为博物馆展品。利用你新获得的直觉来解决类型安全框架解决的问题,找出可以利用他人工作的地方,使你的代码更安全、更抽象、更模块化。从本章中带走的重要思想不是在 2019 年使用最佳框架是什么,而是什么样的问题可以通过框架更好地解决。
结合类型安全的 UI 代码、带类型的 API 层和类型安全的后端,你可以从应用程序中消除整类错误,结果是你可以晚上睡得更安稳。
第十章:命名空间和模块
当你编写程序时,可以在多个级别表达封装。在最低级别,函数封装行为,而像对象和列表这样的数据结构封装数据。然后,你可以将函数和数据组合成类,或者将它们作为具有单独数据库或存储的命名空间实用程序保持分离。每个文件通常包含一个类或一组实用程序。再上一层,你可能将几个类或实用程序组合成一个包,并将其发布到 NPM。
当我们谈论模块时,重要的是要区分编译器(TSC)如何解析模块,构建系统(Webpack、Gulp 等)如何解析模块,以及模块如何在运行时加载到应用程序中(<script /> 标签、SystemJS 等)。在 JavaScript 的世界中,通常有一个单独的程序来执行每一项任务,这使得模块很难理解。CommonJS 和 ES2015 模块标准使得这三个程序更容易互操作,而像 Webpack 这样的强大捆绑工具帮助抽象出底层的三种解析方式。
在本章中,我们将重点关注这三种程序中的第一种:TypeScript 如何解析和编译模块。我们将把讨论构建系统和运行时加载器如何处理模块的工作留给第十二章,在这里我们讨论:
-
不同的命名空间和模块化代码的方式
-
不同的导入和导出代码的方式
-
随着代码库的增长而扩展这些方法
-
模块模式与脚本模式
-
什么是声明合并,以及你可以用它做什么
不过首先,稍微了解一下背景。
JavaScript 模块的简要历史
因为 TypeScript 编译成 JavaScript 并与之互操作,所以必须支持 JavaScript 程序员使用的各种模块标准。
最初(1995 年),JavaScript 不支持任何模块系统。没有模块,一切都声明在全局命名空间中,这使得构建和扩展应用程序非常困难。你很快就会用完变量名,并遇到变量名之间的冲突;没有为每个模块公开明确的 API,很难知道哪些模块部分是应该使用的,哪些是私有实现细节。
为了解决这些问题,人们用对象或立即调用的函数表达式(IIFE)模拟模块,并将它们分配给全局的 window,使它们可以在应用程序中的其他模块(以及同一网页上的其他应用程序)中使用。看起来像这样:
window.emailListModule = {
renderList() {}
// ...
}
window.emailComposerModule = {
renderComposer() {}
// ...
}
window.appModule = {
renderApp() {
window.emailListModule.renderList()
window.emailComposerModule.renderComposer()
}
}
因为加载和运行 JavaScript 会阻塞浏览器的 UI,随着 Web 应用程序越来越多地包含更多行代码,用户的浏览器会变得越来越慢。因此,聪明的程序员开始在页面加载后动态加载 JavaScript,而不是一次性加载它。在 JavaScript 首次发布近 10 年后,Dojo(Alex Russell,2004)、YUI(Thomas Sha,2005)和 LABjs(Kyle Simpson,2009)发布了模块加载器——在初始页面加载后惰性(通常是异步地)加载 JavaScript 代码的方法。惰性和异步模块加载意味着三件事:
-
模块需要良好封装。否则,在依赖项流入时页面可能会崩溃。
-
模块之间的依赖关系需要是显式的。否则,我们不知道哪些模块需要被加载以及以什么顺序加载。
-
每个模块在应用程序内部需要一个唯一的标识符。否则,无法可靠地指定需要加载哪些模块。
使用 LABjs 加载一个模块看起来像这样:
$LAB
.script('/emailBaseModule.js').wait()
.script('/emailListModule.js')
.script('/emailComposerModule.js')
同一时期,NodeJS(Ryan Dahl,2009)正在开发中,其创造者从 JavaScript 的成长痛苦和其他语言中吸取教训,并决定在平台中构建一个模块系统。像任何良好的模块系统一样,它需要满足 LABjs 和 YUI 加载器的三个标准。NodeJS 通过 CommonJS 模块标准实现了这一点,它看起来像这样:
// emailBaseModule.js
var emailList = require('emailListModule')
var emailComposer = require('emailComposerModule')
module.exports.renderBase = function() {
// ...
}
与此同时,在 Web 上,由 Dojo 和 RequireJS 推动的 AMD 模块标准(James Burke,2008)正在崛起。它支持一组等效功能,并配备了自己的构建系统用于打包 JavaScript 代码:
define('emailBaseModule',
['require', 'exports', 'emailListModule', 'emailComposerModule'],
function(require, exports, emailListModule, emailComposerModule) {
exports.renderBase = function() {
// ...
}
}
)
几年后,Browserify 出现了(James Halliday,2011),使得前端工程师也能在前端使用 CommonJS。CommonJS 成为了模块捆绑和导入/导出语法的事实标准。
CommonJS 做事情的方式存在一些问题。其中之一是,require调用是同步的,且 CommonJS 模块解析算法在 Web 上并不理想。除此之外,使用它的代码在某些情况下不能静态分析(作为 TypeScript 程序员,这应该引起您的注意),因为module.exports可以出现在任何地方(甚至在永远不会实际达到的死代码分支中),而require调用可以出现在任何地方并包含任意字符串和表达式,这使得静态链接 JavaScript 程序并验证所有引用的文件是否真实存在并导出它们说他们导出的内容成为不可能。
在这种背景下,ES2015——ECMAScript 语言的第六版——引入了一个新的标准,用于导入和导出,具有清晰的语法并且是静态分析可行的。它看起来像这样:
// emailBaseModule.js
import emailList from 'emailListModule'
import emailComposer from 'emailComposerModule'
export function renderBase() {
// ...
}
这是我们今天在 JavaScript 和 TypeScript 代码中使用的标准。然而,在撰写本文时,该标准尚未在每个 JavaScript 运行时原生支持,因此我们必须将其编译为受支持的格式(NodeJS 环境下的 CommonJS,浏览器环境中的全局或模块可加载格式)。
TypeScript 为我们提供了几种在模块中使用和导出代码的方式:使用全局声明、标准的 ES2015 的import和export,以及从 CommonJS 模块中向后兼容的import。此外,TSC 的构建系统允许我们将模块编译为多种环境:全局、ES2015、CommonJS、AMD、SystemJS 或 UMD(CommonJS、AMD 和全局的混合——以消费者环境中可用的为准)。
import,export
除非你被狼追赶,否则在你的 TypeScript 代码中应该使用 ES2015 的import和export,而不是使用 CommonJS、全局或命名空间模块。它们看起来像这样——与普通的 JavaScript 一样:
// a.ts
export function foo() {}
export function bar() {}
// b.ts
import {foo, bar} from './a'
foo()
export let result = bar()
ES2015 模块标准支持默认导出:
// c.ts
export default function meow(loudness: number) {}
// d.ts
import meow from './c' // Note the lack of {curlies}
meow(11)
它还支持使用通配符导入(*)从一个模块中导入所有内容:
// e.ts
import * as a from './a'
a.foo()
a.bar()
并重新导出一个模块的一些(或全部)导出:
// f.ts
export * from './a'
export {result} from './b'
export meow from './c'
因为我们写的是 TypeScript,而不是 JavaScript,当然可以导出类型和接口以及值。而且因为类型和值存在于不同的命名空间中,所以当您实际使用它时,TypeScript 会推断您是指类型还是值:
// g.ts
export let X = 3
export type X = {y: string}
// h.ts
import {X} from './g'
let a = X + 1 // X refers to the value X
let b: X = {y: 'z'} // X refers to the type X
模块路径是文件系统上的文件名。这将模块与它们在文件系统中的布局方式耦合在一起,但对于需要了解该布局以解析模块名称为文件的模块加载器来说,这是一个重要的特性。
动态导入
随着应用程序变得越来越庞大,其初始渲染的时间会变得越来越糟糕。这在前端应用程序中尤为严重,因为网络可能成为瓶颈,但它也适用于后端应用程序,随着在顶层导入更多代码,启动时间会更长——这些代码需要从文件系统加载、解析、编译和评估,同时阻塞其他代码的运行。
在前端,解决这个问题的一种方法(除了写更少的代码之外!)是使用代码分割:将代码分成许多生成的 JavaScript 文件,而不是将所有内容打包到一个大文件中。使用分割,您可以并行加载多个块,从而减轻大型网络请求的负担(参见图 10-1)。

图 10-1. 从 facebook.com 加载的 JavaScript 的网络瀑布图
进一步的优化是在实际需要时延迟加载代码块。像 Facebook 和 Google 这样的非常大型前端应用程序通常使用这种优化。没有这个优化,客户端可能会在初始页面加载时加载数千兆字节的 JavaScript 代码,这可能需要几分钟或几小时(更不用说一旦收到手机账单,人们可能会停止使用这些服务)。
惰性加载也因其他原因而有用。例如,流行的Moment.js日期处理库提供了支持全球使用的每种日期格式的包,按区域划分。每个包大小约为 3 KB。为每个用户加载所有这些区域设置可能会对性能和带宽造成不可接受的影响;相反,您可能希望检测用户的区域设置,然后仅加载相关的日期包。
LABjs 及其衍生物介绍了在实际需要时延迟加载代码的概念,该概念在动态导入中得到了正式化。看起来像这样:
let locale = await import('locale_us-en')
您可以将import用作静态拉取代码的语句(就像我们到目前为止使用的那样),也可以将其用作返回您模块的Promise的函数(就像我们在此示例中所做的那样)。
虽然您可以将评估为字符串的任意表达式传递给import,但这样做会丢失类型安全性。要安全地使用动态导入,请确保要么
-
直接将字符串文字传递给
import,而不先将字符串分配给变量。 -
将表达式传递给
import并手动注释模块的签名。
如果使用第二个选项,一个常见模式是静态导入模块,但仅在类型位置使用它,这样 TypeScript 将编译掉静态导入(要了解更多,请参见“types 指令”)。例如:
import {locale} from './locales/locale-us'
async function main() {
let userLocale = await getUserLocale()
let path = ./locales/locale-${userLocale}
let localeUS: typeof locale = await import(path)
}
我们从./locales/locale-us导入了locale,但我们仅将其用于其类型,我们通过typeof locale检索了它的类型。我们之所以需要这样做,是因为 TypeScript 不能静态查找import(path)的类型,因为path是一个计算的变量,而不是静态字符串。因为我们从未将locale用作值,而是只是为了获取其类型,所以在这个示例中,TypeScript 没有生成任何顶级导出,这使我们既拥有出色的类型安全性,又具有动态计算的导入。
TSC 设置:模块
TypeScript 仅在esnext模块模式下支持动态导入。要使用动态导入,请在您的tsconfig.json的compilerOptions中设置{"module": "esnext"}。跳到“在服务器上运行 TypeScript”和“在浏览器中运行 TypeScript”了解更多信息。
使用 CommonJS 和 AMD 代码
在使用 CommonJS 或 AMD 标准的 JavaScript 模块时,您可以像 ES2015 模块一样简单地从中导入名称:
import {something} from './a/legacy/commonjs/module'
默认情况下,CommonJS 默认导出与 ES2015 默认导入不兼容;要使用默认导出,必须使用通配符导入:
import * as fs from 'fs'
fs.readFile('some/file.txt')
为了更顺畅地进行互操作性,可以在您的 tsconfig.json 的 compilerOptions 中设置 {"esModuleInterop": true}。现在,您可以省略通配符:
`import` `fs` `from` `'fs'`
fs.readFile('some/file.txt')
注意
正如我在本章开头提到的,即使此代码编译通过,也不能保证在运行时能正常工作。无论您使用哪种模块标准——import/export、CommonJS、AMD、UMD 或浏览器全局对象——您的模块捆绑器和模块加载器都必须了解该格式,以便在编译时正确打包和拆分您的代码,并在运行时正确加载您的代码。请前往第十二章了解更多信息。
模块模式与脚本模式的区别
TypeScript 在其中一种模式下解析每个 TypeScript 文件:模块模式 或 脚本模式。它根据一个简单的启发式算法决定使用哪种模式:您的文件是否有任何 import 或 export?如果有,它将使用模块模式;否则,它将使用脚本模式。
模块模式是我们到目前为止使用的模式,也是您大部分时间会使用的模式。在模块模式中,您使用 import 和 import() 来从其他文件中引入代码,并使用 export 将代码提供给其他文件使用。如果您使用任何第三方 UMD 模块(提醒一下,UMD 模块尝试使用环境支持的 CommonJS、RequireJS 或浏览器全局对象),您必须首先 import 它们,不能直接使用它们的全局导出。
在脚本模式中,您声明的任何顶级变量将在项目中的其他文件中可用,无需显式导入,并且您可以安全地使用来自第三方 UMD 模块的全局导出,而无需先显式导入它们。脚本模式的几个用例包括:
-
为了快速原型化浏览器代码,您计划将其编译为完全不使用模块系统(在您的 tsconfig.json 中设置
{"module": "none"}),并将其作为原始<script />标签包含在您的 HTML 文件中。 -
创建类型声明(参见“类型声明”)
您几乎总是希望坚持使用模块模式,TypeScript 会在您编写实际代码时自动选择这种模式,该模式通过 import 其他代码和 export 供其他文件使用。
命名空间
TypeScript 为我们提供了另一种封装代码的方式:namespace 关键字。对于许多 Java、C#、C++、PHP 和 Python 程序员来说,命名空间会感觉很熟悉。
小贴士
如果您之前使用过带命名空间的语言,请注意,尽管 TypeScript 支持命名空间,但它们不是封装代码的首选方式;如果不确定是使用命名空间还是模块,请选择模块。
命名空间抽象了文件系统中文件布局的细节;您不必知道您的 .mine 函数位于 schemes/scams/bitcoin/apps 文件夹中,而是可以通过简短方便的命名空间 Schemes.Scams.Bitcoin.Apps.mine 来访问它。^(1)
假设我们有两个文件——一个用于进行 HTTP GET 请求的模块,一个用于使用该模块进行请求的消费者:
// Get.ts
namespace Network {
export function get<T>(url: string): Promise<T> {
// ...
}
}
// App.ts
namespace App {
Network.get<GitRepo>('https://api.github.com/repos/Microsoft/typescript')
}
命名空间必须有一个名称(比如 Network),它可以导出函数、变量、类型、接口或其他命名空间。在命名空间块中未显式导出的任何代码对该块是私有的。因为命名空间可以导出命名空间,所以你可以轻松地建模嵌套命名空间。假设我们的 Network 模块变得很大,我们想将其拆分成几个子模块。我们可以使用命名空间来实现这一点:
namespace Network {
export namespace HTTP {
export function get <T>(url: string): Promise <T> {
// ...
}
}
export namespace TCP {
listenOn(port: number): Connection {
//...
}
// ...
}
export namespace UDP {
// ...
}
export namespace IP {
// ...
}
}
现在,我们所有与网络相关的实用程序都在 Network 的子命名空间下。例如,我们现在可以从任何文件调用 Network.HTTP.get 和 Network.TCP.listenOn。与接口一样,命名空间可以被扩展,使得跨文件拆分它们变得方便。TypeScript 将递归地为我们合并同名的命名空间:
// HTTP.ts
namespace Network {
export namespace HTTP {
export function get<T>(url: string): Promise<T> {
// ...
}
}
}
// UDP.ts
namespace Network {
export namespace UDP {
export function send(url: string, packets: Buffer): Promise<void> {
// ...
}
}
}
// MyApp.ts
Network.HTTP.get<Dog[]>('http://url.com/dogs')
Network.UDP.send('http://url.com/cats', new Buffer(123))
如果你有很长的命名空间层次结构,你可以使用别名来方便地缩短它们。请注意,尽管语法类似,但不支持别名的解构(就像导入 ES2015 模块时所做的那样):
// A.ts
namespace A {
export namespace B {
export namespace C {
export let d = 3
}
}
}
// MyApp.ts
import d = A.B.C.d
let e = d * 3
冲突
相同名称导出之间的冲突是不允许的:
// HTTP.ts
namespace Network {
export function request<T>(url: string): T {
// ...
}
}
// HTTP2.ts
namespace Network {
// Error TS2393: Duplicate function implementation.
export function request<T>(url: string): T {
// ...
}
}
不与不发生碰撞规则的例外是过载的环境函数声明,您可以使用它来细化函数类型:
// HTTP.ts
namespace Network {
export function request<T>(url: string): T
}
// HTTP2.ts
namespace Network {
export function request<T>(url: string, priority: number): T
}
// HTTPS.ts
namespace Network {
export function request<T>(url: string, algo: 'SHA1' | 'SHA256'): T
}
编译输出
与导入和导出不同,命名空间不尊重您的 tsconfig.json 的 module 设置,并始终编译为全局变量。让我们窥视幕后,看看生成的输出是什么样子的。假设我们有以下模块:
// Flowers.ts
namespace Flowers {
export function give(count: number) {
return count + ' flowers'
}
}
通过 TSC 运行,生成的 JavaScript 输出如下:
let Flowers
(function (Flowers) { 
function give(count) {
return count + ' flowers'
}
Flowers.give = give 
})(Flowers || (Flowers = {})) 
Flowers 在一个立即调用的函数表达式(IIFE)中声明,以创建闭包并防止未显式导出的变量泄漏出 Flowers 模块。
TypeScript 将我们导出的 give 函数分配给 Flowers 命名空间。
如果 Flowers 命名空间已经全局定义,则 TypeScript 会扩展它(Flowers);否则,TypeScript 会创建并扩展新创建的命名空间(Flowers = {})。
声明合并
到目前为止,我们已经涉及 TypeScript 为我们做的三种合并类型:
-
合并值和类型,使得相同的名称可以根据使用方式引用值或类型(见“伴生对象模式”)
-
将多个命名空间合并为一个
-
将多个接口合并为一个(见“声明合并”)
正如你可能直觉到的那样,这些都是 TypeScript 更一般行为的三个特例。TypeScript 拥有丰富的名称合并行为,可以解锁各种模式,否则可能难以表达(详见表 10-1)。
表 10-1. 声明是否可以合并?
| 到 | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| 值 | 类 | 枚举 | 函数 | 类型别名 | 接口 | 命名空间 | 模块 | |||
| 值 | 否 | 否 | 否 | 否 | 是 | 是 | 否 | — | ||
| 类 | — | 否 | 否 | 否 | 否 | 是 | 是 | — | ||
| 枚举 | — | — | 是 | 否 | 否 | 否 | 是 | — | ||
| 来自 | 函数 | — | — | — | 否 | 是 | 是 | 是 | — | |
| 类型别名 | — | — | — | — | 否 | 否 | 是 | — | ||
| 接口 | — | — | — | — | — | 是 | 是 | — | ||
| 命名空间 | — | — | — | — | — | — | 是 | — | ||
| 模块 | — | — | — | — | — | — | — | 是 |
意味着,例如,如果你在同一作用域中声明一个值和一个类型别名,TypeScript 将允许这样做,并且从你在值或类型位置使用名称来推断你所指的是类型还是值。这就是我们实现“伴生对象模式”中描述的模式的原因。这也意味着你可以使用接口和命名空间来实现伴生对象——你不仅限于值和类型别名。或者你可以利用模块合并来增强第三方模块声明(详见“扩展模块”)。或者你可以通过将命名空间与枚举合并来添加静态方法(试试看!)。
总结
在本章中,我们介绍了 TypeScript 的模块系统,从 JavaScript 模块系统的简要历史开始,ES2015 模块,以及使用动态导入安全地延迟加载代码,与 CommonJS 和 AMD 模块的互操作,以及模块模式与脚本模式的比较。然后我们涵盖了命名空间、命名空间合并,以及 TypeScript 的声明合并工作原理。
在开发 TypeScript 应用程序时,努力坚持使用 ES2015 模块。TypeScript 不关心你使用哪种模块系统,但它会使得与构建工具集成更容易(参见第十二章了解更多)。
练习
-
玩转声明合并,以:
-
使用命名空间和接口重新实现伴生对象(来自“伴生对象模式”),而不是使用值和类型。
-
给枚举添加静态方法。
-
^(1) 我真的希望这个笑话会成为经典,并且我不会后悔没有投资比特币。
第十一章:与 JavaScript 的互操作
我们不生活在一个完美的世界。你的咖啡可能太烫,喝的时候会烫到嘴;你的父母可能会频繁地打电话给你留言;你家门口的坑洼无论你怎么打电话给市政府都还在那里;你的代码可能不完全涵盖静态类型。
大多数人都处于这种情况中:尽管偶尔你会有机会在 TypeScript 中启动一个全新的项目,但大多数情况下,它将作为一个安全的小岛,嵌入在一个更大、不太安全的代码库中。也许你有一个良好隔离的组件,想要尝试在上面使用 TypeScript,即使你的公司在其他地方都使用常规的 ES6 JavaScript,或者你因为重构了某些代码但忘记更新调用点而在凌晨 6 点被叫醒(现在已经 7 点了,你在同事们醒来之前正在快速合并 TypeScript 编译器到你的代码库中)。无论哪种情况,你可能会从一个 TypeScript 的孤岛开始。
到目前为止,在本书中我教你如何正确编写 TypeScript。本章是关于在真实代码库中以实用的方式编写 TypeScript,这些代码库正在迁移远离无类型语言,使用第三方的 JavaScript 库,并且有时为了快速修复生产问题而牺牲类型安全性。本章专注于与 JavaScript 的交互。我们将探讨:
-
使用类型声明
-
逐步从 JavaScript 迁移到 TypeScript
-
使用第三方的 JavaScript 和 TypeScript
类型声明
类型声明 是一个扩展名为 .d.ts 的文件。除了 JSDoc 注释(参见“第 2 步 b:添加 JSDoc 注释(可选)”)外,它是将 TypeScript 类型附加到本来无类型的 JavaScript 代码的一种方式。
类型声明的语法与常规的 TypeScript 类似,但也有一些区别:
-
类型声明只能包含类型,不能包含值。这意味着不能包括函数、类、对象或变量的实现,也不能为参数提供默认值。
-
虽然类型声明不能定义值,但它们可以声明在你的 JavaScript 中的某处存在一个值。我们使用特殊的
declare关键字来实现这一点。 -
类型声明只声明对消费者可见的内容的类型。我们不包括未导出的内容,或者函数体内部的局部变量的类型。
让我们来看一个示例,看看 TypeScript (.ts) 代码片段及其相应的类型声明 (.d.ts)。这个示例是来自流行的 RxJS 库的一段相当复杂的代码;可以忽略它正在做什么的细节,而是关注它使用了哪些语言特性(导入、类、接口、类字段、函数重载等):
import {Subscriber} from './Subscriber'
import {Subscription} from './Subscription'
import {PartialObserver, Subscribable, TeardownLogic} from './types'
export class Observable<T> implements Subscribable<T> {
public _isScalar: boolean = false
constructor(
subscribe?: (
this: Observable<T>,
subscriber: Subscriber<T>
) => TeardownLogic
) {
if (subscribe) {
this._subscribe = subscribe
}
}
static create<T>(subscribe?: (subscriber: Subscriber<T>) => TeardownLogic) {
return new Observable<T>(subscribe)
}
subscribe(observer?: PartialObserver<T>): Subscription
subscribe(
next?: (value: T) => void,
error?: (error: any) => void,
complete?: () => void
): Subscription
subscribe(
observerOrNext?: PartialObserver<T> | ((value: T) => void),
error?: (error: any) => void,
complete?: () => void
): Subscription {
// ...
}
}
使用带有declarations标志的 TSC 运行此代码(tsc -d Observable.ts)会生成以下Observable.d.ts类型声明:
import {Subscriber} from './Subscriber'
import {Subscription} from './Subscription'
import {PartialObserver, Subscribable, TeardownLogic} from './types'
export declare class Observable<T> implements Subscribable<T> { 
_isScalar: boolean
constructor(
subscribe?: (
this: Observable<T>,
subscriber: Subscriber<T>
) => TeardownLogic
);
static create<T>(
subscribe?: (subscriber: Subscriber<T>) => TeardownLogic
): Observable<T>
subscribe(observer?: PartialObserver<T>): Subscription
subscribe(
next?: (value: T) => void,
error?: (error: any) => void,
complete?: () => void
): Subscription 
}
注意在class之前的declare关键字。我们实际上不能在类型声明中定义类,但可以声明在.d.ts文件的对应 JavaScript 文件中定义了一个类。可以将declare想象成一种确认:“我保证我的 JavaScript 导出了这种类型的类。”
因为类型声明不包含实现,所以我们只保留了subscribe的两个重载,而没有保留其实现的签名。
注意Observable.d.ts只是Observable.ts的一个版本,去掉了实现部分。换句话说,它只包含了Observable.ts中的类型信息。
这种类型声明对于 RxJS 库中使用Observable.ts的其他文件并不有用,因为它们可以直接访问Observable.ts源 TypeScript 文件。但是,如果您在 TypeScript 应用程序中使用 RxJS,则这很有用。
想想看:如果 RxJS 的作者想要在 NPM 上为他们的 TypeScript 用户提供打包的类型信息(RxJS 可用于 TypeScript 和 JavaScript 应用程序),他们有两个选择:打包源 TypeScript 文件(供 TypeScript 用户使用)和已编译的 JavaScript 文件(供 JavaScript 用户使用),或者只提供已编译的 JavaScript 文件以及 TypeScript 用户的类型声明。后者可以减少文件大小,并明确正确的导入使用方式。它还有助于保持应用程序的编译时间快速,因为您的 TSC 实例不需要在每次编译应用程序时重新编译 RxJS(实际上,这就是我们在“项目引用”中介绍的优化策略的原因!)。
类型声明文件有几个用途:
-
当其他人在他们的 TypeScript 应用程序中使用您编译的 TypeScript 时,他们的 TSC 实例将查找与您生成的 JavaScript 文件对应的.d.ts文件。这告诉 TypeScript 您项目的类型信息。
-
带有 TypeScript 支持的代码编辑器(如 VSCode)将读取这些.d.ts文件,以在用户键入时为他们提供有用的类型提示,即使他们不使用 TypeScript。
-
它们通过避免不必要地重新编译您的 TypeScript 代码显著加快了编译时间。
类型声明是告诉 TypeScript:“JavaScript 中有这样一个定义,我将描述给你。” 当我们谈论类型声明时,为了与包含值的常规声明区分开来,我们通常将它们称为 环境 声明;例如,环境变量声明 使用 declare 关键字声明变量在 JavaScript 中某处定义,而常规的非环境变量声明则是使用 let 或 const 声明一个变量,而无需使用 declare 关键字。
你可以为几件事情使用类型声明:
-
要告诉 TypeScript 有关某个在 JavaScript 中某处定义的全局变量。例如,如果你在浏览器环境中填充了
Promise全局变量或者定义了process.env,你可以使用 环境变量声明 来提醒 TypeScript。 -
定义一个在整个项目中都全局可用的类型,因此在使用时不需要先导入它(我们称之为环境类型声明)。
-
告诉 TypeScript 关于你使用 NPM 安装的第三方模块的信息(环境模块声明)。
无论你使用类型声明做什么,它都必须存在于脚本模式的 .ts 或 .d.ts 文件中(回想一下我们早前讨论的模块模式与脚本模式 “模块模式与脚本模式”)。按照惯例,如果文件有对应的 .js 文件,我们会给文件一个 .d.ts 扩展名;否则,我们使用 .ts 扩展名。文件的名称并不重要——例如,我喜欢将所有类型声明放在一个顶级文件 types.ts 中,直到文件变得难以管理——一个类型声明文件可以包含任意多个类型声明。
最后,虽然类型声明文件中的顶级值需要使用 declare 关键字(如 declare let、declare function、declare class 等),但顶级类型和接口则不需要。
在明确了这些基本规则后,让我们简要看一些每种类型声明的例子。
环境变量声明
环境变量声明是一种告诉 TypeScript 在项目中任何 .ts 或 .d.ts 文件中可以使用的全局变量的方式,而无需首先显式导入它。
假设你在浏览器中运行 NodeJS 程序,并且在某一时刻程序检查 process.env.NODE_ENV(可能是 "development" 或 "production")。当你运行程序时,你会得到一个难看的运行时错误:
Uncaught ReferenceError: process is not defined.
你在 Stack Overflow 上搜寻了一下,意识到让程序运行起来的最快方法是自己填充 process.env.NODE_ENV 并将其硬编码。所以你创建了一个新文件 polyfills.ts,并定义了一个全局的 process.env:
process = {
env: {
NODE_ENV: 'production'
}
}
当然,TypeScript 会挺身而出,用红色波浪线提醒你,试图通过增强全局 window 来阻止你显然正在犯的错误:
Error TS2304: Cannot find name 'process'.
但在这种情况下,TypeScript 显得过于保守了。实际上,你确实希望增强 window,并且希望安全地进行操作。
那么你该怎么办呢?你在 Vim 中打开polyfills.ts(你知道这将发生什么)并输入:
`declare` `let` `process``:` `{`
`env``:` `{`
`NODE_ENV``:` `'development'` `|` `'production'`
`}`
`}`
process = {
env: {
NODE_ENV: 'production'
}
}
你在告诉 TypeScript 存在一个全局对象process,它有一个名为env的属性,其中包含一个名为NODE_ENV的属性。一旦你告诉 TypeScript 这一点,红色波浪线消失了,你可以安全地定义你的process全局对象。
TSC 设置:lib
TypeScript 自带一组类型声明,用于描述包括内置 JavaScript 类型(如Array和Promise)和内置类型的方法(如''.toUpperCase)在内的 JavaScript 标准库,还包括全局对象如window和document(在浏览器环境中),以及onmessage(在 Web Worker 环境中)。
你可以通过你的tsconfig.json文件的lib字段引入 TypeScript 的内置类型声明。查看lib以深入了解如何调整项目的lib设置。
环境类型声明
环境类型声明遵循环境变量声明相同的规则:声明必须位于脚本模式的.ts或.d.ts文件中,并且它将在项目中的其他文件中全局可用,无需显式导入。例如,让我们声明一个全局实用程序类型ToArray<T>,如果T尚不是数组,则将其提升为数组。我们可以在项目的任何脚本模式文件中定义这种类型——例如,让我们在顶层的types.ts文件中定义它:
type ToArray<T> = T extends unknown[] ? T : T[]
现在,我们可以在任何项目文件中使用这种类型,而无需显式导入:
function toArray<T>(a: T): ToArray<T> {
// ...
}
考虑使用环境类型声明来建模应用程序中始终使用的数据类型。例如,你可以用它们来使我们在“模拟名义类型”中开发的UserID类型全局可用:
type UserID = string & {readonly brand: unique symbol}
现在,你可以在应用程序的任何地方使用UserID,而无需先显式导入它。
环境模块声明
当你使用 JavaScript 模块并希望快速为其声明一些类型,以便安全地使用它——而无需首先将类型声明贡献回 JavaScript 模块的 GitHub 存储库或 DefinitelyTyped——环境模块声明就是你要使用的工具。
环境模块声明是一个常规的类型声明,被包围在特殊的declare module语法中:
declare module 'module-name' {
export type MyType = number
export type MyDefaultType = {a: string}
export let myExport: MyType
let myDefaultExport: MyDefaultType
export default myDefaultExport
}
在这个例子中,模块名('module-name')对应于一个确切的import路径。当你导入这个路径时,你的环境模块声明告诉 TypeScript 可用的内容:
import ModuleName from 'module-name'
ModuleName.a // string
如果你有一个嵌套的模块,请确保在其声明中包含整个import路径:
declare module '@most/core' {
// Type declaration
}
如果你只是想快速告诉 TypeScript “我正在导入这个模块——稍后我会为它添加类型,现在就假设它是any”,保留头部但省略实际声明:
// Declare a module that can be imported, where each of its imports are any
declare module 'unsafe-module-name'
现在,如果你使用这个模块,它就不太安全了:
import {x} from 'unsafe-module-name'
x // any
模块声明支持通配符导入,因此你可以为任何匹配给定模式的import路径提供类型。使用通配符(*)来匹配一个import路径:^(1)
// Type JSON files imported with Webpack's json-loader
declare module 'json!*' {
let value: object
export default value
}
// Type CSS files imported with Webpack's style-loader
declare module '*.css' {
let css: CSSRuleList
export default css
}
现在,你可以加载 JSON 和 CSS 文件:
import a from 'json!myFile'
a // object
import b from './widget.css'
b // CSSRuleList
注意
为了使最后两个示例工作,你需要配置你的构建系统来加载.json和.css文件。你可以告诉 TypeScript 这些路径模式是安全导入的,但 TypeScript 本身不能构建它们。
直接跳转到“没有在 DefinitelyTyped 上具有类型声明的 JavaScript”,了解如何使用环境模块声明为无类型第三方 JavaScript 声明类型的示例。
逐步从 JavaScript 迁移到 TypeScript
TypeScript 的设计考虑了与 JavaScript 的互操作性,并非事后补救。因此,尽管不是毫不费力,迁移到 TypeScript 是一种很好的体验,它允许你逐个文件地将代码库转换过来,随着迁移逐步选择更严格的安全级别,向老板和同事展示静态类型化代码可以有多么有影响力,一次提交一个。
从高层次来看,你希望的目标是:你的代码库完全用 TypeScript 编写,具有严格的类型覆盖,而你依赖的第三方 JavaScript 库应该具有高质量且严格的类型。任何可以在编译时捕获的错误都会被捕获,TypeScript 丰富的自动补全功能可以减少每行代码编写所需的时间。你可能需要采取一些小步骤来达到这个目标:
-
将 TSC 添加到你的项目中。
-
开始对你现有的 JavaScript 代码进行类型检查。
-
将你的 JavaScript 代码逐步迁移到 TypeScript。
-
为你的依赖安装类型声明,可以是为没有类型的依赖存根出类型,也可以是为无类型依赖编写类型声明,并将其贡献回 DefinitelyTyped。^(2)
-
为你的代码库打开
strict模式。
这个过程可能需要一些时间,但你会立即看到安全性和生产力的提升,随着你的进展,还会发现更多的收益。让我们一步步来看看这些步骤。
步骤 1:添加 TSC
在处理结合了 TypeScript 和 JavaScript 的代码库时,首先让 TSC 编译 JavaScript 文件与你的 TypeScript 并行工作。在你的tsconfig.json中:
{
"compilerOptions": {
"allowJs": true
}
通过这一个改变,你现在可以使用 TSC 来编译你的 JavaScript。只需将 TSC 添加到你的构建流程中,并且通过 TSC 运行每个现有的 JavaScript 文件,^(3) 或者继续通过现有的构建流程运行传统的 JavaScript 文件,并通过 TSC 运行新的 TypeScript 文件。
设置allowJs为true后,TypeScript 不会对你现有的 JavaScript 代码进行类型检查,但它会根据你在tsconfig.json中设置的目标(ES3、ES5 或其他)转译它,使用你要求的模块系统(在tsconfig.json的module字段中)。第一步完成。提交它,并给自己一个鼓励——你的代码库现在使用 TypeScript 了!
步骤 2a:启用 JavaScript 的类型检查(可选)
现在 TSC 正在处理您的 JavaScript,为什么不也对其进行类型检查呢?虽然您的 JavaScript 中可能没有显式的类型注释,请记住 TypeScript 在推断类型方面有多么出色;它可以像在常规 TypeScript 代码中那样推断出您 JavaScript 中的类型。在您的tsconfig.json中启用此功能:
{
"compilerOptions": {
"allowJs": true,
"checkJs": true
}
现在,每当 TypeScript 编译 JavaScript 文件时,它都会尽力推断类型并进行类型检查,就像对常规 TypeScript 代码一样。
如果您的代码库很大,并且打开checkJs会同时报告太多类型错误,请关闭它,而是通过在文件顶部添加// @ts-check指令(普通注释)来逐个文件启用 JavaScript 文件的检查。或者,如果一些大文件引发了大部分错误,而您暂时不想修复它们,请保持checkJs开启,并仅为这些文件添加// @ts-nocheck指令。
注意
因为 TypeScript 无法推断一切(例如函数参数类型),它将在您的 JavaScript 代码中推断大量类型为any。如果您在tsconfig.json中启用了strict模式(应该!),您可能希望在迁移时暂时允许隐式any。在您的tsconfig.json中添加:
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noImplicitAny": false
}
不要忘记在迁移了大量代码到 TypeScript 后再次打开noImplicitAny!它可能会显示出您错过的一堆真实错误(当然,除非您是 Xenithar,巴思莫尔达的 JavaScript 巫师的弟子,能够仅凭一锅满满的艾蒿进行心眼中的类型检查)。
当 TypeScript 对 JavaScript 代码进行处理时,它会使用比对 TypeScript 代码更宽松的推断算法。具体而言:
-
所有函数参数都是可选的。
-
函数和类的属性类型是从使用中推断出来的(而不是必须一开始就声明的):
class A {
x = 0 // number | string | string[], inferred from usage
method() {
this.x = 'foo'
}
otherMethod() {
this.x = ['array', 'of', 'strings']
}
}
- 在声明对象、类或函数后,您可以为其分配额外的属性。在幕后,TypeScript 通过为每个类和函数声明生成相应的命名空间,并自动向每个对象字面量添加索引签名来实现这一点。
步骤 2b:添加 JSDoc 注释(可选)
也许您很忙,只需为添加到旧 JavaScript 文件中的新函数添加单个类型注释。在有机会将该文件转换为 TypeScript 之前,您可以使用 JSDoc 注释为您的新函数类型。
您可能以前见过 JSDoc;它是那些带有@注释的奇怪看起来的 JavaScript 和 TypeScript 代码上方的注释,如@param、@returns等。 TypeScript 理解 JSDoc,并将其作为输入传递给其类型检查器,方式与其在 TypeScript 代码中使用显式类型注释相同。
假设您有一个 3000 行的实用程序文件(是的,我知道您的“朋友”写的)。您向其中添加一个新的实用程序函数:
export function toPascalCase(word) {
return word.replace(
/\w+/g,
([a, ...b]) => a.toUpperCase() + b.join('').toLowerCase()
)
}
不必将utils.js全面转换为 TypeScript,这样可能会捕捉到一堆你接下来需要修复的错误;你只需注释你的toPascalCase函数,就在未经类型定义的 JavaScript 海洋中创造出一个小小的安全岛:
*`/** * @param word {string} An input string to convert * @returns {string} The string in PascalCase */`*
export function toPascalCase(word) {
return word.replace(
/\w+/g,
([a, ...b]) => a.toUpperCase() + b.join('').toLowerCase()
)
}
没有 JSDoc 注释的话,TypeScript 会将toPascalCase的类型推断为(word: any) => string。现在,当 TypeScript 编译你的代码时,它知道toPascalCase的类型是(word: string) => string。而且你还得到了一些不错的文档!
前往TypeScript Wiki了解更多支持的 JSDoc 注释。
步骤 3:将文件重命名为.ts
一旦你将 TSC 添加到构建流程中,并可选择在可能的情况下开始对 JavaScript 进行类型检查和注释,现在是时候开始切换到 TypeScript 了。
逐个文件,将文件扩展名从.js(或.coffee、.es6等)更改为.ts。一旦你在代码编辑器中重命名文件,你会看到你的朋友红色的波浪线出现(TypeError,而不是儿童电视节目),揭示了类型错误、遗漏的情况、忘记的null检查和拼写错误的变量名。修复这些错误有两种策略:
-
做正确的事情。花时间正确地为形状、字段和函数类型化,这样你可以捕捉到所有使用它们的文件中的错误。如果启用了
checkJs,在tsconfig.json中打开noImplicitAny来揭示any并为其添加类型,然后关闭它以减少对余下 JavaScript 文件类型检查输出的干扰。 -
快速行动。将你的 JavaScript 文件批量重命名为.ts扩展名,并保持tsconfig.json设置宽松(即将
strict设置为false),以在重命名后尽可能少地抛出类型错误。将复杂类型定义为any以满足类型检查器的要求。修复剩下的类型错误并提交。完成后,逐个打开strict模式标志(如noImplicitAny、noImplicitThis、strictNullChecks等),并修复弹出的错误。(参见附录 F 获取完整的标志列表。)
提示
如果你选择快速而不拘一格的方式,一个有用的技巧是将环境类型声明TODO定义为any类型的别名,并使用它而不是any,这样你可以更轻松地找到和跟踪缺少的类型。你也可以将其命名得更加具体,以便在项目范围的代码搜索中更容易找到:
// globals.ts
type TODO_FROM_JS_TO_TS_MIGRATION = any
// MyMigratedUtil.ts
export function mergeWidgets(
widget1: TODO_FROM_JS_TO_TS_MIGRATION,
widget2: TODO_FROM_JS_TO_TS_MIGRATION
): number {
// ...
}
这两种方法都是可以接受的,你可以自行选择。因为 TypeScript 是一种逐渐类型化的语言,它从根本上构建起与未经类型定义的 JavaScript 代码尽可能安全地进行互操作。无论你是在严格类型化的 TypeScript 中与未经类型定义的 JavaScript 互操作,还是与松散类型化的 TypeScript 互操作,TypeScript 都会尽最大努力确保你的安全,并保证在你精心构建的严格类型化岛上,一切都尽可能安全。
步骤 4:使其严格
一旦你将大量 JavaScript 迁移到 TypeScript,你会希望通过逐步选择 TSC 更严格的标志来尽可能地使代码更安全(详见 附录 F 获取完整的标志列表)。
最后,您可以禁用 TSC 的 JavaScript 互操作标志,强制所有代码都以严格类型化的 TypeScript 编写:
{
"compilerOptions": {
"allowJs": false,
"checkJs": false
}
这将显示与类型相关的最终错误。修复这些错误,您将得到一个干净且安全的代码库,即使是最顽固的 OCaml 工程师也会为您竖起大拇指(如果您礼貌地问的话)。
遵循这些步骤将有助于您在控制的 JavaScript 中添加类型,但对于您无法控制的 JavaScript(例如从 NPM 安装的代码),该如何处理呢?为了达到这个目标,我们首先需要稍微改变方向…
JavaScript 的类型查找
当您从 TypeScript 文件导入 JavaScript 文件时,TypeScript 遵循以下类似的算法来查找您的 JavaScript 代码的类型声明(请记住,在 TypeScript 中我们谈论“文件”和“模块”时是可以互换的):^(4)
-
查找与您的 .js 文件同名的兄弟 .d.ts 文件。如果存在,则将其用作 .js 文件的类型声明。
例如,假设您的文件夹结构如下所示:
my-app/ ├──src/ │ ├──index.ts │ └──legacy/ │ ├──old-file.js │ └──old-file.d.ts然后从 index.ts 中导入 old-file:
// index.ts import './legacy/old-file'TypeScript 将使用 src/legacy/old-file.d.ts 作为 ./legacy/old-file 的类型声明源。
-
否则,如果
allowJs和checkJs为 true,则根据 .js 文件中的任何 JSDoc 注释推断出类型。 -
否则,将整个模块视为
any。
当导入第三方 JavaScript 模块——即您安装到 node modules 的 NPM 包时,TypeScript 使用稍微不同的算法:
-
查找模块的本地类型声明。如果存在,则使用它。
例如,假设您的应用程序文件夹结构如下所示:
my-app/ ├──node_modules/ │ └──foo/ ├──src/ │ ├──index.ts │ └──types.d.ts并且 types.d.ts 看起来像这样:
// types.d.ts declare module 'foo' { let bar: {} export default bar }如果接着导入
foo,TypeScript 将使用 types.d.ts 中的环境模块声明作为foo的类型源:// index.ts import bar from 'foo' -
否则,请查看模块的 package.json。如果它定义了一个名为
types或typings的字段,请使用该字段指向的 .d.ts 文件作为模块类型声明的源。 -
否则,逐级向外遍历目录,并查找具有模块类型声明的 node modules/@types 目录。
例如,假设你安装了 React:
npm install react --save npm install @types/react --save-devmy-app/ ├──node_modules/ │ ├──@types/ │ │ └──react/ │ └──react/ ├──src/ │ └──index.ts当您导入 React 时,TypeScript 将找到 @types/react 文件夹,并将其用作 React 的类型声明源:
// index.ts import * as React from 'react' -
否则,按照本地类型查找算法的步骤 1–3 进行。
虽然步骤很多,但一旦你掌握了,它实际上非常直观。
TSC 设置:types 和 typeRoots
默认情况下,TypeScript 在项目文件夹中的node modules/@types及其包含的文件夹(../node modules/@types等)中查找第三方类型声明。大多数情况下,你应该保留这种行为。
要覆盖全局类型声明的默认行为,请在你的tsconfig.json中配置typeRoots,并用一个包含文件夹路径的数组来查找类型声明。例如,你可以告诉 TypeScript 在typings文件夹和node modules/@types中查找类型声明:
{
"compilerOptions": {
"typeRoots" : ["./typings", "./node modules/@types"]
}
}
要进行更精细的控制,可以在你的tsconfig.json中使用types选项来指定希望 TypeScript 查找类型的包。例如,以下配置忽略除了 React 外的所有第三方类型声明:
{
"compilerOptions": {
"types" : ["react"]
}
}
使用第三方 JavaScript
注意
我假设你正在使用像 NPM 或 Yarn 这样的包管理器来安装第三方 JavaScript。如果你是那些喜欢手动复制粘贴代码的人——你真是可耻。
当你通过npm install将第三方 JavaScript 代码安装到你的项目中时,可能会有三种情况:
-
你安装的代码自带类型声明。
-
你安装的代码没有类型声明,但在 DefinitelyTyped 上可以找到声明。
-
你安装的代码没有类型声明,并且在 DefinitelyTyped 上也找不到声明。
让我们深入了解每种情况。
自带类型声明的 JavaScript
如果一个包默认带有类型声明,当你使用{"noImplicitAny": true}导入它时,TypeScript 不会给你抛出红色波浪线。
如果你安装的代码是从 TypeScript 编译而来的,或者其作者在其 NPM 包中包含了类型声明,那么你很幸运。只需安装该代码并开始使用它,就可以获得完整的类型支持。
一些带有内置类型声明的 NPM 包示例包括:
npm install rxjs
npm install ava
npm install @angular/cli
警告
除非你安装的代码实际上是从 TypeScript 编译而来的,否则始终存在类型声明与描述该代码的实际代码不匹配的风险。当类型声明与源代码打包在一起时,这种情况发生的风险非常低(尤其是对于流行的包而言),但仍需注意。
在 DefinitelyTyped 上有类型声明的 JavaScript
即使你导入的第三方代码没有类型声明,也可能在DefinitelyTyped上找到它的声明,这是 TypeScript 的社区维护的用于开源项目环境模块声明的集中存储库。
要检查你安装的包是否在 DefinitelyTyped 上有可用的类型声明,可以在 TypeSearch 上搜索,或者尝试安装声明。所有 DefinitelyTyped 类型声明都发布到 NPM 的 @types 范围下,因此你可以直接从该范围进行 npm install:
npm install lodash --save # Install Lodash
npm install @types/lodash --save-dev # Install type declarations for Lodash
大多数情况下,你会想要使用 npm install 的 --save-dev 标志将已安装的类型声明添加到 package.json 的 devDependencies 字段中。
注意
由于 DefinitelyTyped 上的类型声明是由社区维护的,它们可能存在不完整、不准确或过时的风险。虽然大多数流行的包都有良好维护的类型声明,但如果发现你使用的声明可以改进,花时间改进它们并将其贡献回 DefinitelyTyped,以便其他 TypeScript 用户可以利用你的辛勤工作。
在 DefinitelyTyped 上没有类型声明的 JavaScript
这是三种情况中最不常见的情况。在这里,你有几个选项,从最便宜且最不安全的到最耗时且最安全的:
-
通过添加
// @ts-ignore指令在未经类型化的导入之上,将特定导入列入白名单。TypeScript 将允许你使用未经类型化的模块,但该模块及其所有内容将被类型化为any:// @ts-ignore import Unsafe from 'untyped-module' Unsafe // any -
通过创建一个空的类型声明文件并为该模块创建存根,将该模块的所有用法列入白名单。例如,如果你安装了很少使用的包
nearby-ferret-alerter,你可以创建一个新的类型声明文件(例如 types.d.ts),并向其中添加环境类型声明:// types.d.ts declare module 'nearby-ferret-alerter'这告诉 TypeScript 存在一个模块,你可以导入(
import alert from 'nearby-ferret-alerter'),但它并不告诉 TypeScript 有关该模块中包含的类型。这种方法略好于第一种,因为现在有一个中心的 types.d.ts 文件列举了应用程序中所有未经类型化的模块,但同样不安全,因为nearby-ferret-alerter及其所有导出仍将被类型化为any。 -
创建一个环境模块声明。就像在前一种方法中一样,创建一个名为 types.d.ts 的文件,并添加一个空的声明(
declare module 'nearby-ferret-alerter')。现在,填写类型声明。例如,结果可能如下所示:// types.d.ts declare module 'nearby-ferret-alerter' { export default function alert(loudness: 'soft' | 'loud'): Promise<void> export function getFerretCount(): Promise<number> }现在当你
import alert from 'nearby-ferret-alerter'时,TypeScript 将准确知道alert的类型。它不再是any,而是(loudness: 'quiet' | 'loud') => Promise<void>。 -
创建类型声明并贡献给 NPM。如果您已经为您的模块创建了本地类型声明,并且达到了第三个选项,考虑将其贡献回 NPM,以便下一个需要为
nearby-ferret-alerter包获取类型声明的人也可以使用它。要实现这一点,您可以向nearby-ferret-alerter的 Git 存储库提交拉取请求,直接贡献类型声明,或者如果该存储库的维护者不想负责维护 TypeScript 类型声明,可以将您的声明贡献给 DefinitelyTyped。
为第三方 JavaScript 编写类型声明是直截了当的,但其具体操作方式取决于您正在为其编写类型的模块类型。在为不同类型的 JavaScript 模块(从 NodeJS 模块到 jQuery 增强,再到 Lodash 混入和 React、Angular 组件)编写类型时,会出现一些常见的模式。转到 附录 D 查看针对第三方 JavaScript 模块的各种类型编写方法。
注
自动为未类型化的 JavaScript 生成类型声明是一个活跃的研究领域。查看 dts-gen 以了解一种自动生成任何第三方 JavaScript 模块类型声明框架的方法。
总结
有几种方法可以从 TypeScript 中使用 JavaScript。表 11-1 总结了这些选项。
表 11-1. 使用 JavaScript 从 TypeScript 的方法总结
| 方法 | tsconfig.json 标志 | 类型安全性 |
|---|---|---|
| 导入未类型化的 JavaScript | {"allowJs": true} |
差 |
| 导入和检查 JavaScript | {"allowJs": true, "checkJs": true} |
良好 |
| 导入和检查带有 JSDoc 注释的 JavaScript | {"allowJs": true, "checkJs": true, "strict": true} |
优秀 |
| 导入具有类型声明的 JavaScript | {"allowJs": false, "strict": true} |
优秀 |
| 导入 TypeScript | {"allowJs": false, "strict": true} |
优秀 |
在本章中,我们涵盖了一些使用 JavaScript 和 TypeScript 结合的各个方面,从不同类型声明的种类及其使用方式,到逐步将现有 JavaScript 项目逐步迁移到 TypeScript,再到安全(以及不安全地)使用第三方 JavaScript。与 JavaScript 的互操作可能是 TypeScript 中最棘手的方面之一;通过手头的所有工具,您现在已经具备在自己的项目中进行操作的能力了。
^(1) 使用 * 进行通配符匹配遵循与常规 glob 模式匹配 相同的规则。
^(2) DefinitelyTyped 是 JavaScript 类型声明的开源存储库。继续阅读以了解更多信息。
^(3) 对于非常大的项目,逐个文件通过 TSC 可能会很慢。有关如何提升大型项目性能的方法,请参见“项目引用”。
^(4) 严格来说,这对于模块模式而言是正确的,但对于脚本模式则不是。详细了解请参阅“模块模式与脚本模式”。
第十二章:构建和运行 TypeScript
如果您在生产环境中部署和运行 JavaScript 应用程序,那么您也知道如何运行 TypeScript 应用程序——一旦您将其编译为 JavaScript,这两者并没有太大的不同。本章讨论的是生产和构建 TypeScript 应用程序,但在 TypeScript 应用程序中并没有太多独特的内容——它大部分也适用于 JavaScript 应用程序。我们将其分为四个部分,涵盖以下内容:
-
您必须做的事情来构建任何 TypeScript 应用程序
-
在服务器上构建和运行 TypeScript 应用程序
-
在浏览器中构建和运行 TypeScript 应用程序
-
为 TypeScript 应用程序构建和发布到 NPM
构建您的 TypeScript 项目
构建 TypeScript 项目非常简单。在本节中,我们将涵盖您需要理解的核心概念,以便为计划运行的任何环境构建项目。
项目布局
我建议将您的源 TypeScript 代码保存在顶级 src/ 文件夹中,并将其编译为顶级 dist/ 文件夹。这种文件结构是一种常见的约定,将源代码和生成的代码分为两个顶级文件夹可以在日后与其他工具集成时简化您的生活。它还使得可以更轻松地将生成的工件排除在源代码控制之外。
尽量坚持这种约定:
my-app/
├──dist/
│ ├──index.d.ts
│ ├──index.js
│ └──services/
│ ├──foo.d.ts
│ ├──foo.js
│ ├──bar.d.ts
│ └──bar.js
├──src/
│ ├──index.ts
│ └──services/
│ ├──foo.ts
│ └──bar.ts
工件
当您将 TypeScript 程序编译为 JavaScript 时,TSC 可以为您生成几种不同的工件(表 12-1)。
表 12-1. TSC 可以为您生成的工件
| 类型 | 文件扩展名 | tsconfig.json 标志 | 默认是否生成? |
|---|---|---|---|
| JavaScript | .js | {"emitDeclarationOnly": false} |
是 |
| 源映射 | .js.map | {"sourceMap": true} |
否 |
| 类型声明 | .d.ts | {"declaration": true} |
否 |
| 声明映射 | .d.ts.map | {"declarationMap": true} |
否 |
第一种工件——JavaScript 文件——应该很熟悉。TSC 将您的 TypeScript 代码编译为 JavaScript,然后您可以使用类似 NodeJS 或 Chrome 的 JavaScript 平台运行它。如果您运行 tsc yourfile.ts,TSC 将对 yourfile.ts 进行类型检查并将其编译为 JavaScript。
第二种类型的工件——源映射——是特殊文件,将生成的每个 JavaScript 片段链接回生成它的 TypeScript 文件的特定行和列。这对于调试代码很有帮助(Chrome DevtTools 将显示您的 TypeScript 代码,而不是生成的 JavaScript),并且可以将 JavaScript 异常堆栈跟踪的行和列映射回 TypeScript(如果您提供了源映射,像“错误监控”中提到的工具会自动执行此查找)。
第三种工件——类型声明——允许其他 TypeScript 项目利用您生成的类型。
最后,声明映射被用来加快 TypeScript 项目的编译时间。你将在“项目引用”中详细了解它们。本章的其余部分将讨论如何以及为何生成这些工件。
调整你的编译目标
JavaScript 可能是一个不寻常的语言来处理:它不仅具有快速发展的规范,每年发布一个版本,而且作为程序员,你并不能总是控制你的程序运行的平台实现了哪个 JavaScript 版本。此外,许多 JavaScript 程序是同构的,这意味着你可以在服务器或客户端上运行它们。例如:
-
如果你在你控制的服务器上运行后端 JavaScript 程序,那么你可以精确控制它将运行的 JavaScript 版本。
-
如果你将你的后端 JavaScript 程序作为开源项目发布,你不知道消费者的 JavaScript 平台将支持哪个 JavaScript 版本。在 NodeJS 环境中,你能做的最好的是声明支持的 NodeJS 版本范围,但在浏览器环境中,你就没那么幸运了。
-
如果你在浏览器中运行你的 JavaScript,你不知道人们将使用哪个浏览器来运行它——最新版本的 Chrome、Firefox 或支持大多数现代 JavaScript 特性的 Edge,稍微过时的其中一个浏览器版本,可能缺少一些前沿功能的浏览器,像 Internet Explorer 8 这样的古老浏览器,或者在你车库里运行的 PlayStation 4 上的嵌入式浏览器。你能做的最好的是定义人们的浏览器需要支持的最小功能集,为尽可能多的这些功能提供 polyfill,并尝试检测用户是否在真正旧的浏览器上,你的应用无法运行,并显示他们需要升级的消息。
-
如果你发布了一个同构的 JavaScript 库(例如,在浏览器和服务器上都能运行的日志库),那么你必须支持一个最低的 NodeJS 版本和一系列浏览器 JavaScript 引擎和版本。
并非每个 JavaScript 环境都能在开箱即用时支持每个 JavaScript 特性,但你仍应该尝试用最新的语言版本编写代码。有两种方法可以做到这一点:
-
转译(即自动转换)应用程序,从最新版本的 JavaScript 转换为目标平台支持的最老的 JavaScript 版本。我们为语言特性如
for..of循环和async/await,可以自动转换为for循环和.then调用,做到这一点。 -
Polyfill(即提供实现)任何现代特性,在你运行的 JavaScript 运行时中缺失。我们为 JavaScript 标准库(如
Promise、Map和Set)以及原型方法(如Array.prototype.includes和Function.prototype.bind)提供这些功能。
TSC 内置支持将你的代码转译为旧版本的 JavaScript,但不会自动为你提供 polyfill。这值得再强调一下:TSC 会为旧环境转译大多数 JavaScript 特性,但不会提供缺失特性的实现。
TSC 提供了三个设置选项,帮助你调整目标环境:
-
target设置你想要编译到的 JavaScript 版本:es5、es2015等等。 -
module设置你想要的模块系统:es2015模块、commonjs模块、systemjs模块等等。 -
lib告诉 TypeScript 目标环境中可用的 JavaScript 特性:es5特性、es2015特性、dom等等。它并不实现这些特性——这就是 polyfill 的作用——但它告诉 TypeScript 这些特性是可用的(无论是原生支持还是通过 polyfill)。
你计划在哪个环境中运行应用程序决定了你应该用 target 编译到哪个 JavaScript 版本,以及应该设置哪些 lib。如果不确定,通常 es5 对于两者都是一个安全的默认值。你设置 module 取决于你是在目标 NodeJS 还是浏览器环境,以及如果是后者,你使用的模块加载器是什么。
提示
如果你需要支持一组不寻常的平台,请查阅 Juriy Zaytsev(又名 Kangax)的 兼容性表格,了解你的目标平台原生支持哪些 JavaScript 特性。
让我们更深入地了解 target 和 lib;关于 “在服务器上运行 TypeScript” 和 “在浏览器中运行 TypeScript”,我们将留给各自的章节。
target
TSC 的内置转译器支持将大多数 JavaScript 特性转译为旧版本的 JavaScript,这意味着你可以使用最新的 TypeScript 版本编写代码,并将其转译为你需要支持的任何 JavaScript 版本。由于 TypeScript 支持最新的 JavaScript 特性(例如 async/await,目前并非所有主要 JavaScript 平台都支持),你几乎总是会利用这个内置转译器将你的代码转换为 NodeJS 和浏览器今天能理解的代码。
让我们看看 TSC 在转译为旧版 JavaScript 时支持哪些具体的 JavaScript 特性,以及不支持哪些特性(表 12-2 和 表 12-3)^(1)。
注意
在过去,JavaScript 语言每隔几年就会发布一个新版本,版本号逐步增加(ES1,ES3,ES5,ES6)。从 2015 年开始,JavaScript 语言现在采用每年发布一次的周期,每个语言版本都以发布的年份命名(ES2015,ES2016 等)。然而,一些 JavaScript 特性在实际进入特定 JavaScript 版本之前就已经获得了 TypeScript 的支持;我们称这些特性为“ESNext”(即下一个修订版本)。
表 12-2. TSC 进行了转译
| 版本 | 功能 |
|---|---|
| ES2015 | const, let, for..of 循环, 数组/对象展开 (...), 带标签的模板字符串, 类, 生成器, 箭头函数, 函数默认参数, 函数剩余参数, 解构声明/赋值/参数 |
| ES2016 | 指数运算符 (**) |
| ES2017 | async 函数,等待 promise |
| ES2018 | async 迭代器 |
| ES2019 | catch 子句中的可选参数 |
| ESNext | 数字分隔符 (123_456) |
表 12-3. TSC 不会进行转译
| 版本 | 功能 |
|---|---|
| ES5 | 对象的 getter/setter |
| ES2015 | 正则表达式 y 和 u 标志 |
| ES2018 | 正则表达式 s 标志 |
| ESNext | BigInt (123n) |
要设置转译目标,请打开您的 tsconfig.json 并将 target 字段设置为:
-
es3代表 ECMAScript 3 -
es5代表 ECMAScript 5(如果不确定使用什么版本,这是一个很好的默认值) -
es6或es2015代表 ECMAScript 2015 -
es2016代表 ECMAScript 2016 -
es2017代表 ECMAScript 2017 -
es2018代表 ECMAScript 2018 -
esnext代表最新的 ECMAScript 修订版本
例如,要编译为 ES5:
{
"compilerOptions": {
"target": "es5"
}
}
lib
正如我所提到的,将您的代码转译为较旧的 JavaScript 版本有一个小问题:虽然大多数语言特性可以安全地转译(let 转为 var,class 转为 function),但是如果您的目标环境不支持较新的库特性,仍然需要自己 polyfill 功能。例如像 Promise 和 Reflect 这样的实用工具,以及像 Map、Set 和 Symbol 这样的数据结构。如果您的目标是最新版本的 Chrome、Firefox 或 Edge 等先进环境,通常不需要任何 polyfills;但是如果您的目标是几个版本之前的浏览器,或者大多数 NodeJS 环境,则需要 polyfill 遗漏的特性。
幸运的是,您不需要自己编写 polyfill。相反,您可以从流行的 polyfill 库如 core-js 安装它们,或者通过运行经过类型检查的 TypeScript 代码通过 @babel/polyfill 自动将 polyfills 添加到您的代码中。
提示
如果你计划在浏览器中运行你的应用程序,请注意不要通过包含每一个 polyfill 来使你的 JavaScript 捆绑包膨胀,无论你的代码运行的浏览器是否实际需要它们 —— 你的目标平台可能已经支持一些你正在 polyfill 的功能。相反,使用像 Polyfill.io 这样的服务,只加载你的用户浏览器需要的那些 polyfill。
一旦你在代码中添加了 polyfill,就需要告诉 TSC 你的环境已经支持了你 polyfill 的功能 —— 输入你的 tsconfig.json 的 lib 字段。例如,如果你已经 polyfill 了所有 ES2015 特性以及 ES2016 的 Array.prototype.includes,你可以使用这个配置:
{
"compilerOptions": {
"lib": ["es2015", "es2016.array.includes"]
}
}
如果你在浏览器中运行你的代码,还要为 DOM 类型声明启用,比如 window、document,以及在浏览器中运行 JavaScript 时获得的所有其他 API:
{
"compilerOptions": {
"lib": ["es2015", "es2016.array.include", `"dom"`]
}
}
要获取支持的 libs 的完整列表,请运行 tsc --help。
启用源映射
源映射是一种将你的编译后代码与生成它的源代码进行关联的方法。大多数开发者工具(如 Chrome DevTools)、错误报告和日志框架以及构建工具都支持源映射。由于典型的构建流水线可能会生成与最初的代码非常不同的代码(例如,你的流水线可能会将 TypeScript 编译为 ES5 JavaScript,使用 Rollup 进行树摇,使用 Prepack 进行预评估,然后使用 Uglify 进行压缩),在整个构建流水线中使用源映射可以大大简化调试生成的 JavaScript 的过程。
通常建议在开发中使用源映射,并在浏览器和服务器环境中将源映射发布到生产环境。不过,有一个注意事项:如果你依赖于某种程度上的安全性通过混淆来保护你的浏览器代码,那么不要在生产环境中将源映射发布到浏览器中。
项目引用
随着你的应用程序增长,TSC 对你的代码进行类型检查和编译所需的时间会越来越长。这个时间大致与你的代码库的大小成正比增长。在本地开发时,缓慢的增量编译时间会严重拖慢你的开发速度,并使得使用 TypeScript 变得困难。
为了解决这个问题,TSC 提供了一个名为 项目引用 的功能,大大加快了编译时间,包括增量编译时间。对于任何具有几百个或更多文件的项目,项目引用都是必不可少的。
像这样使用它们:
-
将你的 TypeScript 项目分割成多个项目。一个项目只是一个包含 tsconfig.json 和一些 TypeScript 代码的文件夹。尝试以这样一种方式分割你的代码,使得通常一起更新的代码位于同一个文件夹中。
-
在每个项目文件夹中,创建一个 tsconfig.json,至少包括以下内容:
{ "compilerOptions": { "composite": true, "declaration": true, "declarationMap": true, "rootDir": "." }, "include": [ "./**/*.ts" ], "references": [ { "path": "../myReferencedProject", "prepend": true } ], }关键在于:
-
composite表示这个文件夹是一个更大的 TypeScript 项目的子项目。 -
declaration,告诉 TSC 为这个项目生成 .d.ts 声明文件。项目引用的工作方式是,项目可以访问彼此的声明文件和生成的 JavaScript,但不能访问它们的源 TypeScript 文件。这创建了一个边界,超出这个边界 TSC 将不会尝试重新检查或重新编译你的代码:如果你在子项目 A 中更新了一行代码,TSC 不必重新检查你的其他子项目 B;TSC 只需要检查 B 的类型声明以查找类型错误。这是使项目引用在重建大型项目时如此高效的核心行为。 -
declarationMap,告诉 TSC 为生成的类型声明构建源映射。 -
references,是一个包含你的子项目依赖的子项目数组。每个引用的path应该指向一个包含 tsconfig.json 文件的文件夹,或直接指向一个 TSC 配置文件(如果你的配置文件不叫 tsconfig.json)。prepend将连接由你引用的子项目生成的 JavaScript 和源映射到你的子项目生成的 JavaScript 和源映射中。注意,只有当你使用outFile时,prepend才有用处——如果你不使用outFile,你可以放弃prepend。 -
rootDir,明确指定该子项目应该相对于根项目 (.) 进行编译。或者,你可以指定一个outDir,作为根项目outDir的子文件夹。
-
-
创建一个根 tsconfig.json,引用任何尚未被其他子项目引用的子项目:
{ "files": [], "references": [ {"path": "./myProject"}, {"path": "./mySecondProject"} ] } -
现在当你使用 TSC 编译你的项目时,使用
build标志告诉 TSC 考虑项目引用:tsc --build # Or, tsc -b for short
警告
目前项目引用是 TypeScript 的一个新功能,有些地方还不够成熟。在使用时,请务必注意以下几点:
-
在克隆或重新获取后,重新构建整个项目(使用
tsc -b),以重新生成任何丢失或过时的 .d.ts 文件。或者,检查你生成的 d.ts 文件。 -
不要在项目引用中使用
noEmitOnError: false—— TSC 将始终将选项硬编码为true。 -
手动确保给定的子项目不被多于一个其他子项目预置。否则,双重预置的子项目将在你的编译输出中显示两次。注意,如果你只是引用而不是预置,那就没问题。
错误监控
TypeScript 在编译时会警告你有关错误,但你也需要一种方法来了解用户在运行时遇到的异常,以便你可以尝试在编译时防止它们(或者至少修复导致运行时错误的 bug)。使用像 Sentry 或 Bugsnag 这样的错误监控工具来报告和整理你的运行时异常。
在服务器上运行 TypeScript
要在 NodeJS 环境中运行你的 TypeScript 代码,只需将你的代码编译成 ES2015 JavaScript(或者如果你的目标是旧版 NodeJS,则编译成 ES5),并且将你的tsconfig.json的 module 标志设置为commonjs:
{
"compilerOptions": {
"target": "es2015",
"module": "commonjs"
}
}
这将把你的 ES2015 的import和export语句编译成require和module.exports,因此你的代码可以在 NodeJS 上运行,无需进一步打包。
如果你正在使用源映射(你应该!),你需要将你的源映射提供给你的 NodeJS 进程。只需从 NPM 获取source-map-support包,并按照该包的设置说明进行设置。大多数进程监控、日志记录和错误报告工具(如PM2,Winston和Sentry)都内置了对源映射的支持。
在浏览器中运行 TypeScript
编译 TypeScript 以在浏览器中运行需要比在服务器上运行 TypeScript 多一些工作。
首先,选择一个模块系统进行编译。一个很好的经验法则是,如果要发布供他人使用的库(例如在 NPM 上),最好使用umd以确保与各种项目中可能使用的模块打包工具兼容。
如果你只打算自己使用代码而不将其发布到 NPM,请根据你正在使用的模块打包工具来决定编译到哪种格式。查看你的打包工具的文档——例如,Webpack 和 Rollup 最适合 ES2015 模块,而 Browserify 需要 CommonJS 模块。以下是一些指南:
-
如果你正在使用SystemJS模块加载器,请将
module设置为systemjs。 -
如果你通过像Webpack或Rollup这样的 ES2015 模块感知的模块打包工具运行你的代码,请将
module设置为es2015或更高版本。 -
如果你正在使用 ES2015 感知的模块打包工具,并且你的代码使用动态导入(参见“动态导入”),请将
module设置为esnext。 -
如果你正在为其他项目构建一个库,并且在
tsc之后没有任何其他构建步骤,请通过将module设置为umd来最大化与人们使用的不同加载器的兼容性。 -
如果你正在使用像Browserify这样的 CommonJS 打包工具打包你的模块,请将
module设置为commonjs。 -
如果你计划使用RequireJS或其他 AMD 模块加载器加载你的代码,请将
module设置为amd。 -
如果你希望你的顶层导出在
window对象上全局可用(例如,如果你是墨索里尼的侄孙),请将module设置为none。请注意,如果你的代码处于模块模式下,TSC 将尝试通过编译成commonjs来遏制你对其他软件工程师施加痛苦的热情(参见“模块模式与脚本模式”)。
接下来,配置你的构建流水线,将所有的 TypeScript 编译成单个 JavaScript 文件(通常称为“捆绑包”)或一组 JavaScript 文件。虽然 TSC 可以通过 outFile TSC 标志为小型项目执行此操作,但该标志仅限于生成 SystemJS 和 AMD 捆绑包。由于 TSC 不支持构建插件和智能代码分割,就像 Webpack 这样的专用构建工具一样,你很快就会发现自己需要一个更强大的打包工具。
因此,对于前端项目,你应该从一开始就使用更强大的构建工具。无论你使用什么构建工具,都有适用于该工具的 TypeScript 插件,例如:
-
tsify适用于 Browserify -
gulp-typescript适用于 Gulp
尽管详细讨论如何优化 JavaScript 捆绑包以实现快速加载超出了本书的范围,但一些简短的建议(不特定于 TypeScript)包括:
-
保持代码模块化,避免在代码中引入隐式依赖(当你将东西分配给
window全局变量或其他全局变量时可能会发生这种情况),这样你的构建工具就能更准确地分析项目的依赖关系。 -
使用动态导入来延迟加载不需要在初始页面加载时就加载的代码,这样可以避免不必要地阻塞页面渲染。
-
充分利用构建工具的自动代码分割功能,以避免加载过多的 JavaScript 代码,从而不必要地减慢页面加载速度。
-
制定页面加载时间测量策略,可以是通过合成数据或理想情况下使用真实用户数据。随着应用程序的增长,初始加载时间可能会变得越来越慢;只有在能够测量加载时间的情况下,你才能优化它。像 New Relic 和 Datadog 这样的工具在这方面非常宝贵。
-
尽量使生产构建与开发构建尽可能相似。两者差异越大,只会在生产环境中才会出现的难以修复的错误就越多。
-
最后,在将 TypeScript 部署到浏览器中运行时,需要制定一个策略来填充缺失的浏览器功能。这可以是一组标准的 polyfill,作为每个捆绑包的一部分进行发布,或者根据用户浏览器支持的功能动态选择 polyfill。
将你的 TypeScript 代码发布到 NPM
编译 TypeScript 代码以便其他 TypeScript 和 JavaScript 项目可以使用。在将 TypeScript 编译为供外部使用的 JavaScript 时,有一些最佳实践需要牢记:
-
生成源映射,这样你就可以调试自己的代码。
-
编译为 ES5,以便其他人可以轻松地构建和运行你的代码。
-
在选择要编译到的模块格式时要注意(UMD、CommonJS、ES2015 等)。
-
生成类型声明,以便其他 TypeScript 用户可以为你的代码提供类型。
首先用 tsc 将你的 TypeScript 编译为 JavaScript,并生成相应的类型声明。确保配置你的tsconfig.json以最大化与流行的 JavaScript 环境和构建系统的兼容性(关于此更多信息,请参见“构建您的 TypeScript 项目”):
{
"compilerOptions": {
"declaration": true,
"module": "umd",
"sourceMaps": true,
"target": "es5"
}
}
接下来,在你的.npmignore中将你的 TypeScript 源代码列入黑名单,以避免将其发布到 NPM 时使包大小膨胀。并且在你的.gitignore中排除生成的工件,以避免污染你的 Git 仓库:
# .npmignore
*.ts # Ignore .ts files
!*.d.ts # Allow .d.ts files
# .gitignore
*.d.ts # Ignore .d.ts files
*.js # Ignore .js files
注意
如果你坚持推荐的项目布局,并将源文件保存在src/中,生成的文件保存在dist/中,你的.ignore文件将会更简单:
# .npmignore
src/ # Ignore source files
# .gitignore
dist/ # Ignore generated files
最后,在你项目的package.json中添加一个 "types" 字段,指示它带有类型声明(注意这不是强制的,但对于使用 TypeScript 的任何消费者,这是一个有用的提示),并添加一个脚本来在发布之前构建你的包,以确保你的包的 JavaScript、类型声明和源映射始终保持更新并与你编译它们的 TypeScript 同步:
{
"name": "my-awesome-typescript-project",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"prepublishOnly": "tsc -d"
}
}
就这样!现在当你将你的包npm publish到 NPM 时,NPM 将自动将你的 TypeScript 编译为可供既使用 TypeScript 的人(具有完全的类型安全性)使用,也供使用 JavaScript 的人(如果他们的代码编辑器支持的话,有一定的类型安全性)使用的格式。
三斜杠指令
TypeScript 自带一个鲜为人知、使用稀少且大多已过时的特性,称为三斜杠指令。这些指令是特别格式化的 TypeScript 注释,用作给 TSC 的指令。
它们有几种不同的风格,在本节中,我们将只涵盖其中的两种:types,用于省略仅类型的完整模块导入,和amd-module,用于命名生成的 AMD 模块。有关完整参考,请参见附录 E。
types 指令
当你从模块导入东西时,根据你导入的内容,当你将代码编译为 JavaScript 时,TypeScript 不会总是需要生成 import 或 require 调用。如果你有一个 import 语句,其导出仅在模块的类型位置中使用(即,你只是从一个模块中导入了一个类型),TypeScript 将不会为该 import 生成任何 JavaScript 代码——可以将其视为仅存在于类型级别。这个特性称为导入省略。
唯一的例外是用于副作用的导入:如果你导入整个模块(而不导入特定的导出或通配符),当你编译 TypeScript 时,该导入将生成 JavaScript 代码。例如,你可能这样做,以确保脚本模式模块中定义的环境类型在你的程序中可用(就像我们在“安全地扩展原型”中所做的那样)。例如:
// global.ts
type MyGlobal = number
// app.ts
import './global'
使用 tsc app.ts 将 app.ts 编译为 JavaScript 后,你会注意到 ./global 的导入并没有被省略:
// app.js
import './global'
如果你发现自己写了这样的导入语句,你可能需要首先确保你的导入确实需要使用副作用,并且没有其他方法可以重写你的代码,使得导入的值或类型更加明确(例如,import {MyType} from './global' — TypeScript 将会为你省略这部分 — 而不是 import './global')。或者,看看是否可以在你的 tsconfig.json 的 types、files 或 include 字段中包含你的环境类型,避免导入整个模块。
如果以上两种方式对你的使用场景都不适用,并且你希望继续使用完整的模块导入但又避免生成 JavaScript 的 import 或 require 调用,可以使用 types 的三斜线指令。三斜线指令由三个斜线 /// 开头,接着是几个可能的 XML 标签之一,每个标签有自己的一组必需属性。对于 types 指令来说,它看起来像这样:
-
声明对环境类型声明的依赖:
/// <reference types="./global" /> -
声明对 @types/jasmine/index.d.ts 的依赖:
/// <reference types="jasmine" />
你可能不会经常使用这个指令。如果确实使用了,请重新考虑如何在项目中使用类型,并考虑是否有办法减少对环境类型的依赖。
amd-module 指令
当将你的 TypeScript 代码编译为 AMD 模块格式(在你的 tsconfig.json 中指定为 {"module": "amd"})时,默认情况下 TypeScript 将生成匿名的 AMD 模块。你可以使用 AMD 的三斜线指令来为生成的模块指定名称。
假设你有以下代码:
export let LogService = {
log() {
// ...
}
}
编译为 amd 模块格式时,TSC 生成了以下 JavaScript 代码:
define(['require', 'exports'], function(require, exports) {
exports.__esModule = true
exports.LogService = {
log() {
// ...
}
}
})
如果你熟悉 AMD 模块格式,你可能注意到这是一个匿名的 AMD 模块。要为你的 AMD 模块指定名称,可以在你的代码中使用 amd-module 的三斜线指令:
/// <amd-module name="LogService" /> 
export let LogService = { 
log() {
// ...
}
}
我们使用 amd-module 指令,并在其上设置了 name 属性。
其余代码保持不变。
使用 TSC 重新编译到 AMD 模块格式后,我们现在得到以下 JavaScript 代码:
*`/// <amd-module name='LogService' />`*
define(`'LogService'``,` ['require', 'exports'], function(require, exports) {
exports.__esModule = true
exports.LogService = {
log() {
*`// ...`*
}
}
})
当编译为 AMD 模块时,使用 amd-module 指令可以使你的代码更易于捆绑和调试(或者,如果可能的话,切换到更现代的模块格式,如 ES2015 模块)。
概要
本章我们涵盖了构建和在生产环境中运行 TypeScript 应用程序所需的一切内容,无论是在浏览器还是服务器上。我们讨论了如何选择要编译的 JavaScript 版本,如何标记环境中可用的库(以及如何在缺失时填充库),以及如何构建和发布带有源映射的应用程序,以便在生产中进行调试和在本地开发时更轻松。然后,我们探讨了如何将您的 TypeScript 项目模块化以保持快速编译时间。最后,我们介绍了如何在服务器和浏览器上运行您的 TypeScript 应用程序,如何将您的 TypeScript 代码发布到 NPM 供他人使用,import elision 的工作原理,以及对于 AMD 用户如何使用三斜杠指令命名您的模块。
^(1) 如果您使用了 TSC 不会转译的语言特性,并且您的目标环境也不支持它,通常可以找到一个 Babel 插件来为您转译它。要找到最新的插件,请在您喜欢的搜索引擎中搜索“babel plugin
第十三章:结论
我们的旅程即将结束。
我们已经涵盖了什么是类型及其有用性;TSC 的工作原理;TypeScript 支持的类型;TypeScript 的类型系统如何处理推断、可赋值性、细化、扩展和完整性;上下文类型的规则;协变性的工作原理;以及如何使用类型操作符。我们还涉及了函数、类、接口、迭代器、可迭代对象和生成器、重载、多态类型、混入、装饰器,以及偶尔可以使用的各种逃生舱口,以牺牲安全性以在截止日期之前完成代码。我们探讨了安全处理异常的不同方法及其权衡,以及如何使用类型使并发、并行和异步程序变得安全。我们深入探讨了如何在流行框架如 Angular 和 React 中使用 TypeScript,以及命名空间和模块的工作方式。我们还讨论了在前端和后端使用、构建和部署 TypeScript 的方法,以及如何逐步迁移代码到 TypeScript,如何使用类型声明,如何将代码发布到 NPM 以供他人使用,如何安全地使用第三方代码,以及如何构建你的 TypeScript 项目。
我希望我已经用静态类型的福音感染了你。我希望你现在偶尔会在实现之前用类型勾勒出程序,希望你已经深刻直观地理解了如何利用类型使你的应用程序更安全。我希望我至少在某种程度上改变了你对世界的看法,希望你现在在编写代码时会思考类型。
现在你已经具备了向他人教授 TypeScript 的能力。倡导安全性,帮助提升你所在公司和你朋友们编写代码的乐趣和质量。
最后,继续探索吧。TypeScript 可能不是你的第一种语言,也可能不是你的最后一种。继续学习新的编程方式,新的类型思维方式,以及在安全性和易用性之间权衡的新方式。也许你会在 TypeScript 之后创造下一个大事件,也许某天我会成为写下这一切的人…
附录 A. 类型操作符
TypeScript 支持丰富的类型操作符,用于处理类型。想要了解更多关于操作符的信息,请使用表格 A-1 作为方便的参考。
表格 A-1. 类型操作符
| 类型操作符 | 语法 | 在哪里使用 | 了解更多 |
|---|---|---|---|
| 类型查询 | typeof, instanceof |
任何类型 | “细化”, “类声明值和类型” |
| 键 | keyof |
对象类型 | “keyof 操作符” |
| 属性查找 | O[K] |
对象类型 | “键入操作符” |
| 映射类型 | [K in O] |
对象类型 | “映射类型” |
| 修饰符加法 | + |
对象类型 | “映射类型” |
| 修饰符减法 | - |
对象类型 | “映射类型” |
| 只读修饰符 | readonly |
对象类型,数组类型,元组类型 | “对象”, “类和继承”, “只读数组和元组” |
| 可选修饰符 | ? |
对象类型,元组类型,函数参数类型 | “对象”, “元组”, “可选和默认参数” |
| 条件类型 | ? |
泛型类型,类型别名,函数参数类型 | “条件类型” |
非null断言 |
! |
可空类型 | “非空断言”, “明确赋值断言” |
| 泛型类型参数默认值 | = |
泛型类型 | “泛型类型默认值” |
| 类型断言 | as, <> |
任何类型 | “类型断言”, “const 类型” |
| 类型守卫 | is |
函数返回类型 | “用户定义的类型守卫” |
附录 B. 类型工具
TypeScript 的类型工具包含在其标准库中。在撰写本文时,表格 B-1 列举了所有可用的工具。
查看 es5.d.ts 获取最新参考。
表格 B-1. 类型工具
| 类型工具 | 使用对象 | 描述 |
|---|---|---|
ConstructorParameters |
类构造函数类型 | 类构造函数参数类型的元组 |
Exclude |
联合类型 | 从另一个类型中排除某一类型 |
Extract |
联合类型 | 选择一个可分配给另一类型的子类型 |
InstanceType |
类构造函数类型 | 从类构造函数 new 实例化得到的实例类型 |
NonNullable |
可空类型 | 排除 null 和 undefined 从类型中 |
Parameters |
函数类型 | 函数参数类型的元组 |
Partial |
对象类型 | 使对象中所有属性可选 |
Pick |
对象类型 | 对象类型的子类型,包含其键的子集 |
Readonly |
数组、对象和元组类型 | 使对象中所有属性只读,或使数组或元组只读 |
ReadonlyArray |
任意类型 | 创建给定类型的不可变数组 |
Record |
对象类型 | 从键类型到值类型的映射 |
Required |
对象类型 | 使对象中所有属性必填 |
ReturnType |
函数类型 | 函数的返回类型 |
附录 C. 作用域声明
TypeScript 声明具有多种行为,用于建模类型和值,与 JavaScript 类似,它们可以以各种方式重载。本附录涵盖了其中两种行为,总结了哪些声明生成类型(以及哪些生成值),以及哪些声明可以合并。
是否生成类型?
一些 TypeScript 声明创建类型,一些创建值,还有一些两者兼具。请参阅表 C-1 进行快速参考。
表 C-1. 声明是否生成类型?
| 关键字 | 是否生成类型? | 是否生成值? |
|---|---|---|
class |
是 | 是 |
const, let, var |
否 | 是 |
enum |
是 | 是 |
function |
否 | 是 |
interface |
是 | 否 |
namespace |
否 | 是 |
type |
是 | 否 |
是否合并?
声明合并是 TypeScript 的核心行为。利用它来创建更丰富的 API,更好地模块化您的代码,并使您的代码更安全。
表 C-2 重新引自“声明合并”;这是一个便于参考的手册,指导哪些类型的声明 TypeScript 将会为您合并。
表 C-2. 声明是否可以合并?
| 用 | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| 值 | 类 | 枚举 | 函数 | 类型别名 | 接口 | 命名空间 | 模块 | |||
| 值 | 否 | 否 | 否 | 否 | 是 | 是 | 否 | — | ||
| 类 | — | 否 | 否 | 否 | 否 | 是 | 是 | — | ||
| 枚举 | — | — | 是 | 否 | 否 | 否 | 是 | — | ||
| 从 | 函数 | — | — | — | 否 | 是 | 是 | 是 | — | |
| 类型别名 | — | — | — | — | 否 | 否 | 是 | — | ||
| 接口 | — | — | — | — | — | 是 | 是 | — | ||
| 命名空间 | — | — | — | — | — | — | 是 | — | ||
| 模块 | — | — | — | — | — | — | — | 是 |
附录 D. 为第三方 JavaScript 模块编写声明文件的配方
本附录涵盖了一些关键的构建块和模式,这些在输入第三方模块时反复出现。要深入讨论输入第三方代码,请转到“在 DefinitelyTyped 上没有类型声明的 JavaScript”。
因为模块声明文件必须存在于 .d.ts 文件中,不能包含值,所以在声明模块类型时,需要使用declare关键字来确认给定类型的值确实被模块导出。表 D-1 提供了常规声明及其类型声明等效的简短摘要。
表 D-1. TypeScript 及其仅类型等效项
| .ts | .d.ts |
|---|---|
var a = 1 |
declare var a: number |
let a = 1 |
declare let a: number |
const a = 1 |
declare const a: 1 |
function a(b) { return b.toFixed() } |
declare function a(b: number): string |
class A { b() { return 3 } } |
declare class A { b(): number } |
namespace A {} |
declare namespace A {} |
type A = number |
type A = number |
interface A { b?: string } |
interface A { b?: string } |
导出类型
模块使用全局、ES2015 或 CommonJS 导出会影响你编写声明文件的方式。
全局
如果你的模块只是将值分配给全局命名空间,并且实际上并没有导出任何东西,你可以创建一个脚本模式文件(参见“模块模式与脚本模式”),并使用declare前缀你的变量、函数和类声明(其他类型的声明如enum、type等保持不变):
// Global variable
declare let someGlobal: GlobalType
// Global class
declare class GlobalClass {}
// Global function
declare function globalFunction(): string
// Global enum
enum GlobalEnum {A, B, C}
// Global namespace
namespace GlobalNamespace {}
// Global type alias
type GlobalType = number
// Global interface
interface GlobalInterface {}
这些声明都将全局可用,无需显式导入到项目中的每个文件。在此,你可以在项目中的任何文件中使用someGlobal,而无需首先导入它,但在运行时,someGlobal需要分配给全局命名空间(浏览器中的window或 NodeJS 中的global)。
要保持文件在脚本模式下,请小心避免在声明文件中使用import和export。
ES2015 导出
如果你的模块使用 ES2015 导出,即export关键字,只需用export替换declare(它确认全局变量已定义):
// Default export
declare let defaultExport: SomeType
export default defaultExport
// Named export
export class SomeExport {
a: SomeOtherType
}
// Class export
export class ExportedClass {}
// Function export
export function exportedFunction(): string
// Enum export
enum ExportedEnum {A, B, C}
// Namespace export
export namespace SomeNamespace {
let someNamespacedExport: number
}
// Type export
export type SomeType = {
a: number
}
// Interface export
export interface SomeOtherType {
b: string
}
CommonJS 导出
在 ES2015 之前,CommonJS 是事实上的模块标准,并且仍然是 NodeJS 的标准。它也使用export关键字,但语法有些不同:
declare let defaultExport: SomeType
export = defaultExport
注意我们是如何将我们的导出分配给export,而不是像我们对 ES2015 导出所做的那样使用export作为修饰符。
第三方 CommonJS 模块的类型声明可以只包含一个导出。要导出多个内容,我们利用声明合并(参见附录 C)。
例如,要为多个导出定义类型而没有默认导出,我们导出一个单一的namespace:
declare namespace MyNamedExports {
export let someExport: SomeType
export type SomeType = number
export class OtherExport {
otherType: string
}
}
export = MyNamedExports
如果一个 CommonJS 模块同时具有默认导出和命名导出呢?我们可以利用声明合并的功能:
declare namespace MyExports {
export let someExport: SomeType
export type SomeType = number
}
declare function MyExports(a: number): string
export = MyExports
UMD 导出
类型化 UMD 模块几乎与类型化 ES2015 模块相同。唯一的区别在于,如果你想让你的模块在脚本模式文件中全局可用(参见“模块模式与脚本模式”),你需要使用特殊的export as namespace语法。例如:
// Default export
declare let defaultExport: SomeType
export default defaultExport
// Named export
export class SomeExport {
a: SomeType
}
// Type export
export type SomeType = {
a: number
}
export as namespace MyModule
注意最后一行——如果你在项目中有一个脚本模式文件,你现在可以直接在全局MyModule命名空间下使用该模块(无需先导入它):
let a = new MyModule.SomeExport
扩展模块
扩展模块类型声明不如给模块加类型常见,但如果你编写了一个 JQuery 插件或 Lodash 混合功能,可能会遇到这种情况。在可能的情况下,尽量避免这样做;相反,考虑使用一个单独的模块。也就是说,不要使用 Lodash 混合功能,而应使用常规函数,不要使用 JQuery 插件——等等,你为什么还在用 JQuery?
全局
如果你想扩展另一个模块的全局命名空间或接口,只需创建一个脚本模式文件(参见“模块模式与脚本模式”),然后增加它。请注意,这仅适用于接口和命名空间,因为 TypeScript 会为您处理它们的合并。
例如,让我们给 JQuery 添加一个新的厉害的marquee方法。我们首先安装jquery本身:
npm install jquery --save
npm install @types/jquery --save-dev
然后我们会在项目中创建一个新文件,比如jquery-extensions.d.ts,并将marquee添加到 JQuery 的全局JQuery接口中(我发现 JQuery 在其类型声明中定义了其方法):
interface JQuery {
marquee(speed: number): JQuery<HTMLElement>
}
现在,在任何使用 JQuery 的文件中,我们都可以使用marquee(当然,我们还需要为marquee添加一个运行时实现):
import $ from 'jquery'
$(myElement).marquee(3)
注意,这与我们在“安全扩展原型”中用来扩展内置全局变量的技术相同。
模块
扩展模块导出有点棘手,并且有更多陷阱:你需要正确地为你的扩展类型,运行时按正确的顺序加载你的模块,并确保当你扩展的模块类型声明结构发生变化时更新你的扩展类型。
例如,让我们为 React 类型化一个新的导出。我们首先安装 React 及其类型声明:
npm install react --save
npm install @types/react --save-dev
然后我们利用模块合并(参见“声明合并”),简单地声明一个与我们的 React 模块同名的模块:
import {ReactNode} from 'react'
declare module 'react' {
export function inspect(element: ReactNode): void
}
注意,与我们用于扩展全局变量的示例不同,我们的扩展文件是否处于模块模式或脚本模式并不重要。
可以考虑从模块中扩展特定的导出功能吗?灵感来自ReasonReact,假设我们想为我们的 React 组件添加一个内置的 reducer(reducer 是为 React 组件声明显式状态转换的一种方式)。在撰写本文时,React 的类型声明将React.Component类型声明为一个接口和一个类,这两者会合并为单个 UMD 导出:
export = React
export as namespace React
declare namespace React {
interface Component<P = {}, S = {}, SS = any>
extends ComponentLifecycle<P, S, SS> {}
class Component<P, S> {
constructor(props: Readonly<P>)
// ...
}
// ...
}
让我们通过在项目根目录中的react-extensions.d.ts文件中输入以下内容来扩展Component与我们的reducer方法:
import 'react' 
declare module 'react' { 
interface Component<P, S> { 
reducer(action: object, state: S): S 
}
}
我们导入'react',将我们的扩展文件切换到脚本模式,这是我们需要以消耗 React 模块的方式。请注意,我们还可以通过导入其他内容、导出某些内容或导出空对象(export {})等其他方式切换到脚本模式,不一定非要专门导入'react'。
我们声明'react'模块,告诉 TypeScript 我们要为该特定的import路径声明类型。因为我们已经安装了@types/react(它为相同的'react'路径定义了一个导出),TypeScript 将会把这个模块声明与@types/react提供的合并。
我们通过声明自己的Component接口来增强 React 提供的Component接口。根据接口合并的规则(“声明合并”),我们在声明中必须使用与@types/react中相同的确切签名。
最后,我们声明我们的reducer方法。
在声明这些类型之后(并假设我们已经在某个地方实现了支持此更新的运行时行为),我们现在可以以类型安全的方式声明具有内置reducers的 React 组件:
import * as React from 'react'
type Props = {
// ...
}
type State = {
count: number
item: string
}
type Action =
| {type: 'SET_ITEM', value: string}
| {type: 'INCREMENT_COUNT'}
| {type: 'DECREMENT_COUNT'}
class ShoppingBasket extends React.Component<Props, State> {
reducer(action: Action, state: State): State {
switch (action.type) {
case 'SET_ITEM':
return {...state, item: action.value}
case 'INCREMENT_COUNT':
return {...state, count: state.count + 1}
case 'DECREMENT_COUNT':
return {...state, count: state.count - 1}
}
}
}
正如本节开头所述,尽量避免可能使您的模块变脆弱且依赖于加载顺序的模式(即使它很酷)。相反,尝试使用组合,使您的模块扩展消耗它们正在扩展的模块,并导出一个包装器,而不是修改该模块。
附录 E. 三斜杠指令
三斜杠指令只是普通的 JavaScript 注释,TypeScript 会寻找这些指令来做一些事情,比如调整特定文件的编译器设置,或者指示你的文件依赖于另一个文件。将你的指令放在文件顶部,任何代码之前。三斜杠指令看起来像这样(每个指令都是三斜杠 ///,后面跟着一个 XML 标签):
/// <directive attr="value" />
TypeScript 支持少量三斜杠指令。表 E-1 列出了你可能会使用的指令:
amd-module
转到 “amd-module 指令” 了解更多。
lib
lib 指令是告诉 TypeScript 你的模块依赖于 TypeScript 的哪些 lib 的一种方式,如果你的项目没有 tsconfig.json,在 tsconfig.json 中声明你依赖的 lib 是几乎总是更好的选择。
path
当使用 TSC 的 outFile 选项时,请使用 path 指令声明对另一个文件的依赖,以便该文件在编译输出中出现在依赖文件之前。如果你的项目使用 import 和 export,你可能永远不会使用此指令。
type
转到 “类型指令” 了解更多关于 type 指令的信息。
表 E-1. 三斜杠指令
| 指令 | 语法 | 用它来… |
|---|---|---|
amd-module |
<amd-module name="MyComponent" /> |
在编译到 AMD 模块时声明导出名称 |
lib |
<reference lib="dom" /> |
声明你的类型声明依赖于 TypeScript 内置的哪些 lib |
path |
<reference path="./path.ts" /> |
声明你的模块依赖哪些 TypeScript 文件 |
type |
<reference types="./path.d.ts" /> |
声明你的模块依赖哪些类型声明文件 |
内部指令
你可能永远不会在你自己的代码中使用 no-default-lib 指令(表 E-2)。
表 E-2. 内部三斜杠指令
| 指令 | 语法 | 用它来… |
|---|---|---|
no-default-lib |
<reference no-default-lib="true" /> |
告诉 TypeScript 在这个文件中完全不使用任何 lib |
废弃的指令
你永远不应该使用 amd-dependency 指令(表 E-3),而是坚持使用普通的 import。
表 E-3. 废弃的三斜杠指令
| 指令 | 语法 | 可以用… |
|---|---|---|
amd-dependency |
<amd-dependency path="./a.ts" name="MyComponent" /> |
import |
附录 F. TSC 编译器安全标志
Tip
要获取完整的编译器标志列表,请访问 TypeScript Handbook website。
每个 TypeScript 发布版本都引入了新的检查,您可以启用这些检查以使代码更加安全。其中一些标志以strict为前缀,作为strict标志的一部分包含在内;或者,您可以逐个选择加入strict标志。表格 F-1 列出了与安全性相关的编译器标志(截至撰写本文时)。
表 F-1. TSC 安全标志
| Flag | Description |
|---|---|
alwaysStrict |
发出 'use strict'。 |
noEmitOnError |
当代码存在类型错误时不生成 JavaScript。 |
noFallthroughCasesInSwitch |
确保每个switch分支要么返回一个值,要么中断。 |
noImplicitAny |
当变量类型被推断为any时报错。 |
noImplicitReturns |
确保每个函数中的每条代码路径都显式返回。参见 “Totality”。 |
noImplicitThis |
在函数中使用this而未显式注释this类型时报错。参见 “Typing this”。 |
noUnusedLocals |
警告未使用的局部变量。 |
noUnusedParameters |
警告未使用的函数参数。使用 _ 前缀忽略此错误。 |
strictBindCallApply |
强制执行bind、call和apply的类型安全性。参见 “call, apply, and bind”。 |
strictFunctionTypes |
强制函数在其参数和this类型上是逆变的。参见 “Function variance”。 |
strictNullChecks |
将null提升为一种类型。参见 “null, undefined, void, and never”。 |
strictPropertyInitialization |
强制类属性要么是可空的,要么被初始化。参见 第五章。 |
附录 G. TSX
TypeScript 在底层公开了几个钩子,用于以可插拔方式为 TSX 类型化。这些是 global.JSX 命名空间上的特殊类型,TypeScript 在整个程序中将其视为 TSX 类型的真实来源。
注意
如果只使用 React,您无需了解这些底层钩子,但如果编写一个不使用 React 而使用 TSX 的 TypeScript 库,本附录为您提供了可以使用的钩子的有用参考。
TSX 支持两种类型的元素:内置元素(内置元素)和用户定义元素(值类型元素)。内置元素始终使用小写名称,并指代内置元素如 <li>、<h1> 和 <div>。值类型元素使用帕斯卡命名法,并指代使用 React(或使用 TSX 与其他前端框架)创建的元素;它们可以定义为函数或类。请参见 图 G-1。

图 G-1. TSX 元素的种类
使用 React 的类型声明 作为示例,我们将详细介绍 TypeScript 用于安全地为 TSX 类型化的钩子。以下是 React 如何将钩子插入 TSX 以安全地为 JSX 类型化的步骤:
declare global {
namespace JSX {
interface Element extends React.ReactElement<any> {} 
interface ElementClass extends React.Component<any> { 
render(): React.ReactNode
}
interface ElementAttributesProperty { 
props: {}
}
interface ElementChildrenAttribute { 
children: {}
}
type LibraryManagedAttributes<C, P> = // ... 
interface IntrinsicAttributes extends React.Attributes {} 
interface IntrinsicClassAttributes<T> extends React.ClassAttributes<T> {} 
interface IntrinsicElements { 
a: React.DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>
abbr: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>
address: React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>
// ...
}
}
}
JSX.Element 是值类型 TSX 元素的类型。
JSX.ElementClass 是值类型类组件实例的类型。每当声明一个类组件,并计划使用 TSX 的 <MyComponent /> 语法实例化时,其类必须满足此接口。
JSX.ElementAttributesProperty 是 TypeScript 查看以确定组件支持哪些属性的属性名称。对于 React,这意味着查看 props 属性。TypeScript 在类实例上查找此值。
JSX.ElementChildrenAttribute 是 TypeScript 查看以确定组件支持哪些类型子节点的属性名称。对于 React,这意味着查看 children 属性。
JSX.IntrinsicAttributes 是所有内置元素支持的属性集。对于 React,这意味着支持 key 属性。
JSX.IntrinsicClassAttributes 是所有类组件(包括内置和值类型)支持的属性集。对于 React,这意味着支持 ref。
JSX.LibraryManagedAttributes 指定 JSX 元素可以声明和初始化属性类型的其他位置。对于 React,这意味着 propTypes 可以作为声明属性类型的另一位置,而 defaultProps 是声明属性默认值的位置。
JSX.IntrinsicElements 枚举了你可以在 TSX 中使用的每种 HTML 元素类型,将每个元素的标签名称映射到其属性和子元素的类型。因为 JSX 不是 HTML,React 的类型声明必须告诉 TypeScript 究竟哪些元素可能会在 TSX 表达式中使用,并且因为你可以在 TSX 中使用任何标准的 HTML 元素,这些声明必须手动枚举每个元素及其属性类型(例如,对于 <a> 标签,有效的属性包括 href: string 和 rel: string,但不包括 value)以及它可能具有的子元素类型。
声明任何这些类型在全局的 JSX 命名空间中,你可以钩入 TypeScript 对 TSX 的类型检查行为,并按需自定义它。除非你正在编写一个使用 TSX(而不使用 React)的库,否则你可能永远不会接触这些钩子。


浙公网安备 33010602011771号