TypeScript-高效编程-全-

TypeScript 高效编程(全)

原文:zh.annas-archive.org/md5/eb22cd25aa0259ea1599c07286670477

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

2016 年春天,我访问了我在 Google 旧同事 Evan Martin 在旧金山办公室,并问他最近对什么感到兴奋。多年来,我问过他同样的问题很多次,因为答案多种多样且不可预测,但总是很有趣:C++构建工具、Linux 音频驱动程序、在线填字游戏、emacs 插件。这次,Evan 对 TypeScript 和 Visual Studio Code 感到兴奋。

我很惊讶!我以前听说过 TypeScript,但我只知道它是由微软创建的,而且我错误地认为它与.NET 有关。作为一名终身 Linux 用户,我无法相信 Evan 竟然加入了微软团队。

然后 Evan 向我展示了 vscode 和 TypeScript playground,我立即被它们征服了。一切都如此迅速,代码智能使得建立类型系统的心理模型变得轻而易举。多年来,我在 Closure Compiler 的 JSDoc 注释中编写类型注解,而现在感觉就像是真正有效的带有类型的 JavaScript。而且微软还基于 Chromium 构建了一个跨平台文本编辑器?也许这是一个值得学习的语言和工具链。

我最近加入了 Sidewalk Labs,并开始编写我们的第一个 JavaScript 代码。代码库仍然很小,Evan 和我能够在接下来的几天内将其全部转换为 TypeScript。

从那以后,我一直着迷。TypeScript 不仅仅是一个类型系统,它还带来了一整套快速易用的语言服务。累积效应是,TypeScript 不仅使 JavaScript 开发更加安全,而且更加有趣!

本书适合谁阅读

Effective 系列书籍旨在成为其领域的“标准第二本书”。如果你之前有一些实际使用 JavaScript 和 TypeScript 的经验,你将能够从 Effective TypeScript 中获得最大的收益。我这本书的目标不是教你 TypeScript 或 JavaScript,而是帮助你从初级或中级用户进阶到专家级别。本书中的内容通过帮助你建立 TypeScript 及其生态系统如何工作的心理模型,使你意识到应避免的陷阱和问题,并指导你以最有效的方式利用 TypeScript 的多种能力来实现这一目标。而参考书将解释一种语言允许你以五种方式执行 X,Effective 书将告诉你应该选择其中哪一种以及为什么选择它。

过去几年中,TypeScript 已经快速发展,但我希望它已经足够稳定,以至于本书内容将在未来数年内仍然有效。本书主要关注语言本身,而不涉及任何框架或构建工具。你不会在本书中找到如何在 TypeScript 中使用 React 或 Angular 的示例,也不会找到如何配置 TypeScript 与 webpack、Babel 或 Rollup 配合使用的内容。本书的建议应该适用于所有 TypeScript 用户。

为什么我写这本书

当我刚开始在谷歌工作时,我得到了第三版《Effective C++》的副本。这本书不像我读过的任何其他编程书籍。它没有试图对初学者友好,也没有试图成为语言的完全指南。它不是告诉你 C++ 的不同特性是什么,而是告诉你如何使用它们以及不应该如何使用它们。它通过数十个短小的具体条目来做到这一点,每个条目都有具体的例子作为动机。

在日常使用语言时阅读所有这些示例的效果是明显的。我以前使用过 C++,但这是我第一次感到对它感到舒适,并知道如何思考它所提供的选择。后来的几年里,我读了《Effective Java》和《Effective JavaScript》时也有类似的经历。

如果你已经熟悉在几种不同的编程语言中工作,那么直接深入到一种新语言的奇怪角落可能是挑战你思维模式并了解其不同之处的有效方法。从撰写本书中,我学到了大量有关 TypeScript 的知识。希望你阅读本书时也能有同样的体验!

本书的组织方式

本书是一系列“条目”,每篇都是一篇短小的技术文章,向您提供有关 TypeScript 某些方面的具体建议。这些条目按主题分组成章节,但请随意跳跃阅读您最感兴趣的部分。

每个条目的标题都传达了关键的要点。当你使用 TypeScript 时,这些是你应该记住的东西,因此浏览目录以记住它们是值得的。例如,如果你正在撰写文档,并且有一种不应该写类型信息的感觉,那么你会知道去阅读 第 30 条:不要在文档中重复类型信息。

每一项的文本都以标题的建议为动机,并通过具体例子和技术论证加以支持。这本书几乎每个观点都通过示例代码进行了演示。我倾向于通过查看示例和略读散文来阅读技术书籍,我想你也会做类似的事情。希望你能阅读散文和解释!但如果你只是略读示例,主要观点仍然应该能够传达。

阅读完条目后,你应该理解为什么它会帮助你更有效地使用 TypeScript。你还会知道足够的信息来判断它是否适用于你的情况。《Effective C++》的作者斯科特·迈尔斯给出了一个令人难忘的例子。他遇到了一个团队的工程师,他们编写的软件在导弹上运行。他们知道可以忽略他关于防止资源泄漏的建议,因为他们的程序在导弹击中目标并且硬件爆炸时总会终止。我不知道有没有带有 JavaScript 运行时的导弹,但詹姆斯·韦伯空间望远镜有一个,所以永远不知道!

最后,每个条目都以“Things to Remember”结束。这些是总结条目的几个要点。如果你在浏览中,可以阅读这些以了解条目的内容和是否希望进一步阅读。你仍然应该阅读条目!但总结在紧急情况下也足够了。

TypeScript 代码示例中的约定

所有代码示例都是 TypeScript,除非从上下文中明确它们是 JSON、GraphQL 或其他语言。使用 TypeScript 的体验主要涉及与编辑器的交互,在印刷中会带来一些挑战。我采用了一些约定来解决这些问题。

大多数编辑器使用波浪线下划线显示错误。要查看完整的错误消息,你可以悬停在下划线文本上。为了指示代码示例中的错误,我在发生错误的地方的注释行中放置波浪线:

let str = 'not a number';
let num: number = str;
 // ~~~ Type 'string' is not assignable to type 'number'

我偶尔会编辑错误消息以提高清晰度和简洁性,但我从不删除错误。如果你将代码示例复制/粘贴到编辑器中,你应该得到精确的错误指示,没有多余的也没有少了的。

为了引起注意,我使用了 // OK:

let str = 'not a number';
let num: number = str as any;  // OK

你应该能够在编辑器中悬停在符号上查看 TypeScript 认为它的类型。为了在文本中表示这一点,我使用以“type is”开头的注释:

let v = {str: 'hello', num: 42};  // Type is { str: string; num: number; }

类型用于行中的第一个符号(在本例中是v)或函数调用的结果:

'four score'.split(' ');  // Type is string[]

这与你的编辑器中看到的字符一致。对于函数调用,你可能需要赋值给临时变量以查看类型。

我偶尔会引入无操作语句来指示代码中特定行的变量类型:

function foo(x: string|string[]) {
  if (Array.isArray(x)) {
    x;  // Type is string[]
  } else {
    x;  // Type is string
  }
}

x;行仅用于展示条件分支中的类型。你不需要(也不应该)在自己的代码中包含这样的语句。

除非另有说明或上下文明确,代码示例都应使用--strict标志进行检查。所有示例都经过 TypeScript 3.7.0-beta 的验证。

本书中使用的排版约定

以下是本书中使用的排版约定:

Italic

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

Constant width

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

Constant width bold

显示用户应该按原样键入的命令或其他文本。

Constant width italic

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

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

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

使用代码示例

可以通过https://github.com/danvk/effective-typescript下载补充材料(代码示例、练习等)。

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

这本书旨在帮助您完成工作。一般而言,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分,否则无需联系我们以获取许可。例如,编写一个使用本书几个代码块的程序不需要许可。销售或分发 O’Reilly 书籍的示例则需要许可。通过引用本书并引用示例代码回答问题不需要许可。将本书的大量示例代码合并到产品文档中确实需要许可。

我们感激,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Effective TypeScript by Dan Vanderkam (O’Reilly). Copyright 2020 Dan Vanderkam, 978-1-492-05374-3.”

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

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media 提供技术和业务培训、知识和见解,帮助企业成功。

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

如何联系我们

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

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

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

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

  • 707-829-0104 (传真)

您可以访问此书的网页,我们在那里列出勘误、示例和任何额外信息,网址为https://oreil.ly/Effective_TypeScript.

要对本书提出评论或提出技术问题,请发送电子邮件至bookquestions@oreilly.com.

有关我们的书籍、课程、会议和新闻的更多信息,请查看我们的网站http://www.oreilly.com.

在 Facebook 上找到我们:http://facebook.com/oreilly

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

在 YouTube 上观看我们:http://www.youtube.com/oreillymedia

致谢

这本书能够问世,离不开许多帮助过我的人。感谢埃文·马丁(Evan Martin)介绍我认识 TypeScript 并教我如何思考它。感谢道威·奥辛加(Douwe Osinga)介绍我与 O'Reilly 的联系并对这个项目给予支持。感谢布雷特·斯拉特金(Brett Slatkin)在结构上的建议,并向我展示我认识的人也能写出一本Effective书籍。感谢斯科特·迈耶斯(Scott Meyers)提出这种格式,并感谢他的“Effective Effective Books”博客文章,为本书提供了重要指导。

感谢我的评审人员,里克·巴塔格林(Rick Battagline)、瑞安·卡瓦纳(Ryan Cavanaugh)、鲍里斯·切尔尼(Boris Cherny)、雅科夫·费恩(Yakov Fain)、杰西·哈莱特(Jesse Hallett)和杰森·基利安(Jason Killian)。感谢所有在 Sidewalk 和我一起多年学习 TypeScript 的同事们。感谢所有帮助这本书问世的 O’Reilly 同事们:安吉拉·鲁菲诺(Angela Rufino)、詹妮弗·波洛克(Jennifer Pollock)、德博拉·贝克(Deborah Baker)、尼克·亚当斯(Nick Adams)和贾斯敏·奎特因(Jasmine Kwityn)。感谢 TypeScript 纽约市的全体成员,杰森、奥尔塔和基里尔,以及所有演讲者。许多条款灵感来自 Meetup 上的演讲,如下所列:

  • 第 3 条受到埃文·马丁(Evan Martin)的一篇博文的启发,我在初学 TypeScript 时觉得尤为启发。

  • 第 7 条受安德斯(Anders)在 TSConf 2018 上关于结构类型和keyof关系的演讲以及杰西·哈莱特(Jesse Hallett)在 2019 年 4 月 TypeScript 纽约市 Meetup 上的演讲启发。

  • 巴萨拉特(Basarat)的指南以及 Stack Overflow 上 DeeV 和 GPicazo 的有用回答对撰写第 9 条至关重要。

  • 第 10 条建立在《Effective JavaScript》(Addison-Wesley)第 4 条中类似的建议之上。

  • 我受到 2019 年 8 月 TypeScript 纽约市 Meetup 上围绕这个主题的大量混淆启发,因此写下了第 11 条。

  • 第 13 条受 Stack Overflow 上关于type vs. interface的几个问题的极大帮助。杰西·哈莱特(Jesse Hallett)建议了关于可扩展性的表述方式。

  • 雅各布·巴斯金(Jacob Baskin)在第 14 条的早期反馈和鼓励。

  • 第 19 条受到提交到 r/typescript subreddit 的几个代码示例的启发。

  • 第 26 条基于我在 Medium 上的写作以及我在 2018 年 10 月 TypeScript 纽约市 Meetup 上的演讲。

  • 第 28 条基于 Haskell 中的常见建议(“让非法状态不可表示”)。法国航空 447 航班的故事灵感来自杰夫·怀斯(Jeff Wise)2011 年在《Popular Mechanics》发表的令人难以置信的文章。

  • 第 29 条基于我在 Mapbox 类型声明中遇到的问题。杰森·基利安(Jason Killian)建议了标题的措辞。

  • 关于命名的建议在第 36 条中很常见,但这个特定的表述灵感来自丹·诺斯(Dan North)在《97 Things Every Programmer Should Know》(O’Reilly)中的短文。

  • 第 37 条目受到 Jason Killian 在 2017 年 9 月第一个 TypeScript NYC Meetup 上的演讲的启发。

  • 第 41 条目基于 TypeScript 2.1 的发布说明。术语“evolving any”在 TypeScript 编译器之外并不广泛使用,但我觉得给这种不寻常的模式取个名字很有用。

  • 第 42 条目受到 Jesse Hallett 博客文章的启发。第 43 条目得益于 Titian Cernicova Dragomir 在 TypeScript 问题#33128 中的反馈。

  • 第 44 条目基于 York Yao 对type-coverage工具的工作。我想要类似的东西,它已经存在了!

  • 第 46 条目基于我在 2017 年 12 月在 TypeScript NYC Meetup 上的演讲。

  • 第 50 条目深受 David Sheldrick 在Artsy博客上关于条件类型的文章的启发,这些文章极大地解开了我对该主题的困惑。

  • 第 51 条目受到 Steve Faulkner(即 southpolesteve)在 2019 年 2 月 Meetup 上的演讲的启发。

  • 第 52 条目基于我在 Medium 上的写作和 typings-checker 工具的工作,后来被整合到了 dtslint 中。

  • 第 53 条目受到 Kat Busch 在 Medium 上关于 TypeScript 各种枚举类型的帖子以及 Boris Cherny 在《Programming TypeScript》(O'Reilly)中关于该主题的写作的启发/强化。

  • 第 54 条目受到我和同事对这个主题的困惑的启发。Anders 在 TypeScript PR #12253 中给出了最终的解释。

  • 编写第 55 条目时,MDN 文档对我至关重要。

  • 第 56 条目松散地基于《Effective JavaScript》(Addison-Wesley)的第 35 条目。

  • 第八章基于我迁移老化的 dygraphs 库的经验。

我找到了许多导致本书的博客文章和演讲,都来源于出色的 r/typescript subreddit。我特别感谢在那里提供了代码示例的开发人员,这些示例对理解 TypeScript 初学者常见问题至关重要。感谢 Marius Schulz 提供的 TypeScript Weekly 通讯。虽然它偶尔才是每周的,但它始终是一个极好的资料来源,也是跟进 TypeScript 的好方法。感谢 Anders、Daniel、Ryan 以及 Microsoft 的整个 TypeScript 团队在演讲和所有问题反馈上的支持。我的大多数问题都是误解,但没有什么比提交一个 bug 然后立即看到 Anders Hejlsberg 本人修复它更令人满足!最后,感谢 Alex 在整个项目期间的支持和理解,包括我完成这个项目所需的所有工作假期、早晨、晚上和周末。

第一章:了解 TypeScript

在我们深入细节之前,本章将帮助你理解 TypeScript 的整体图景。它是什么?你应该如何思考它?它如何与 JavaScript 相关联?它的类型是可空的还是不可空的?any 是什么意思?还有鸭子类型?

TypeScript 作为一种语言有些不同寻常,它既不像 Python 和 Ruby 那样在解释器中运行,也不像 Java 和 C 那样编译成低级语言。相反,它将编译为另一种高级语言,即 JavaScript。运行的是这个 JavaScript 而不是你的 TypeScript。因此,TypeScript 与 JavaScript 的关系至关重要,但也可能引起混淆。了解这种关系将帮助你成为一个更有效的 TypeScript 开发者。

TypeScript 的类型系统也有一些不寻常的地方,你需要注意。后面的章节会更详细地介绍类型系统,但这一章节将提醒你一些它所隐藏的惊喜。

Item 1: 理解 TypeScript 和 JavaScript 之间的关系

如果你长期使用 TypeScript,你必然会听到短语“TypeScript 是 JavaScript 的超集”或“TypeScript 是 JavaScript 的有类型超集”。但这到底意味着什么?TypeScript 和 JavaScript 之间的关系是什么?由于这些语言之间如此密切相关,对它们如何相互关联的深刻理解是有效使用 TypeScript 的基础。

从语法角度看,TypeScript 是 JavaScript 的超集:只要你的 JavaScript 程序没有任何语法错误,它也是一个 TypeScript 程序。很可能 TypeScript 的类型检查器会标记你的代码中的一些问题。但这是一个独立的问题。TypeScript 仍然会解析你的代码并生成 JavaScript。(这是关系的另一个关键部分。我们将在 Item 3 中进一步探讨这一点。)

TypeScript 文件使用 .ts(或 .tsx)扩展名,而不是 JavaScript 文件的 .js(或 .jsx)扩展名。这并不意味着 TypeScript 是一种完全不同的语言!由于 TypeScript 是 JavaScript 的超集,你的 .js 文件中的代码已经是 TypeScript 了。将 main.js 重命名为 main.ts 并不会改变这一点。

如果你将现有的 JavaScript 代码库迁移到 TypeScript,这是非常有帮助的。这意味着你不必重写任何代码以开始使用 TypeScript 并获得它提供的好处。如果你选择将你的 JavaScript 重写为像 Java 这样的语言,情况就不一样了。这种渐进式迁移路径是 TypeScript 的最佳特性之一。关于这个主题,我们将在 Chapter 8 中详细讨论。

所有 JavaScript 程序都是 TypeScript 程序,但反之则不然:有些 TypeScript 程序不是 JavaScript 程序。这是因为 TypeScript 添加了用于指定类型的额外语法。(出于历史原因,它还添加了一些其他语法。见条目 53。)

例如,这是一个有效的 TypeScript 程序:

function greet(who: string) {
  console.log('Hello', who);
}

但是当你通过像node这样期望 JavaScript 的程序运行时,你会得到一个错误:

function greet(who: string) {
                  ^

SyntaxError: Unexpected token :

: string是特定于 TypeScript 的类型注解。一旦使用了它,你就超出了纯 JavaScript(参见图 1-1)。

图 1-1. 所有 JavaScript 都是 TypeScript,但并非所有 TypeScript 都是 JavaScript

这并不意味着 TypeScript 对普通 JavaScript 程序没有提供价值。它有!例如,这段 JavaScript 程序:

let city = 'new york city';
console.log(city.toUppercase());

当你运行它时会抛出一个错误:

TypeError: city.toUppercase is not a function

此程序中没有类型注解,但 TypeScript 的类型检查器仍能够发现问题:

let city = 'new york city';
console.log(city.toUppercase());
              // ~~~~~~~~~~~ Property 'toUppercase' does not exist on type
              //             'string'. Did you mean 'toUpperCase'?

你不必告诉 TypeScriptcity的类型是string:它是从初始值推断出来的。类型推断是 TypeScript 的关键部分,第三章探讨了如何使用它。

TypeScript 类型系统的目标之一是检测在运行时会抛出异常的代码,而无需运行你的代码。当你听到 TypeScript 被描述为“静态”类型系统时,就是指这一点。类型检查器并不总能够发现会抛出异常的代码,但它会尝试。

即使你的代码没有抛出异常,它可能仍然无法达到你的意图。TypeScript 也试图捕获其中一些问题。例如,这段 JavaScript 程序:

const states = [
  {name: 'Alabama', capital: 'Montgomery'},
  {name: 'Alaska',  capital: 'Juneau'},
  {name: 'Arizona', capital: 'Phoenix'},
  // ...
];
for (const state of states) {
  console.log(state.capitol);
}

将记录:

undefined
undefined
undefined

糟糕!出了什么问题?这段程序是有效的 JavaScript(因此也是 TypeScript)。它运行时没有抛出任何错误。但显然它没有执行你的意图。即使没有添加类型注解,TypeScript 的类型检查器也能够发现错误(并提供有用的建议):

for (const state of states) {
  console.log(state.capitol);
                 // ~~~~~~~ Property 'capitol' does not exist on type
                 //         '{ name: string; capital: string; }'.
                 //         Did you mean 'capital'?
}

TypeScript 即使没有提供类型注解,也能捕获错误,但如果提供了类型注解,它将能够做得更彻底。这是因为类型注解告诉 TypeScript 你的意图是什么,这使它能够发现代码行为与意图不符的地方。例如,如果在之前的例子中反转了capital/capitol的拼写错误呢?

const states = [
  {name: 'Alabama', capitol: 'Montgomery'},
  {name: 'Alaska',  capitol: 'Juneau'},
  {name: 'Arizona', capitol: 'Phoenix'},
  // ...
];
for (const state of states) {
  console.log(state.capital);
                 // ~~~~~~~ Property 'capital' does not exist on type
                 //         '{ name: string; capitol: string; }'.
                 //         Did you mean 'capitol'?
}

之前如此有帮助的错误现在完全错了!问题在于你用两种不同的方式拼写了同一个属性,而 TypeScript 不知道哪一个是正确的。它可以猜测,但并不总是正确的。解决方法是通过显式声明states的类型来澄清你的意图:

interface State {
  name: string;
  capital: string;
}
const states: State[] = [
  {name: 'Alabama', capitol: 'Montgomery'},
                 // ~~~~~~~~~~~~~~~~~~~~~
  {name: 'Alaska',  capitol: 'Juneau'},
                 // ~~~~~~~~~~~~~~~~~
  {name: 'Arizona', capitol: 'Phoenix'},
                 // ~~~~~~~~~~~~~~~~~~ Object literal may only specify known
                 //         properties, but 'capitol' does not exist in type
                 //         'State'.  Did you mean to write 'capital'?
  // ...
];
for (const state of states) {
  console.log(state.capital);
}

现在错误匹配问题,建议的修复方法是正确的。通过明确表达我们的意图,你还帮助 TypeScript 发现了其他潜在问题。例如,如果你在数组中只错拼了一次 capitol,之前不会有错误。但有了类型注解,就有了:

const states: State[] = [
  {name: 'Alabama', capital: 'Montgomery'},
  {name: 'Alaska',  capitol: 'Juneau'},
                 // ~~~~~~~~~~~~~~~~~ Did you mean to write 'capital'?
  {name: 'Arizona', capital: 'Phoenix'},
  // ...
];

从 Venn 图的角度来看,我们可以添加一个新的程序组:通过类型检查的 TypeScript 程序(见 Figure 1-2)。

efts 0102

图 1-2. 所有 JavaScript 程序都是 TypeScript 程序。但只有一些 JavaScript(和 TypeScript)程序通过了类型检查器。

如果“TypeScript 是 JavaScript 的超集”这种说法让你觉得不对劲,可能是因为你在考虑图中的第三组程序。在实践中,这是使用 TypeScript 的日常体验中最相关的问题。通常在使用 TypeScript 时,你会努力确保你的代码通过所有类型检查。

TypeScript 的类型系统 模拟 了 JavaScript 的运行时行为。如果你来自于有更严格运行时检查的语言,这可能会带来一些意外。例如:

const x = 2 + '3';  // OK, type is string
const y = '2' + 3;  // OK, type is string

这两个语句虽然在许多其他语言中会产生运行时错误,但它们都通过了类型检查器。但这确实模拟了 JavaScript 的运行时行为,其中两个表达式都会产生字符串 "23"。

尽管如此,TypeScript 在某些情况下会有所限制。类型检查器会标记所有这些语句中的问题,即使它们在运行时不会抛出异常:

const a = null + 7;  // Evaluates to 7 in JS
       // ~~~~ Operator '+' cannot be applied to types ...
const b = [] + 12;  // Evaluates to '12' in JS
       // ~~~~~~~ Operator '+' cannot be applied to types ...
alert('Hello', 'TypeScript');  // alerts "Hello"
            // ~~~~~~~~~~~~ Expected 0-1 arguments, but got 2

TypeScript 类型系统的指导原则是它应该模拟 JavaScript 的运行时行为。但在所有这些情况下,TypeScript 更可能认为奇怪的使用是错误而不是开发者的意图,因此它超越了简单地模拟运行时行为。我们在 capital/capitol 示例中看到了另一个例子,程序没有抛出异常(而是记录了 undefined),但类型检查器仍然标记了一个错误。

TypeScript 如何决定何时模拟 JavaScript 的运行时行为,何时超越它?最终这是一种品味的问题。通过选择 TypeScript,你正在信任构建它的团队的判断。如果你喜欢添加 null 和 7 或者 [] 和 12,或者调用带有多余参数的函数,那么 TypeScript 可能不适合你!

如果你的程序通过了类型检查,它在运行时仍然可能会抛出错误吗?答案是“是的”。以下是一个例子:

const names = ['Alice', 'Bob'];
console.log(names[2].toUpperCase());

运行此代码时,会抛出异常:

TypeError: Cannot read property 'toUpperCase' of undefined

TypeScript 假定数组访问将在边界内,但实际并非如此。结果是一个异常。

当你使用 any 类型时,也经常会出现未捕获的错误,我们将在 Item 5 和 Chapter 5 中详细讨论。

这些异常的根本原因是 TypeScript 对值的类型理解与现实已经有所不同。一个可以保证其静态类型准确性的类型系统被称为sound。TypeScript 的类型系统在很大程度上不是 sound,并且从未打算如此。如果准确性对你很重要,你可能需要考虑其他语言,如 Reason 或 Elm。尽管这些语言提供了更多的运行时安全性保证,但这也带来了代价:它们都不是 JavaScript 的超集,因此迁移会更加复杂。

要记住的事情

  • TypeScript 是 JavaScript 的超集。换句话说,所有 JavaScript 程序已经是 TypeScript 程序。TypeScript 有一些自己的语法,因此一般来说,TypeScript 程序并不是有效的 JavaScript 程序。

  • TypeScript 添加了一个类型系统,模拟了 JavaScript 的运行时行为,并尝试识别在运行时抛出异常的代码。但你不应该期望它标记每个异常。代码可以通过类型检查器,但仍然可能在运行时抛出异常。

  • 虽然 TypeScript 的类型系统主要模拟 JavaScript 的行为,但也有一些 JavaScript 允许的构造,但 TypeScript 选择禁止,例如调用函数时参数数量不正确。这在很大程度上是一种品味问题。

条目 2:了解你正在使用的 TypeScript 选项

这段代码通过了类型检查器吗?

function add(a, b) {
  return a + b;
}
add(10, null);

不知道你正在使用哪些选项,是不可能说的!在撰写本文时,TypeScript 编译器有一个庞大的选项集,几乎有 100 个。

它们可以通过命令行设置:

$ tsc --noImplicitAny program.ts

或通过配置文件,tsconfig.json:

{
  "compilerOptions": {
    "noImplicitAny": true
  }
}

你应该偏爱配置文件。它确保你的同事和工具都清楚你计划如何使用 TypeScript。你可以通过运行tsc --init来创建一个配置文件。

TypeScript 的许多配置设置控制它查找源文件的位置以及生成什么样的输出。但有一些控制语言核心方面的设置。这些是高级设计选择,大多数语言不会留给用户。根据如何配置它,TypeScript 可能感觉像是一种非常不同的语言。要有效使用它,你应该理解其中最重要的设置:noImplicitAny和strictNullChecks。

noImplicitAny控制变量是否必须具有已知类型。当noImplicitAny关闭时,此代码是有效的:

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

如果你在编辑器中悬停在add符号上,它将显示 TypeScript 已推断出该函数的类型:

function add(a: any, b: any): any

any类型有效地禁用了涉及这些参数的类型检查器。any是一个有用的工具,但应谨慎使用。有关更多关于any的信息,请参见条目 5 和第三章。

这些称为implicit anys,因为你从未写过any这个词,但最终却得到了危险的any类型。如果设置了noImplicitAny选项,则会出现错误:

function add(a, b) {
          // ~    Parameter 'a' implicitly has an 'any' type
          //    ~ Parameter 'b' implicitly has an 'any' type
  return a + b;
}

可以通过显式编写类型声明来修复这些错误,例如: any或更具体的类型:

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

TypeScript 在有类型信息时最为有帮助,因此你应尽可能设置noImplicitAny。一旦你习惯了所有变量都有类型,没有noImplicitAny的 TypeScript 几乎感觉像是一种不同的语言。

对于新项目,你应该从noImplicitAny开始,这样你编写代码时就能同时编写类型。这将帮助 TypeScript 检测问题,提高代码可读性,并增强开发体验(参见第 6 项)。仅在从 JavaScript 迁移项目到 TypeScript 时,才适合关闭noImplicitAny(参见第八章)。

strictNullChecks控制在每种类型中是否允许null和undefined值。

当strictNullChecks关闭时,此代码有效:

const x: number = null;  // OK, null is a valid number

但当你打开strictNullChecks时,则会触发错误:

const x: number = null;
//    ~ Type 'null' is not assignable to type 'number'

如果你使用undefined而不是null,可能会发生类似的错误。

如果你的意图是允许null,你可以通过明确你的意图来修复错误:

const x: number | null = null;

如果你不希望允许null,你需要跟踪它来自哪里,并添加检查或断言:

   const el = document.getElementById('status');
   el.textContent = 'Ready';
// ~~ Object is possibly 'null'

   if (el) {
     el.textContent = 'Ready';  // OK, null has been excluded
   }
   el!.textContent = 'Ready';  // OK, we've asserted that el is non-null

strictNullChecks在捕获涉及null和undefined值的错误方面非常有帮助,但确实增加了使用语言的难度。如果你要开始一个新项目,请尝试设置strictNullChecks。但如果你对这种语言不熟悉或者要迁移一个 JavaScript 代码库,你可能选择不启用它。在设置strictNullChecks之前,你应该确保先设置noImplicitAny。

如果选择不使用strictNullChecks,请注意可怕的“undefined is not an object”运行时错误。这些错误提醒你应考虑启用更严格的检查。随着项目的增长,改变这个设置会变得越来越困难,因此在启用它之前不要等待太久。

还有许多其他设置影响语言语义(例如noImplicitThis和strictFunctionTypes),但与noImplicitAny和strictNullChecks相比,这些都是次要的。要启用所有这些检查,请打开strict设置。TypeScript 能够通过strict捕获大部分错误,因此这是你最终想要达到的地方。

知道你使用的是哪些选项!如果同事分享了一个 TypeScript 示例,而你无法重现他们的错误,请确保你的编译器选项是相同的。

要记住的事情

  • TypeScript 编译器包括几个影响语言核心方面的设置。

  • 使用tsconfig.json配置 TypeScript 而不是命令行选项。

  • 除非你正在将 JavaScript 项目迁移到 TypeScript,否则请打开noImplicitAny。

  • 使用strictNullChecks来防止类似“undefined is not an object”的运行时错误。

  • 目标是启用strict,以获取 TypeScript 能提供的最彻底检查。

项目 3:了解代码生成独立于类型之外

在高级别上,tsc(TypeScript 编译器)执行两件事:

  • 它将下一代 TypeScript/JavaScript 转换为在浏览器中可用的旧版本 JavaScript(“转换”)。

  • 它检查你的代码是否存在类型错误。

令人惊讶的是这两种行为完全独立于彼此。换句话说,在你的代码中的类型无法影响 TypeScript 生成的 JavaScript。因为执行的是这段 JavaScript 代码,这意味着你的类型不能影响代码的运行方式。

这有一些令人惊讶的含义,应该让你对 TypeScript 能够做什么以及不能做什么有所期待。

存在类型错误的代码可以产生输出

因为代码输出与类型检查无关,所以代码存在类型错误时仍然可以产生输出!

$ cat test.ts
let x = 'hello';
x = 1234;
$ tsc test.ts
test.ts:2:1 - error TS2322: Type '1234' is not assignable to type 'string'

2 x = 1234;
  ~

$ cat test.js
var x = 'hello';
x = 1234;

如果你之前使用过像 C 或 Java 这样的语言,这可能会相当令人惊讶,因为类型检查和输出是紧密相关的。你可以将 TypeScript 中的所有错误看作是那些语言中的警告:它们很可能表示有问题,值得调查,但不会阻止构建。

在实践中,即使存在错误,代码的输出也是有帮助的。如果你正在构建一个 Web 应用程序,你可能知道其中的某个部分存在问题。但因为 TypeScript 仍然会生成代码,你可以在修复问题之前测试应用程序的其他部分。

当你提交代码时,应该力求零错误,以免陷入记住什么是预期或意外错误的陷阱。如果你想要在错误时禁用输出,可以在 tsconfig.json 中使用 noEmitOnError 选项,或者在你的构建工具中使用等效选项。

你不能在运行时检查 TypeScript 类型。

你可能会被诱惑编写这样的代码:

interface Square {
  width: number;
}
interface Rectangle extends Square {
  height: number;
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape instanceof Rectangle) {
                    // ~~~~~~~~~ 'Rectangle' only refers to a type,
                    //           but is being used as a value here
    return shape.width * shape.height;
                    //         ~~~~~~ Property 'height' does not exist
                    //                on type 'Shape'
  } else {
    return shape.width * shape.width;
  }
}

instanceof 检查发生在运行时,但 Rectangle 是一种类型,因此它不能影响代码的运行行为。TypeScript 类型是“可擦除的”:编译成 JavaScript 的一部分只是从你的代码中删除所有的 interface、type 和类型注解。

要确定你处理的形状的类型,你需要一些方法在运行时重建它的类型。在这种情况下,你可以检查 height 属性的存在:

function calculateArea(shape: Shape) {
  if ('height' in shape) {
    shape;  // Type is Rectangle
    return shape.width * shape.height;
  } else {
    shape;  // Type is Square
    return shape.width * shape.width;
  }
}

这是因为属性检查仅涉及运行时可用的值,但仍允许类型检查器将 shape 的类型细化为 Rectangle。

另一种方法是引入一个“标签”,以显式方式存储在运行时可用的类型:

interface Square {
  kind: 'square';
  width: number;
}
interface Rectangle {
  kind: 'rectangle';
  height: number;
  width: number;
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape.kind === 'rectangle') {
    shape;  // Type is Rectangle
    return shape.width * shape.height;
  } else {
    shape;  // Type is Square
    return shape.width * shape.width;
  }
}

这里的 Shape 类型是“标记联合”的一个例子。由于它们可以轻松地在运行时恢复类型信息,标记联合在 TypeScript 中非常常见。

有些结构引入了类型(运行时不可用)和值(可用)。class 关键字就是其中之一。将 Square 和 Rectangle 设为类也是解决错误的另一种方法:

class Square {
  constructor(public width: number) {}
}
class Rectangle extends Square {
  constructor(public width: number, public height: number) {
    super(width);
  }
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape instanceof Rectangle) {
    shape;  // Type is Rectangle
    return shape.width * shape.height;
  } else {
    shape;  // Type is Square
    return shape.width * shape.width;  // OK
  }
}

这有效是因为 class Rectangle 同时引入了类型和值,而 interface 只引入了类型。

type Shape = Square | Rectangle 中的 Rectangle 指的是类型,但 shape instanceof Rectangle 中的 Rectangle 指的是值。理解这一区别很重要,但可能相当微妙。请参阅 Item 8。

类型操作无法影响运行时值

假设您有一个可能是字符串或数字的值,并且希望将其规范化为始终为数字。这是类型检查器接受的一个错误尝试:

function asNumber(val: number | string): number {
  return val as number;
}

查看生成的 JavaScript 可以明确此函数的实际作用:

function asNumber(val) {
  return val;
}

根本没有进行任何转换。as number 是一种类型操作,因此它不能影响您的代码的运行时行为。要规范化值,您需要检查其运行时类型,并使用 JavaScript 构造进行转换:

function asNumber(val: number | string): number {
  return typeof(val) === 'string' ? Number(val) : val;
}

(as number 是类型断言。有关何时适用这些断言的更多信息,请参阅 Item 9。)

运行时类型可能与声明类型不同

此函数是否可能最终触发 console.log?

function setLightSwitch(value: boolean) {
  switch (value) {
    case true:
      turnLightOn();
      break;
    case false:
      turnLightOff();
      break;
    default:
      console.log(`I'm afraid I can't do that.`);
  }
}

TypeScript 通常会标记死代码,但即使使用了 strict 选项,它也不会抱怨此代码。如何进入这个分支?

关键在于记住 boolean 是声明类型。因为它是 TypeScript 类型,在运行时会消失。在 JavaScript 代码中,用户可能会误将 setLightSwitch 与 "ON" 等值一起调用。

在纯 TypeScript 中也有触发此代码路径的方法。也许函数被调用时,其值来自网络调用:

interface LightApiResponse {
  lightSwitchValue: boolean;
}
async function setLight() {
  const response = await fetch('/light');
  const result: LightApiResponse = await response.json();
  setLightSwitch(result.lightSwitchValue);
}

您已声明 /light 请求的结果为 LightApiResponse,但没有任何机制来强制执行此声明。如果误解了 API,并且 lightSwitchValue 实际上是一个 string,那么在运行时将传递一个字符串给 setLightSwitch。或者也许在部署后 API 发生了变化。

当您的运行时类型与声明类型不匹配时,TypeScript 可能会变得相当混乱,这是您应尽量避免的情况。但请注意,可能会存在值具有不同于您声明类型的类型。

您不能基于 TypeScript 类型重载函数

类似 C++ 的语言允许您定义多个版本的函数,这些函数仅在其参数类型上有所不同。这称为“函数重载”。因为您的代码的运行时行为与其 TypeScript 类型无关,所以在 TypeScript 中无法使用此构造:

function add(a: number, b: number) { return a + b; }
      // ~~~ Duplicate function implementation
function add(a: string, b: string) { return a + b; }
      // ~~~ Duplicate function implementation

TypeScript 确实 提供了一种函数重载的机制,但它完全在类型级别上运作。您可以为函数提供多个声明,但只能有一个实现:

function add(a: number, b: number): number;
function add(a: string, b: string): string;

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

const three = add(1, 2);  // Type is number
const twelve = add('1', '2');  // Type is string

add 的前两个声明仅提供类型信息。当 TypeScript 生成 JavaScript 输出时,它们将被移除,只剩下实现部分。(如果您使用此类型的重载风格,请首先查看 项目 50。这里有一些微妙之处需要注意。)

TypeScript 类型对运行时性能没有影响

因为生成 JavaScript 时类型和类型操作被擦除,它们不能影响运行时性能。 TypeScript 的静态类型确实是零成本的。下次有人提出运行时开销不使用 TypeScript 的理由时,您会准确地知道他们对这种说法进行了多么充分的测试!

对此有两个注意事项:

  • 虽然没有 运行时 开销,但 TypeScript 编译器会引入 构建时 开销。TypeScript 团队认真对待编译器性能,编译通常相当快,特别是对于增量构建来说。如果开销变得显著,您的构建工具可能有“仅转译”选项以跳过类型检查。

  • TypeScript 生成的代码以支持旧版本运行时 可能 会导致性能开销与原生实现相比。例如,如果您使用生成器函数并将目标设置为 ES5(比生成器早期),则 tsc 将生成一些辅助代码以使其正常工作。这可能会比生成器的本机实现有一些开销。无论如何,这与发射目标和语言级别有关,并且仍然独立于 类型。

要记住的事情

  • 代码生成与类型系统无关。这意味着 TypeScript 类型无法影响代码的运行行为或性能。

  • 一个带有类型错误的程序可能会生成代码(“编译”)。

  • TypeScript 类型在运行时不可用。要在运行时查询类型,您需要某种方式来重建它。标签联合和属性检查是常用的方法。一些构造,如 class,引入了 TypeScript 类型和在运行时可用的值。

项目 4:熟悉结构化类型

JavaScript 本质上是鸭子类型的:如果您将具有所有正确属性的值传递给函数,它不会关心您如何创建该值。它只会使用它。(“如果它走起来像鸭子,叫起来像鸭子……”)TypeScript 模拟了这种行为,有时可能会导致令人惊讶的结果,因为类型检查器对类型的理解可能比您设想的更广泛。对结构类型有很好的理解将有助于您理解错误和非错误,并帮助您编写更健壮的代码。

假设您正在开发一个物理库,并且有一个二维向量类型:

interface Vector2D {
  x: number;
  y: number;
}

您编写一个函数来计算它的长度:

function calculateLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y);
}

现在你引入了命名向量的概念:

interface NamedVector {
  name: string;
  x: number;
  y: number;
}

calculateLength 函数将与 NamedVector 一起工作,因为它们具有 x 和 y 属性,这些属性是 number 类型。TypeScript 足够智能,可以理解这一点:

const v: NamedVector = { x: 3, y: 4, name: 'Zee' };
calculateLength(v);  // OK, result is 5

有趣的是,你从未声明过 Vector2D 和 NamedVector 之间的关系。你也不必为 NamedVector 写一个 calculateLength 的替代实现。TypeScript 的类型系统模拟了 JavaScript 的运行时行为(Item 1)。它允许 calculateLength 被 NamedVector 调用,因为它的结构与 Vector2D 兼容。这就是“结构化类型”这个术语的来源。

但这也可能带来麻烦。比如你添加了一个 3D 向量类型:

interface Vector3D {
  x: number;
  y: number;
  z: number;
}

并编写一个函数将它们归一化(使它们的长度为 1):

function normalize(v: Vector3D) {
  const length = calculateLength(v);
  return {
    x: v.x / length,
    y: v.y / length,
    z: v.z / length,
  };
}

如果你调用这个函数,你可能会得到一个比单位长度长的东西:

> normalize({x: 3, y: 4, z: 5})
{ x: 0.6, y: 0.8, z: 1 }

那么出了什么问题,为什么 TypeScript 没有捕获到这个错误?

Bug 是 calculateLength 在 2D 向量上运行,但 normalize 在 3D 向量上运行。因此在归一化中忽略了 z 分量。

calculateLength 方法没有捕获到这个问题。为什么你可以用 3D 向量调用 calculateLength,尽管其类型声明说它只接受 2D 向量?

在命名向量中奏效良好的东西在这里产生了反效果。使用 {x, y, z} 对象调用 calculateLength 不会抛出错误。因此类型检查器也不会抱怨,这种行为导致了一个 bug。(如果你希望这是一个错误,你有一些选项。我们将在 Item 37 中返回这个例子。)

当你编写函数时,很容易想象它们将被调用,参数具有你声明的属性而且没有其他属性。这被称为“密封”或“精确”类型,在 TypeScript 的类型系统中无法表达。不管你喜欢与否,你的类型是“开放”的。

这有时会带来意外:

function calculateLengthL1(v: Vector3D) {
  let length = 0;
  for (const axis of Object.keys(v)) {
    const coord = v[axis];
               // ~~~~~~~ Element implicitly has an 'any' type because ...
               //         'string' can't be used to index type 'Vector3D'
    length += Math.abs(coord);
  }
  return length;
}

为什么这是一个错误?因为 axis 是 v 的键之一,它是一个 Vector3D,所以它应该是 "x"、"y" 或 "z" 之一。根据 Vector3D 的声明,这些都是 number,所以 coord 的类型不应该是 number 吗?

这个错误是假阳性吗?不是!TypeScript 正确地抱怨了。前面段落的逻辑假定 Vector3D 是密封的,并且没有其他属性。但它可能有:

const vec3D = {x: 3, y: 4, z: 1, address: '123 Broadway'};
calculateLengthL1(vec3D);  // OK, returns NaN

因为 v 可能具有任何属性,所以 axis 的类型是 string。TypeScript 没有理由相信 v[axis] 是一个数字,因为正如你刚刚看到的,它可能不是。正确地对对象进行迭代可能很棘手。我们将在 Item 54 中返回到这个话题,但在这种情况下,没有循环的实现会更好:

function calculateLengthL1(v: Vector3D) {
  return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z);
}

结构化类型也可能在比较 class 时引发意外,因为它们在分配性上进行结构化比较:

class C {
  foo: string;
  constructor(foo: string) {
    this.foo = foo;
  }
}

const c = new C('instance of C');
const d: C = { foo: 'object literal' };  // OK!

为什么d可以赋值给C?它有一个foo属性,类型是string。此外,它有一个可以用一个参数调用的constructor(来自Object.prototype),尽管通常不带参数调用。所以结构匹配。如果你在C的构造函数中有逻辑,并编写一个假设它运行的函数,这可能会带来惊喜。这与 C++ 或 Java 等语言完全不同,那里声明类型C的参数保证它要么是C,要么是其子类。

当你编写测试时,结构类型是有益的。假设你有一个函数,该函数在数据库上运行查询并处理结果:

interface Author {
  first: string;
  last: string;
}
function getAuthors(database: PostgresDB): Author[] {
  const authorRows = database.runQuery(`SELECT FIRST, LAST FROM AUTHORS`);
  return authorRows.map(row => ({first: row[0], last: row[1]}));
}

要测试这一点,你可以创建一个模拟PostgresDB。但更好的方法是使用结构类型并定义一个更窄的接口:

interface DB {
  runQuery: (sql: string) => any[];
}
function getAuthors(database: DB): Author[] {
  const authorRows = database.runQuery(`SELECT FIRST, LAST FROM AUTHORS`);
  return authorRows.map(row => ({first: row[0], last: row[1]}));
}

你仍然可以在生产中将getAuthors传递给PostgresDB,因为它有一个runQuery方法。由于结构类型,PostgresDB不需要声明它实现了DB。TypeScript 将确定它确实实现了。

当你编写测试时,可以传入一个更简单的对象:

test('getAuthors', () => {
  const authors = getAuthors({
    runQuery(sql: string) {
      return [['Toni', 'Morrison'], ['Maya', 'Angelou']];
    }
  });
  expect(authors).toEqual([
    {first: 'Toni', last: 'Morrison'},
    {first: 'Maya', last: 'Angelou'}
  ]);
});

TypeScript 将验证我们的测试DB是否符合接口。而且你的测试不需要了解生产数据库的任何内容:无需模拟库!通过引入一个抽象(DB),我们从特定实现(PostgresDB)的细节中解放出逻辑(和测试)。

结构类型的另一个优点是它可以清晰地断开库之间的依赖关系。关于此内容,请参阅条款 51。

需要记住的事情

  • 理解 JavaScript 是鸭子类型,并且 TypeScript 使用结构类型来建模:可分配给你的接口的值可能具有超出类型声明中显式列出的属性。类型不是“封闭”的。

  • 请注意,类也遵循结构类型规则。你可能不会得到你期望的类的实例!

  • 使用结构类型来促进单元测试。

条款 5:限制对any类型的使用

TypeScript 的类型系统是渐进的和可选的:渐进是因为你可以逐步向你的代码添加类型,可选是因为你可以随时禁用类型检查器。这些特性的关键在于any类型:

   let age: number;
   age = '12';
// ~~~ Type '"12"' is not assignable to type 'number'
   age = '12' as any;  // OK

类型检查器在这里是正确的,但你可以通过输入as any来消除它。当你开始使用 TypeScript 时,很容易在你不理解错误时、认为类型检查器是错误的时候,或者简单地不想花时间编写类型声明时,就会使用any类型和类型断言(as any)。在某些情况下,这可能是可以接受的,但请注意any消除了使用 TypeScript 的许多优势。在使用之前,你至少应该了解其危险性。

任何类型都没有类型安全性

在前面的示例中,类型声明表明age是一个number。但是any让您可以将string赋值给它。类型检查器会认为它是一个number(毕竟这是您说的),混乱将不会被捕获:

age += 1;  // OK; at runtime, age is now "121"

any让您违反了契约

当您编写一个函数时,您正在指定一个契约:如果调用者给您某种类型的输入,您将产生某种类型的输出。但是使用any类型,您可以打破这些契约:

function calculateAge(birthDate: Date): number {
  // ...
}

let birthDate: any = '1990-01-19';
calculateAge(birthDate);  // OK

出生日期参数应该是Date类型,而不是string类型。使用any类型允许您违反calculateAge的契约。这可能特别问题,因为 JavaScript 通常愿意在类型之间进行隐式转换。string有时可以在期望number的地方工作,但在其他情况下会中断。

任何类型都没有语言服务

当符号具有类型时,TypeScript 语言服务能够提供智能自动完成和上下文文档(如图 1-3 所示)。

efts 01in01

图 1-3. TypeScript 语言服务能够为具有类型的符号提供上下文自动完成。

但对于具有any类型的符号,您需要靠自己(见图 1-4)。

efts 01in02

图 1-4. 对具有任意类型的符号的属性没有自动完成。

重命名是另一种服务。如果您有一个 Person 类型和用于格式化人名的函数:

interface Person {
  first: string;
  last: string;
}

const formatName = (p: Person) => `${p.first} ${p.last}`;
const formatNameAny = (p: any) => `${p.first} ${p.last}`;

然后您可以在编辑器中选择first,选择“重命名符号”,并将其更改为firstName(参见图 1-5 和 1-6)。

efts 01in03

图 1-5. 在 vscode 中重命名符号。

img { width: 50% !important; }

图 1-6. 选择新名称。TypeScript 语言服务确保项目中使用该符号的所有地方也会被重命名。

这更改了formatName函数,但未更改any版本:

interface Person {
  firstName: string;
  last: string;
}
const formatName = (p: Person) => `${p.firstName} ${p.last}`;
const formatNameAny = (p: any) => `${p.first} ${p.last}`;

TypeScript 的座右铭是“可扩展的 JavaScript”。“可扩展”的关键部分是语言服务,这是 TypeScript 经验的核心部分(参见项目 6)。失去它们将导致生产力损失,不仅对您而言,对所有与您的代码一起工作的人来说也是如此。

any类型在重构代码时掩盖了错误

假设您正在构建一个 Web 应用程序,用户可以选择某种项目。您的某个组件可能会有一个onSelectItem回调。编写一个 Item 类型似乎很麻烦,因此您只需使用any作为替代品:

interface ComponentProps {
  onSelectItem: (item: any) => void;
}

下面是管理该组件的代码:

function renderSelector(props: ComponentProps) { /* ... */ }

let selectedId: number = 0;
function handleSelectItem(item: any) {
  selectedId = item.id;
}

renderSelector({onSelectItem: handleSelectItem});

后来,您以使整个item对象传递到onSelectItem更难的方式重写了选择器。但这没什么大不了,因为您只需要 ID。您在ComponentProps中更改了签名:

interface ComponentProps {
  onSelectItem: (id: number) => void;
}

您更新了组件,一切都通过了类型检查器。胜利!

…或者说呢?handleSelectItem接受一个any参数,因此它对 Item 和 ID 一样满意。它会产生一个运行时异常,尽管通过了类型检查器。如果你使用了更具体的类型,类型检查器就会捕捉到这个问题。

any隐藏了你的类型设计

像应用程序状态这样的复杂对象的类型定义可能会变得非常长。与其为页面状态中的数十个属性编写类型定义,你可能会诱惑于只使用any类型就搞定。

这一点存在问题,原因列在此项目中。但它也因为隐藏了你的状态设计而变得棘手。正如第四章所述,良好的类型设计对于编写干净、正确和易理解的代码至关重要。使用any类型时,你的类型设计变得隐含。这使得很难知道设计是否良好,甚至根本不知道设计是什么。如果你让同事审查一个变更,他们将不得不重建应用程序状态的变更情况和方式。最好将其写出来供所有人看到。

any削弱了对类型系统的信心

每当你犯错,类型检查器捕获到它时,这会增强你对类型系统的信心。但当你在运行时看到类型错误时,这种信心会受到打击。如果你正在一个较大团队引入 TypeScript,这可能会让你的同事质疑是否值得使用 TypeScript。any类型通常是这些未捕获错误的根源。

TypeScript 旨在让你的生活更轻松,但带有大量any类型的 TypeScript 可能比无类型的 JavaScript 更难处理,因为你不仅需要修复类型错误,还需要记住真正的类型。当你的类型与现实匹配时,它将解放你,不再需要将类型信息记在脑中,TypeScript 会为你跟踪它。

当你必须使用any类型时,有更好和更糟糕的方法。关于如何限制any带来的不利影响,详见第五章。

要记住的事情

  • any类型有效地沉默了类型检查器和 TypeScript 语言服务。它可能掩盖真正的问题,损害开发者体验,并削弱类型系统的信心。尽量在可以避免使用它的时候避免!

第二章:TypeScript 的类型系统

TypeScript 生成代码(Item 3),但类型系统才是重头戏。这就是您使用这种语言的原因!

本章将带您深入理解 TypeScript 的类型系统的细节:如何思考它,如何使用它,您需要做出的选择以及应避免的特性。TypeScript 的类型系统出人意料地强大,能够表达您可能不希望类型系统能够表达的内容。本章内容将为您提供一个坚实的基础,使您能够在编写 TypeScript 和阅读本书的其他部分时获得支持。

项目 6:使用编辑器查询和探索类型系统

安装 TypeScript 时,您会得到两个可执行文件:

  • tsc,TypeScript 编译器

  • tsserver,TypeScript 独立服务器

您更有可能直接运行 TypeScript 编译器,但服务器同样重要,因为它提供语言服务。这些服务包括自动完成、检查、导航和重构。通常您通过编辑器使用这些服务。如果您的编辑器没有配置好以提供这些服务,那么您会错过很多!自动完成等服务是使 TypeScript 如此令人愉快的因素之一。但除了便利性之外,您的编辑器还是构建和测试您对类型系统知识的最佳场所。这将帮助您建立对 TypeScript 能够推断类型时机的直觉,这对编写紧凑、惯用的代码至关重要(见 Item 19)。

细节会因编辑器而异,但通常您可以将鼠标悬停在符号上,查看 TypeScript 认为其类型是什么(见 Figure 2-1)。

efts 02in01

图 2-1. 一个编辑器(vscode),显示num符号的推断类型为number

在这里你没有写number,但是 TypeScript 能够根据值 10 推断出来。

您还可以检查函数,如 Figure 2-2 所示。

efts 0201

图 2-2. 使用编辑器显示函数的推断类型

值得注意的信息是返回类型number的推断值。如果这与您的期望不符,应添加类型声明并找出差异(参见 Item 9)。

在任何给定点看到 TypeScript 对变量类型的理解是建立关于扩展(Item 21)和缩小(Item 22)直觉的关键。看到变量类型在条件分支中发生变化是建立对类型系统信心的重要方式(见 Figure 2-3)。

efts 0202

图 2-3. 变量message在分支外是string | null,但在分支内是string

您可以检查较大对象中的单个属性,查看 TypeScript 对它们的推断(参见 图 2-4)。

efts 0203

图 2-4. 检查 TypeScript 如何推断对象中的类型

如果您的意图是让 x 成为元组类型([number, number, number]),那么将需要一个类型注解。

要查看操作链中间推断的泛型类型,请检查方法名(如 图 2-5 所示)。

efts 0204

图 2-5. 揭示方法调用链中推断的泛型类型

Array<string> 表明 TypeScript 理解 split 产生了一个字符串数组。在这种情况下几乎没有歧义,但在编写和调试长函数调用链时,这些信息可能是至关重要的。

在您的编辑器中看到类型错误也是学习类型系统细微差别的好方法。例如,此函数尝试通过其 ID 获取 HTMLElement,或返回一个默认值。TypeScript 标记了两个错误:

function getElement(elOrId: string|HTMLElement|null): HTMLElement {
  if (typeof elOrId === 'object') {
    return elOrId;
 // ~~~~~~~~~~~~~~ 'HTMLElement | null' is not assignable to 'HTMLElement'
  } else if (elOrId === null) {
    return document.body;
  } else {
    const el = document.getElementById(elOrId);
    return el;
 // ~~~~~~~~~~ 'HTMLElement | null' is not assignable to 'HTMLElement'
  }
}

在 if 语句的第一个分支中的意图是仅过滤到对象,即 HTMLElement。但奇怪的是,在 JavaScript 中,typeof null 是 "object",因此在该分支中 elOrId 仍然可能是 null。您可以通过先进行 null 检查来解决此问题。第二个错误是因为 document.getElementById 可能返回 null,因此您还需要处理这种情况,可能是通过抛出异常。

语言服务还可以帮助您浏览库和类型声明。假设您在代码中看到对 fetch 函数的调用,并想了解更多信息。您的编辑器应该提供一个“跳转到定义”的选项。在我的编辑器中看起来就像 图 2-6 中的那样。

efts 02in02

图 2-6. TypeScript 语言服务提供了一个“跳转到定义”的功能,应该在您的编辑器中显示。

选择此选项会进入 lib.dom.d.ts,这是 TypeScript 为 DOM 包含的类型声明:

declare function fetch(
  input: RequestInfo, init?: RequestInit
): Promise<Response>;

您可以看到 fetch 返回一个 Promise 并接受两个参数。在 RequestInfo 上点击会带您到这里:

type RequestInfo = Request | string;

从这里可以进入 Request:

declare var Request: {
    prototype: Request;
    new(input: RequestInfo, init?: RequestInit): Request;
};

在这里,您可以看到 Request 类型和值是分开建模的(参见 条目 8)。您已经见过 RequestInfo 了。点击 RequestInit 显示您可以用来构建 Request 的所有内容:

interface RequestInit {
    body?: BodyInit | null;
    cache?: RequestCache;
    credentials?: RequestCredentials;
    headers?: HeadersInit;
    // ...
}

在这里,您可以看到还有许多类型可以跟进,但您已经了解到了。类型声明一开始可能难以阅读,但它们是了解 TypeScript 可以做什么、您正在使用的库是如何建模的以及如何调试错误的绝佳方式。有关类型声明的更多信息,请参见 第六章。

需要记住的事情

  • 利用 TypeScript 语言服务,通过使用能够使用它们的编辑器。

  • 使用你的编辑器来建立对类型系统如何工作以及 TypeScript 如何推断类型的直觉。

  • 知道如何跳转到类型声明文件中,查看它们如何建模行为。

条目 7:将类型视为值集合

在运行时,每个变量从 JavaScript 的值宇宙中选择一个单一值。有许多可能的值,包括:

  • 42

  • null

  • undefined

  • 'Canada'

  • {animal: 'Whale', weight_lbs: 40_000}

  • /regex/

  • new HTMLButtonElement

  • (x, y) => x + y

但在你的代码运行之前,当 TypeScript 检查它是否有错误时,它只有一个 类型。最好将其视为 可能值的集合。这个集合称为类型的 域。例如,你可以将 number 类型视为所有数字值的集合。42 和 -37.25 在其中,但 'Canada' 不在其中。根据 strictNullChecks,null 和 undefined 可能包括在集合中或不包括在集合中。

最小的集合是空集,不包含任何值。它对应于 TypeScript 中的 never 类型。因为它的域为空,所以没有值可分配给具有 never 类型的变量:

const x: never = 12;
   // ~ Type '12' is not assignable to type 'never'

下一个最小的集合是那些包含单个值的集合。这些对应于 TypeScript 中的字面类型,也称为单元类型:

type A = 'A';
type B = 'B';
type Twelve = 12;

要形成包含两个或三个值的类型,可以联合单元类型:

type AB = 'A' | 'B';
type AB12 = 'A' | 'B' | 12;

等等。联合类型对应于值集合的并集。

“可分配”的词出现在许多 TypeScript 错误中。在值集合的背景下,它的意思是“成员关系”(用于值和类型之间的关系)或“子集关系”(用于两种类型之间的关系):

const a: AB = 'A';  // OK, value 'A' is a member of the set {'A', 'B'}
const c: AB = 'C';
   // ~ Type '"C"' is not assignable to type 'AB'

类型 "C" 是一个单元类型。它的域包含单个值 "C"。这不是 AB 的域的子集(它包含值 "A" 和 "B"),所以这是一个错误。在一天结束时,类型检查器几乎在做的全部工作就是测试一个集合是否是另一个集合的子集:

// OK, {"A", "B"} is a subset of {"A", "B"}:
const ab: AB = Math.random() < 0.5 ? 'A' : 'B';
const ab12: AB12 = ab;  // OK, {"A", "B"} is a subset of {"A", "B", 12}

declare let twelve: AB12;
const back: AB = twelve;
   // ~~~~ Type 'AB12' is not assignable to type 'AB'
   //        Type '12' is not assignable to type 'AB'

这些类型的集合易于推理,因为它们是有限的。但是在实际工作中,大多数类型具有无限的域。推理这些类型可能更难。你可以将它们视为建设性地构建:

type Int = 1 | 2 | 3 | 4 | 5 // | ...

或通过描述它们的成员:

interface Identified {
  id: string;
}

将此接口视为其类型域中值的描述。值是否具有一个 id 属性,其值可分配给(成员于)string?那么它就是 Identifiable。

就是 这样。正如第 4 项所解释的那样,TypeScript 的结构化类型规则意味着该值可能具有其他属性。它甚至可能是可调用的!这个事实有时可能会被过多的属性检查掩盖(见第 11 项)。

将类型视为值集合有助于你推理它们的操作。例如:

interface Person {
  name: string;
}
interface Lifespan {
  birth: Date;
  death?: Date;
}
type PersonSpan = Person & Lifespan;

&运算符计算两种类型的交集。哪些值属于PersonSpan类型?乍一看,Person和Lifespan接口没有共同的属性,所以你可能期望它是空集(即never类型)。但类型操作适用于值的集合(类型的定义域),而不是接口中的属性。请记住,具有额外属性的值仍然属于一种类型。因此,具有同时具有Person和Lifespan属性的值将属于交集类型:

const ps: PersonSpan = {
  name: 'Alan Turing',
  birth: new Date('1912/06/23'),
  death: new Date('1954/06/07'),
};  // OK

当然,一个值可以具有超过这三个属性,仍然属于该类型!总的规则是,交集类型中的值包含每个构成部分中的属性的并集。

对于联合接口的交叉属性的直觉是正确的,而不是它们的交集:

type K = keyof (Person | Lifespan);  // Type is never

TypeScript 无法保证联合类型中的值属于任何键,因此联合类型的keyof必须是空集(never)。或者更正式地说:

keyof (A&B) = (keyof A) | (keyof B)
keyof (A|B) = (keyof A) & (keyof B)

如果你能理解这些方程式的原因,你将更深入地理解 TypeScript 的类型系统!

另一种写PersonSpan类型的更常见方式可能是用extends:

interface Person {
  name: string;
}
interface PersonSpan extends Person {
  birth: Date;
  death?: Date;
}

将类型视为值的集合,extends意味着什么?就像“可赋值给”,你可以理解为“子集”。每个PersonSpan中的值必须有一个是string类型的name属性。并且每个值还必须有一个birth属性,所以它是一个真子集。

你可能会听到术语“子类型”。这是说一个集合的定义域是另一个的子集的另一种方式。以一维、二维和三维向量为例思考:

interface Vector1D { x: number; }
interface Vector2D extends Vector1D { y: number; }
interface Vector3D extends Vector2D { z: number; }

你可以说Vector3D是Vector2D的子类型,Vector2D是Vector1D的子类型(在类的上下文中,你会说“子类”)。这种关系通常被绘制为层次结构,但从值集合的角度来看,Venn 图更为合适(参见图 2-7)。

efts 0205

图 2-7. 类型关系的两种思考方式:作为层次结构或重叠集合

通过 Venn 图,清楚地显示了如果你重新编写接口而不使用extends,子集/子类型/可赋值关系不会改变:

interface Vector1D { x: number; }
interface Vector2D { x: number; y: number; }
interface Vector3D { x: number; y: number; z: number; }

集合没有改变,因此 Venn 图也没有改变。

虽然这两种解释对于对象类型都是可行的,但是当你开始思考文字类型和联合类型时,集合解释变得更加直观。extends也可以作为泛型类型中的约束出现,在这种情况下,它也意味着“子集”(参见项目 14):

function getKey<K extends string>(val: any, key: K) {
  // ...
}

扩展 string 是什么意思?如果你习惯于对象继承的思维方式,这很难解释。你可以定义 String 对象包装类型的子类(条款 10),但这似乎是不明智的。

另一方面,从集合的角度来看,情况就很明确了:任何域为 string 的类型都可以。这包括字符串字面类型、字符串字面类型的联合和 string 本身:

getKey({}, 'x');  // OK, 'x' extends string
getKey({}, Math.random() < 0.5 ? 'a' : 'b');  // OK, 'a'|'b' extends string
getKey({}, document.title);  // OK, string extends string
getKey({}, 12);
        // ~~ Type '12' is not assignable to parameter of type 'string'

在最后一个错误中,“extends” 变成了 “assignable”,但这不应该使我们困惑,因为我们知道这两者都可以解读为 “子集”。这也适用于有限集合,比如你可能从 keyof T 中获得的那些集合,它返回对象类型的键的类型:

interface Point {
  x: number;
  y: number;
}
type PointKeys = keyof Point;  // Type is "x" | "y"

function sortBy<K extends keyof T, T>(vals: T[], key: K): T[] {
  // ...
}
const pts: Point[] = [{x: 1, y: 1}, {x: 2, y: 0}];
sortBy(pts, 'x');  // OK, 'x' extends 'x'|'y' (aka keyof T)
sortBy(pts, 'y');  // OK, 'y' extends 'x'|'y'
sortBy(pts, Math.random() < 0.5 ? 'x' : 'y');  // OK, 'x'|'y' extends 'x'|'y'
sortBy(pts, 'z');
         // ~~~ Type '"z"' is not assignable to parameter of type '"x" | "y"

当你有类型之间关系不严格层次化时,集合解释也更加合理。例如 string|number 和 string|Date 之间的关系是什么?它们的交集非空(是 string),但彼此都不是对方的子集。它们域之间的关系是明确的,即使这些类型不符合严格的层次结构(见 图 2-8)。

efts 0206

图 2-8. 联合类型可能不符合层次结构,但可以通过值集合来考虑。

把类型看作集合也可以澄清数组和元组之间的关系。例如:

const list = [1, 2];  // Type is number[]
const tuple: [number, number] = list;
   // ~~~~~ Type 'number[]' is missing the following
   //       properties from type '[number, number]': 0, 1

是否存在不是数字对的数字列表?当然有!空列表和列表 [1] 就是例子。因此,number[] 不能分配给 [number, number],因为它不是其子集。(反向赋值确实有效。)

三元组是否可以分配给对?从结构化类型的角度来看,你可能会期望它可以。一对具有 0 和 1 键,所以它可能还有其他键,比如 2?

const triple: [number, number, number] = [1, 2, 3];
const double: [number, number] = triple;
   // ~~~~~~ '[number, number, number]' is not assignable to '[number, number]'
   //          Types of property 'length' are incompatible
   //          Type '3' is not assignable to type '2'

答案是否定的,并且原因很有趣。不像把一对数字建模为 {0: number, 1: number},TypeScript 将其建模为 {0: number, 1: number, length: 2}。这是有道理的——你可以检查元组的长度——并且这种赋值被排除在外。这可能是最好的选择!

如果将类型视为值的集合,这意味着具有相同值集合的两种类型是相同的。的确如此。除非两种类型在语义上不同,只是恰好具有相同的域,否则没有理由定义相同的类型两次。

最后值得注意的是,并非所有值集合都对应于 TypeScript 类型。并没有适用于所有整数的 TypeScript 类型,或者所有具有 x 和 y 属性但没有其他属性的对象的 TypeScript 类型。有时候你可以使用 Exclude 进行类型减法,但只有在它会产生一个合适的 TypeScript 类型时才行:

type T = Exclude<string|Date, string|number>;  // Type is Date
type NonZeroNums = Exclude<number, 0>;  // Type is still just number

表 2-1 总结了 TypeScript 术语与集合论术语之间的对应关系。

表 2-1. TypeScript 术语和集合术语

TypeScript 术语 集合术语
never ∅(空集)
文字类型 单一元素集合
Value assignable to T Value ∈ T(成员)
T1 assignable to T2 T1 ⊆ T2(子集)
T1 extends T2 T1 ⊆ T2(子集)
T1 | T2 T1 ∪ T2(并集)
T1 & T2 T1 ∩ T2(交集)
unknown 通用集合

要记住的事情

  • 把类型想象为值的集合(类型的域)。这些集合可以是有限的(例如 boolean 或文字类型),也可以是无限的(例如 number 或 string)。

  • TypeScript 类型形成交集集合(文氏图),而不是严格的层次结构。两种类型可以重叠,而没有一种是另一种的子类型。

  • 记住,即使一个对象具有未在类型声明中提到的额外属性,它仍然可以属于某种类型。

  • 类型操作应用于集合的域。A 和 B 的交集是 A 的域和 B 的域的交集。对于对象类型,这意味着 A & B 中的值具有 A 和 B 的属性。

  • 把“extends”、“assignable to”和“subtype of”看作是“subset of”的同义词。

项目 8:了解如何判断符号是在类型空间还是值空间中

TypeScript 中的符号存在于两种空间之一:

  • 类型空间

  • 值空间

这可能会令人困惑,因为同一名称可以根据其所在的空间引用不同的内容:

interface Cylinder {
  radius: number;
  height: number;
}

const Cylinder = (radius: number, height: number) => ({radius, height});

interface Cylinder 在类型空间引入了一个符号。const Cylinder 在值空间引入了一个同名符号。它们彼此无关。根据上下文,当你键入 Cylinder 时,你可能指的是类型或值。有时这会导致错误:

function calculateVolume(shape: unknown) {
  if (shape instanceof Cylinder) {
    shape.radius
       // ~~~~~~ Property 'radius' does not exist on type '{}'
  }
}

这里发生了什么?你可能打算使用 instanceof 检查形状是否是 Cylinder 类型。但 instanceof 是 JavaScript 的运行时操作符,它作用于值。所以 instanceof Cylinder 是指函数,而不是类型。

一开始并不总是明显一个符号是在类型空间还是值空间中。你必须从符号出现的上下文中判断。这可能会特别令人困惑,因为许多类型空间构造与值空间构造看起来完全一样。

例如,文字类型:

type T1 = 'string literal';
type T2 = 123;
const v1 = 'string literal';
const v2 = 123;

通常,在 type 或 interface 后面的符号位于类型空间中,而在 const 或 let 声明中引入的符号位于值空间中。

建立对两个空间直觉的最佳方法之一是通过 TypeScript Playground,它显示了您的 TypeScript 源代码的生成 JavaScript。类型在编译时被擦除(项目 3),所以如果一个符号消失了,那么它很可能是在类型空间中(参见 图 2-9)。

efts 0207

图 2-9. TypeScript Playground 显示生成的 JavaScript。第一行和第二行的符号消失了,所以它们应该是在类型空间中。

TypeScript 中的语句可以在类型空间和值空间之间交替使用。在类型声明后面的符号(:)或断言后面的内容(as)位于类型空间,而在 = 后面的内容位于值空间。例如:

interface Person {
  first: string;
  last: string;
}
const p: Person = { first: 'Jane', last: 'Jacobs' };
//    -           --------------------------------- Values
//       ------ Type

特别是函数语句可以在空间之间交替使用:

function email(p: Person, subject: string, body: string): Response {
  //     ----- -          -------          ----  Values
  //              ------           ------        ------   -------- Types
  // ...
}

class 和 enum 构造引入了类型和值的概念。在第一个示例中,Cylinder 应该是一个 class:

class Cylinder {
  radius=1;
  height=1;
}

function calculateVolume(shape: unknown) {
  if (shape instanceof Cylinder) {
    shape  // OK, type is Cylinder
    shape.radius  // OK, type is number
  }
}

类型脚本中由类引入的类型基于其形状(其属性和方法),而值是构造函数。

许多运算符和关键字在类型或值的上下文中有不同的含义。例如 typeof:

type T1 = typeof p;  // Type is Person
type T2 = typeof email;
    // Type is (p: Person, subject: string, body: string) => Response

const v1 = typeof p;  // Value is "object"
const v2 = typeof email;  // Value is "function"

在类型上下文中,typeof 获取一个值并返回其 TypeScript 类型。您可以将其作为较大类型表达式的一部分使用,或使用 type 语句为其命名。

在值的上下文中,typeof 是 JavaScript 运行时的 typeof 操作符。它返回一个包含符号运行时类型的字符串。这与 TypeScript 类型不同!JavaScript 运行时类型系统比 TypeScript 的静态类型系统简单得多。与 TypeScript 类型的无限多样性相比,在 JavaScript 中历史上只有六种运行时类型:“string”,“number”,“boolean”,“undefined”,“object” 和 “function”。

typeof 总是作用于值。无法将其应用于类型。class 关键字引入了值和类型,那么类的 typeof 是什么?这取决于上下文:

const v = typeof Cylinder;  // Value is "function"
type T = typeof Cylinder;  // Type is typeof Cylinder

值是 "function",这是由于 JavaScript 中类的实现方式。类型并不特别明显。重要的是它不是 Cylinder(实例的类型)。它实际上是构造函数,您可以通过在 new 中使用它来看到:

declare let fn: T;
const c = new fn();  // Type is Cylinder

您可以使用 InstanceType 泛型在构造函数类型和实例类型之间进行转换:

type C = InstanceType<typeof Cylinder>;  // Type is Cylinder

[] 属性访问器在类型空间中也有一个外观相同的等效项。但请注意,虽然 obj['field'] 和 obj.field 在值空间中是等效的,但在类型空间中不是。您必须使用前者来获取另一个类型的属性类型:

const first: Person['first'] = p['first'];  // Or p.first
   // -----                    ---------- Values
   //        ------ ------- Types

Person['first'] 在此处是 类型,因为它出现在类型上下文中(在 : 之后)。您可以在索引位置放置任何类型,包括联合类型或原始类型:

type PersonEl = Person['first' | 'last'];  // Type is string
type Tuple = [string, number, Date];
type TupleEl = Tuple[number];  // Type is string | number | Date

有关更多信息,请参见 Item 14。

还有许多其他构造具有两个空间中不同含义:

  • 值空间中的 this 是 JavaScript 的 this 关键字(参见 Item 49)。作为类型,this 是 TypeScript 中的 this 类型,也称为“多态的 this”。它有助于使用子类实现方法链。

  • 在值空间中,& 和 | 是位与和位或。在类型空间中,它们是交集和并集运算符。

  • const 引入了一个新变量,但 as const 改变了文本或文本表达式的推断类型(参见 Item 21)。

  • extends 可以定义一个子类(class A extends B)或一个子类型(interface A extends B),或者是对泛型类型的约束(Generic<T extends number>)。

  • in 可以是循环的一部分(for (key in object)),也可以是映射类型的一部分(Item 14)。

如果 TypeScript 完全不理解你的代码,可能是因为在类型和值空间之间产生了混淆。例如,假设你修改之前的 email 函数以在单个对象参数中接受其参数:

function email(options: {person: Person, subject: string, body: string}) {
  // ...
}

在 JavaScript 中,你可以使用解构赋值为对象的每个属性创建局部变量:

function email({person, subject, body}) {
  // ...
}

如果你尝试在 TypeScript 中做同样的事情,你会得到一些令人困惑的错误:

function email({
  person: Person,
       // ~~~~~~ Binding element 'Person' implicitly has an 'any' type
  subject: string,
        // ~~~~~~ Duplicate identifier 'string'
        //        Binding element 'string' implicitly has an 'any' type
  body: string}
     // ~~~~~~ Duplicate identifier 'string'
     //        Binding element 'string' implicitly has an 'any' type
) { /* ... */ }

问题在于 Person 和 string 被解释为值上下文。你试图创建一个名为 Person 的变量和两个名为 string 的变量。相反,你应该将类型和值分开:

function email(
  {person, subject, body}: {person: Person, subject: string, body: string}
) {
  // ...
}

这样做显得更冗长,但实际上你可能会为参数定义一个命名类型,或者能够从上下文中推断出它们(Item 26)。

虽然类型空间和值空间中的类似构造起初可能会令人困惑,但一旦你掌握了其中的诀窍,它们最终会成为一种有用的记忆方法。

要记住的事情

  • 在阅读 TypeScript 表达式时,要知道如何区分你是处于类型空间还是值空间。使用 TypeScript 游乐场来建立对此的直觉。

  • 每个值都有一个类型,但类型本身没有值。像 type 和 interface 这样的构造仅存在于类型空间。

  • "foo" 可能是一个字符串字面量,也可能是一个字符串字面量类型。要注意这种区别,并理解如何区分它们。

  • typeof、this 和许多其他运算符和关键字在类型空间和值空间中有不同的含义。

  • 一些构造,如 class 或 enum,同时引入类型和值。

条目 9:优先选择类型声明而不是类型断言

TypeScript 似乎有两种方法来为变量赋值并为其指定类型:

interface Person { name: string };

const alice: Person = { name: 'Alice' };  // Type is Person
const bob = { name: 'Bob' } as Person;  // Type is Person

尽管它们达到了类似的目的,但它们实际上是非常不同的!第一个(alice: Person)向变量添加了一个类型声明,并确保该值符合该类型。后者(as Person)执行了一个类型断言。这告诉 TypeScript,尽管它推断的类型是什么,你更清楚,并且希望类型是 Person。

通常情况下,你应该更喜欢类型声明而不是类型断言。原因如下:

const alice: Person = {};
   // ~~~~~ Property 'name' is missing in type '{}'
   //       but required in type 'Person'
const bob = {} as Person;  // No error

类型声明验证该值是否符合接口。由于它不符合,TypeScript 标记了一个错误。类型断言通过告诉类型检查器,无论出于何种原因,你都比它更了解情况,来消除此错误。

如果你指定了额外的属性,会发生同样的事情:

const alice: Person = {
  name: 'Alice',
  occupation: 'TypeScript developer'
// ~~~~~~~~~ Object literal may only specify known properties
//           and 'occupation' does not exist in type 'Person'
};
const bob = {
  name: 'Bob',
  occupation: 'JavaScript developer'
} as Person;  // No error

这就是多余属性检查的工作原理(Item 11),但如果你使用断言,则不适用。

因为它们提供了额外的安全检查,除非你有特定原因使用类型断言,否则应该使用类型声明。

注意

你可能也会看到类似const bob = <Person>{}的代码。这是断言的原始语法,等效于{} as Person。现在它不太常见,因为在.tsx文件(TypeScript + React)中,<Person>被解释为开始标记。

总是不太清楚如何在箭头函数中使用声明。例如,如果你想在这段代码中使用命名为Person的接口会怎样?

const people = ['alice', 'bob', 'jan'].map(name => ({name}));
// { name: string; }[]... but we want Person[]

在这里使用类型断言是很诱人的,看起来似乎解决了问题:

const people = ['alice', 'bob', 'jan'].map(
  name => ({name} as Person)
); // Type is Person[]

但这遭受与更直接使用类型断言相同的所有问题。例如:

const people = ['alice', 'bob', 'jan'].map(name => ({} as Person));
// No error

那么在这种情况下如何使用类型声明呢?最直接的方法是在箭头函数中声明一个变量:

const people = ['alice', 'bob', 'jan'].map(name => {
  const person: Person = {name};
  return person
}); // Type is Person[]

但与原始代码相比,这引入了相当多的噪音。更简洁的方法是声明箭头函数的返回类型:

const people = ['alice', 'bob', 'jan'].map(
  (name): Person => ({name})
); // Type is Person[]

这对值执行与先前版本相同的所有检查。这里的括号很重要!(name): Person推断name的类型并指定返回类型应为Person。但(name: Person)会指定name的类型为Person并允许推断返回类型,这将产生错误。

在这种情况下,你也可以编写最终期望的类型,让 TypeScript 检查赋值的有效性:

const people: Person[] = ['alice', 'bob', 'jan'].map(
  (name): Person => ({name})
);

但在更长的函数调用链的上下文中,可能需要或希望更早地放置命名类型。这将有助于在发生错误时标记错误。

那么什么时候应该使用类型断言呢?当你确实比 TypeScript 更了解类型时,通常是从类型检查器无法获得的上下文中。例如,你可能比 TypeScript 更精确地了解 DOM 元素的类型:

document.querySelector('#myButton').addEventListener('click', e => {
  e.currentTarget // Type is EventTarget
  const button = e.currentTarget as HTMLButtonElement;
  button // Type is HTMLButtonElement
});

因为 TypeScript 无法访问页面的 DOM,它无法知道#myButton是一个按钮元素。它也不知道事件的currentTarget应该是同一个按钮。由于你有 TypeScript 不具备的信息,因此在这里使用类型断言是有道理的。有关 DOM 类型的更多信息,请参见 Item 55。

你可能也会遇到非空断言,这是如此常见,以至于它有一个特殊的语法:

const elNull = document.getElementById('foo');  // Type is HTMLElement | null
const el = document.getElementById('foo')!; // Type is HTMLElement

作为前缀使用,!是布尔否定。但作为后缀,!被解释为断言值为非空。你应该像对待其他断言一样对待!:它在编译时被擦除,所以只有在你有类型检查器缺乏的信息并且可以确保值为非空时才应该使用它。如果不能确保,应该使用条件语句检查null情况。

类型断言有其局限性:它们不允许你在任意类型之间进行转换。一般的想法是,如果 A 或 B 是另一个的子集,你可以使用类型断言在 A 和 B 之间进行转换。HTMLElement 是 HTMLElement | null 的子类型,因此这种类型断言是可以的。HTMLButtonElement 是 EventTarget 的子类型,因此也是可以的。Person 是 {} 的子类型,因此这种断言也是可以的。

但是你不能在 Person 和 HTMLElement 之间进行转换,因为它们互不是子类型:

interface Person { name: string; }
const body = document.body;
const el = body as Person;
        // ~~~~~~~~~~~~~~ Conversion of type 'HTMLElement' to type 'Person'
        //                may be a mistake because neither type sufficiently
        //                overlaps with the other. If this was intentional,
        //                convert the expression to 'unknown' first

错误提示一个逃生通道,即使用 unknown 类型(Item 42)。每种类型都是 unknown 的子类型,因此涉及 unknown 的断言总是可以的。这使你可以在任意类型之间进行转换,但至少你明确地表明你正在做一些可疑的事情!

const el = document.body as unknown as Person;  // OK

要记住的事情

  • 更倾向于使用类型声明 (: Type) 而不是类型断言 (as Type)。

  • 知道如何注释箭头函数的返回类型。

  • 当你了解 TypeScript 不知道某些类型信息时,使用类型断言和非空断言。

条目 10:避免对象包装类型(String、Number、Boolean、Symbol、BigInt)。

除了对象之外,JavaScript 还有七种原始值类型:字符串、数字、布尔值、null、undefined、符号和大整数。前五种类型从一开始就存在。符号原始值在 ES2015 中添加,大整数正在最终确定中。

原始值通过其不可变性和没有方法来与对象区分开。你可能会提到字符串确实 有 方法:

> 'primitive'.charAt(3)
"m"

事情并非看上去那么简单。这里实际上有一些令人惊讶且微妙的事情发生。虽然字符串 原始值 没有方法,但 JavaScript 也定义了 String 对象 类型具有方法。JavaScript 自由地在这些类型之间转换。当你在字符串原始值上访问 charAt 这样的方法时,JavaScript 会将其包装成一个 String 对象,调用方法,然后丢弃这个对象。

如果你猴子补丁了 String.prototype,你可以观察到这一点(Item 43):

// Don't do this!
const originalCharAt = String.prototype.charAt;
String.prototype.charAt = function(pos) {
  console.log(this, typeof this, pos);
  return originalCharAt.call(this, pos);
};
console.log('primitive'.charAt(3));

这将产生以下输出:

[String: 'primitive'] 'object' 3
m

方法中的 this 值是一个 String 对象包装器,而不是字符串原始值。你可以直接实例化一个 String 对象,有时它会像字符串原始值一样行事。但并非总是如此。例如,String 对象只等于其自身:

> "hello" === new String("hello")
false
> new String("hello") === new String("hello")
false

对象包装类型的隐式转换解释了 JavaScript 中的一个奇怪现象——如果你给原始值分配一个属性,它会消失:

> x = "hello"
> x.language = 'English'
'English'
> x.language
undefined

现在你知道解释了:x 被转换为一个 String 实例,language 属性被设置在其中,然后这个对象(带有其 language 属性)被丢弃了。

其他原始类型也有对象包装类型:Number 表示数字,Boolean 表示布尔值,Symbol 表示符号,BigInt 表示大整数(null 和 undefined 没有对象包装类型)。

这些包装器类型存在是为了方便在原始值上提供方法,并提供静态方法(例如 String.fromCharCode)。但通常没有理由直接实例化它们。

TypeScript 通过为原始类型及其对象包装器定义不同的类型来区分它们:

  • string 和 String

  • number 和 Number

  • boolean 和 Boolean

  • symbol 和 Symbol

  • bigint 和 BigInt

容易无意中输入 String(特别是如果你来自 Java 或 C#),而且它看起来似乎起作用,至少在最初是这样:

function getStringLen(foo: String) {
  return foo.length;
}

getStringLen("hello");  // OK
getStringLen(new String("hello"));  // OK

但当你试图将 String 对象传递给期望 string 的方法时,事情就会变得混乱:

function isGreeting(phrase: String) {
  return [
    'hello',
    'good day'
  ].includes(phrase);
          // ~~~~~~
          // Argument of type 'String' is not assignable to parameter
          // of type 'string'.
          // 'string' is a primitive, but 'String' is a wrapper object;
          // prefer using 'string' when possible
}

因此 string 可以赋值给 String,但 String 不能赋值给 string。令人困惑?遵循错误消息中的建议,坚持使用 string。TypeScript 附带的所有类型声明以及几乎所有其他库的类型定义都使用它。

另一种你可能会得到包装对象的方式是,如果你提供了一个带有大写字母的显式类型注解:

const s: String = "primitive";
const n: Number = 12;
const b: Boolean = true;

当然,运行时的值仍然是原始类型,而不是对象。但 TypeScript 允许这些声明是因为原始类型可以赋值给对象包装器。这些注解既具有误导性又是多余的(第 19 条)。最好坚持使用原始类型。

最后需要注意的是,可以在不使用 new 的情况下调用 BigInt 和 Symbol,因为它们创建的是原始类型:

> typeof BigInt(1234)
"bigint"
> typeof Symbol('sym')
"symbol"

这些是 BigInt 和 Symbol 的值,而不是 TypeScript 的类型(第 8 条)。调用它们会得到 bigint 和 symbol 类型的值。

记住的事情

  • 理解对象包装器类型是如何用于在原始值上提供方法的。应避免直接实例化它们或直接使用它们。

  • 避免使用 TypeScript 对象包装类型。应该使用原始类型:string 而不是 String,number 而不是 Number,boolean 而不是 Boolean,symbol 而不是 Symbol,以及 bigint 而不是 BigInt。

第 11 条:认识到过多属性检查的限制

当你将对象字面量分配给声明类型的变量时,TypeScript 会确保它具有该类型的属性而没有其他属性:

interface Room {
  numDoors: number;
  ceilingHeightFt: number;
}
const r: Room = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: 'present',
// ~~~~~~~~~~~~~~~~~~ Object literal may only specify known properties,
//                    and 'elephant' does not exist in type 'Room'
};

尽管有一个 elephant 属性看起来很奇怪,从结构化类型的角度来看这种错误并没有多大意义(第 4 条)。这个常量确实可以赋值给 Room 类型,通过引入一个中间变量可以看到这一点:

const obj = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: 'present',
};
const r: Room = obj;  // OK

obj 的类型被推断为 { numDoors: number; ceilingHeightFt: number; elephant: string }。因为这种类型包含 Room 类型中的部分值,所以可以赋值给 Room,代码通过了类型检查器的检查(参见 第 7 条)。

那么这两个示例有何不同?在第一个示例中,您触发了称为“多余属性检查”的过程,它有助于捕获结构类型系统通常会忽略的一类重要错误。但这个过程有其局限性,将其与常规的可赋值性检查混淆可能会使构建结构类型的直觉变得更加困难。将多余属性检查视为一个独立的过程,将有助于您建立对 TypeScript 类型系统更清晰的心理模型。

如 Item 1 所述,TypeScript 不仅试图标记在运行时会抛出异常的代码,还试图找到不符合您意图的代码。以下是一个后者的示例:

interface Options {
  title: string;
  darkMode?: boolean;
}
function createWindow(options: Options) {
  if (options.darkMode) {
    setDarkMode();
  }
  // ...
}
createWindow({
  title: 'Spider Solitaire',
  darkmode: true
// ~~~~~~~~~~~~~ Object literal may only specify known properties, but
//               'darkmode' does not exist in type 'Options'.
//               Did you mean to write 'darkMode'?
});

此代码在运行时不会抛出任何错误。但是,它也不太可能做出您打算的精确原因:应该是 darkMode(大写 M),而不是 darkmode。

纯粹的结构类型检查器无法发现此类错误,因为 Options 类型的域非常广泛:它包括所有具有 title 属性的 string 类型对象,以及任何其他属性,只要这些属性不包括将 darkMode 属性设置为除 true 或 false 以外的其他值。

TypeScript 类型的广泛性很容易被忽视。以下是一些可以赋给 Options 的更多值:

const o1: Options = document;  // OK
const o2: Options = new HTMLAnchorElement;  // OK

document 和 HTMLAnchorElement 实例都有 title 属性,它们是字符串,因此这些赋值是可以的。Options 确实是一个非常广泛的类型!

多余属性检查试图在不损害类型系统基本结构性质的情况下加以限制。它通过在对象字面量上专门禁止未知属性来实现这一点。(因此有时称为“严格对象字面量检查”。)document 和 new HTMLAnchorElement 都不是对象字面量,因此它们不会触发检查。但是 {title, darkmode} 对象是,因此它会:

const o: Options = { darkmode: true, title: 'Ski Free' };
                  // ~~~~~~~~ 'darkmode' does not exist in type 'Options'...

这就是为什么使用没有类型注解的中间变量可以消除错误的原因:

const intermediate = { darkmode: true, title: 'Ski Free' };
const o: Options = intermediate;  // OK

虽然第一行右侧是一个对象字面量,但第二行右侧的 intermediate 不是,因此不适用多余属性检查,错误会消失。

当您使用类型断言时,不会进行多余属性检查:

const o = { darkmode: true, title: 'Ski Free' } as Options;  // OK

这是更喜欢声明而不是断言的一个很好的理由(Item 9)。

如果不想进行此类检查,可以使用索引签名告诉 TypeScript 预期额外的属性:

interface Options {
  darkMode?: boolean;
  [otherOptions: string]: unknown;
}
const o: Options = { darkmode: true };  // OK

Item 15 讨论了何时以及何时不应该使用这种方式对数据建模。

对于“弱”类型也会进行相关检查,这些类型只具有可选属性:

interface LineChartOptions {
  logscale?: boolean;
  invertedYAxis?: boolean;
  areaChart?: boolean;
}
const opts = { logScale: true };
const o: LineChartOptions = opts;
   // ~ Type '{ logScale: boolean; }' has no properties in common
   //   with type 'LineChartOptions'

从结构的角度来看,LineChartOptions 类型应该包含几乎所有的对象。对于这种弱类型,TypeScript 添加了另一种检查,以确保值类型和声明类型至少有一个属性是共同的。就像过度属性检查一样,这在捕获拼写错误方面是有效的,并且不是严格的结构检查。但与过度属性检查不同的是,它发生在涉及弱类型的所有赋值检查期间。分解出一个中间变量并不会绕过这种检查。

过度属性检查是捕获拼写错误和其他属性名称错误的一种有效方法,否则这些错误将被结构类型系统允许。它在像包含可选字段的Options类型这样的类型中特别有用。但它的适用范围非常有限:它仅适用于对象字面量。认识到这一限制,并区分过度属性检查和普通类型检查将有助于你建立这两者的心理模型。

在这里将一个常量分解出来使一个错误消失了,但它也可能在其他上下文中引入一个错误。查看项目 26 以查看此类示例。

要记住的事情

  • 当你将对象字面量赋值给变量或将其作为参数传递给函数时,它将经过过度属性检查。

  • 过度属性检查是发现错误的一种有效方法,但它与 TypeScript 类型检查器通常进行的结构赋值检查是不同的。混淆这些过程会让你更难建立起分配模型的心理图景。

  • 注意过度属性检查的限制:引入一个中间变量将会移除这些检查。

项目 12:尽可能将类型应用于整个函数表达式

JavaScript(和 TypeScript)区分函数声明和函数表达式:

function rollDice1(sides: number): number { /* ... */ }  // Statement
const rollDice2 = function(sides: number): number { /* ... */ };  // Expression
const rollDice3 = (sides: number): number => { /* ... */ };  // Also expression

TypeScript 中函数表达式的一个优势是你可以一次性地对整个函数应用类型声明,而不是逐个指定参数和返回类型:

type DiceRollFn = (sides: number) => number;
const rollDice: DiceRollFn = sides => { /* ... */ };

如果你在编辑器中悬停在sides上,你会看到 TypeScript 知道它的类型是 number。在这样一个简单的例子中,函数类型并不提供太多价值,但这种技术确实开辟了许多可能性。

减少重复。例如,如果你想要编写几个用于对数字进行算术运算的函数,你可以像这样编写它们:

function add(a: number, b: number) { return a + b; }
function sub(a: number, b: number) { return a - b; }
function mul(a: number, b: number) { return a * b; }
function div(a: number, b: number) { return a / b; }

或者将重复的函数签名合并成一个函数类型:

type BinaryFn = (a: number, b: number) => number;
const add: BinaryFn = (a, b) => a + b;
const sub: BinaryFn = (a, b) => a - b;
const mul: BinaryFn = (a, b) => a * b;
const div: BinaryFn = (a, b) => a / b;

现在的类型注解比以前少了,并且它们被分离出来放在函数实现之外。这使得逻辑更加显而易见。你还获得了所有函数表达式的返回类型为 number 的检查。

库通常为常见函数签名提供类型。例如,ReactJS 提供了 MouseEventHandler 类型,你可以将其应用于整个函数,而不是将 MouseEvent 指定为函数参数的类型。如果你是库的作者,考虑为常见回调提供类型声明。

另一个你可能想要对函数表达式应用类型的地方是匹配其他函数的签名。例如,在 Web 浏览器中,fetch 函数用于请求某个资源:

const responseP = fetch('/quote?by=Mark+Twain');  // Type is Promise<Response>

你可以通过 response.json() 或 response.text() 从响应中提取数据:

async function getQuote() {
  const response = await fetch('/quote?by=Mark+Twain');
  const quote = await response.json();
  return quote;
}
// {
//   "quote": "If you tell the truth, you don't have to remember anything.",
//   "source": "notebook",
//   "date": "1894"
// }

(参见 Item 25 了解更多关于 Promises 和 async/await 的内容。)

这里有一个 bug:如果对 /quote 的请求失败,响应体很可能包含类似 “404 Not Found.” 的解释。这不是 JSON,所以 response.json() 将返回一个带有关于无效 JSON 的消息的拒绝 Promise。这会掩盖真正的错误,即 404 错误。

很容易忘记 fetch 返回错误响应时不会导致 Promise 被拒绝。让我们编写一个 checkedFetch 函数来为我们执行状态检查。在 lib.dom.d.ts 中,fetch 的类型声明如下:

declare function fetch(
  input: RequestInfo, init?: RequestInit
): Promise<Response>;

所以你可以这样写 checkedFetch:

async function checkedFetch(input: RequestInfo, init?: RequestInit) {
  const response = await fetch(input, init);
  if (!response.ok) {
    // Converted to a rejected Promise in an async function
    throw new Error('Request failed: ' + response.status);
  }
  return response;
}

这样写是有效的,但可以更简洁:

const checkedFetch: typeof fetch = async (input, init) => {
  const response = await fetch(input, init);
  if (!response.ok) {
    throw new Error('Request failed: ' + response.status);
  }
  return response;
}

我们已经从函数声明更改为函数表达式,并对整个函数应用了类型(typeof fetch)。这样做可以让 TypeScript 推断出 input 和 init 参数的类型。

类型注解还保证了 checkedFetch 的返回类型与 fetch 的返回类型相同。例如,如果你写成 return 而不是 throw,TypeScript 就会捕捉到这个错误:

const checkedFetch: typeof fetch = async (input, init) => {
  //  ~~~~~~~~~~~~   Type 'Promise<Response | HTTPError>'
  //                     is not assignable to type 'Promise<Response>'
  //                   Type 'Response | HTTPError' is not assignable
  //                       to type 'Response'
  const response = await fetch(input, init);
  if (!response.ok) {
    return new Error('Request failed: ' + response.status);
  }
  return response;
}

在第一个例子中的相同错误可能会导致错误,但出现在调用 checkedFetch 的代码中,而不是实现中。

除了更简洁外,将整个函数表达式的类型声明而不是其参数给出了更好的安全性。当你编写具有相同类型签名的函数或编写许多具有相同类型签名的函数时,考虑是否可以对整个函数应用类型声明,而不是重复参数和返回值的类型。

要记住的事情

  • 考虑对整个函数表达式应用类型注解,而不是对其参数和返回类型应用。

  • 如果你一直在重复相同的类型签名,可以将函数类型分离出来或查找现有的函数类型。如果你是库的作者,为常见的回调提供类型。

  • 使用 typeof fn 来匹配另一个函数的签名。

项目 13:了解类型与接口之间的区别

如果你想在 TypeScript 中定义一个命名类型,有两种选择。你可以像这里展示的那样使用一个类型:

type TState = {
  name: string;
  capital: string;
}

或者一个接口:

interface IState {
  name: string;
  capital: string;
}

(你也可以使用 class,但这是一个引入值的 JavaScript 运行时概念。参见 Item 8。)

你应该使用type还是interface?这两者之间的界限多年来变得越来越模糊,以至于在许多情况下你可以使用任何一种。你应该了解type和interface之间仍然存在的区别,并在不同情况下保持一致。但你也应该知道如何使用这两者编写相同的类型,这样你就可以轻松地阅读使用任一种类型的 TypeScript。

警告

本条目中的示例用I或T前缀类型名称,仅表示它们是如何定义的。在你的代码中不应该这样做!在 C#中,使用I前缀接口类型很常见,在 TypeScript 的早期阶段也有这种惯例。但现在被认为是一种不好的风格,因为这是不必要的,增加了很少的价值,并且在标准库中并没有一致地遵循。

首先是相似之处:State 类型几乎彼此难以区分。如果你用额外的属性定义一个IState或者TState值,你得到的错误是逐字符相同的:

const wyoming: TState = {
  name: 'Wyoming',
  capital: 'Cheyenne',
  population: 500_000
// ~~~~~~~~~~~~~~~~~~ Type ... is not assignable to type 'TState'
//                    Object literal may only specify known properties, and
//                    'population' does not exist in type 'TState'
};

你可以在interface和type中使用索引签名:

type TDict = { [key: string]: string };
interface IDict {
  [key: string]: string;
}

你也可以用以下任意一种方式定义函数类型:

type TFn = (x: number) => string;
interface IFn {
  (x: number): string;
}

const toStrT: TFn = x => '' + x;  // OK
const toStrI: IFn = x => '' + x;  // OK

对于这种简单的函数类型,类型别名看起来更自然,但如果类型还有属性,声明开始看起来更像:

type TFnWithProperties = {
  (x: number): number;
  prop: string;
}
interface IFnWithProperties {
  (x: number): number;
  prop: string;
}

你可以通过 JavaScript 中函数是可调用对象来记住这个语法。

类型别名和接口都可以是泛型的:

type TPair<T> = {
  first: T;
  second: T;
}
interface IPair<T> {
  first: T;
  second: T;
}

一个interface可以扩展一个type(在某些情况下,稍后解释),而一个type也可以扩展一个interface:

interface IStateWithPop extends TState {
  population: number;
}
type TStateWithPop = IState & { population: number; };

再次强调,这些类型是相同的。需要注意的是interface不能扩展复杂类型如联合类型。如果你想要这样做,你需要使用type和&。

一个类可以实现interface或者一个简单的类型:

class StateT implements TState {
  name: string = '';
  capital: string = '';
}
class StateI implements IState {
  name: string = '';
  capital: string = '';
}

这些是相似之处。那么区别呢?你已经看到了一个——有联合type但没有联合interface:

type AorB = 'a' | 'b';

扩展联合类型可能很有用。如果你有分开的Input和Output变量类型,并且从名称到变量的映射:

type Input = { /* ... */ };
type Output = { /* ... */ };
interface VariableMap {
  [name: string]: Input | Output;
}

那么你可能需要一个类型,将名称附加到变量上。这将是:

type NamedVariable = (Input | Output) & { name: string };

这种类型不能用interface来表示。一般来说,type比interface更加强大。它可以是一个联合类型,并且还可以利用更高级的特性如映射类型或条件类型。

它还可以更轻松地表示元组和数组类型:

type Pair = [number, number];
type StringList = string[];
type NamedNums = [string, ...number[]];

你可以使用interface来表示类似元组的东西:

interface Tuple {
  0: number;
  1: number;
  length: 2;
}
const t: Tuple = [10, 20];  // OK

但这很尴尬,并且丢失了所有元组方法,比如concat。最好使用type。关于数字索引问题的更多信息,请参见 Item 16。

interface确实具有一些type没有的能力。其中之一是interface可以增强。回到State的例子,你可以通过另一种方式添加一个population字段:

interface IState {
  name: string;
  capital: string;
}
interface IState {
  population: number;
}
const wyoming: IState = {
  name: 'Wyoming',
  capital: 'Cheyenne',
  population: 500_000
};  // OK

这被称为“声明合并”,如果你以前没有见过它,那么这相当令人惊讶。这主要用于类型声明文件(第六章),如果你正在编写这样的文件,应该遵循规范并使用interface来支持它。这个想法是你的类型声明中可能存在用户需要填充的空白,这就是他们如何做到的。

TypeScript 使用合并来获取不同版本 JavaScript 标准库的不同类型。例如,Array 接口在 lib.es5.d.ts 中定义。默认情况下,这就是你得到的。但是如果在你的 tsconfig.json 的 lib 条目中添加 ES2015,TypeScript 也会包含 lib.es2015.d.ts。这包括了另一个 Array 接口,带有像 find 这样在 ES2015 中添加的额外方法。它通过合并添加到另一个 Array 接口中。最终效果是你得到一个具有完全正确方法的单一 Array 类型。

合并支持常规代码以及声明,并且你应该意识到这种可能性。如果绝不能增加你的类型是至关重要的,那就使用type。

返回到该条目开始时的问题,你应该使用type还是interface?对于复杂类型,你别无选择:必须使用类型别名。但对于可以两种方式表示的简单对象类型呢?要回答这个问题,你应该考虑一致性和增强性。你是在一个一直使用interface的代码库中工作吗?那就坚持使用interface。它使用type吗?那就使用type。

对于没有既定风格的项目,你应该考虑增强。你正在为 API 发布类型声明吗?那么当 API 更改时,用户能够通过接口合并新字段可能会有所帮助。因此使用interface。但是对于在项目内部使用的类型,声明合并很可能是一个错误。因此更喜欢type。

需记住的事项:

  • 了解type和interface之间的差异和相似之处。

  • 知道如何使用任一语法编写相同类型。

  • 在决定在项目中使用哪种类型时,请考虑已有的风格以及增强可能带来的好处。

条目 14:使用类型操作和泛型来避免重复。

该脚本打印出几个圆柱体的尺寸、表面积和体积:

console.log('Cylinder 1 x 1 ',
  'Surface area:', 6.283185 * 1 * 1 + 6.283185 * 1 * 1,
  'Volume:', 3.14159 * 1 * 1 * 1);
console.log('Cylinder 1 x 2 ',
  'Surface area:', 6.283185 * 1 * 1 + 6.283185 * 2 * 1,
  'Volume:', 3.14159 * 1 * 2 * 1);
console.log('Cylinder 2 x 1 ',
  'Surface area:', 6.283185 * 2 * 1 + 6.283185 * 2 * 1,
  'Volume:', 3.14159 * 2 * 2 * 1);

这段代码看起来不舒服吗?应该是的。它非常重复,就像是复制和粘贴同一行,然后修改了一样。它重复了值和常量。这导致了一个错误的出现(你注意到了吗?)。更好的做法是将一些函数、一个常量和一个循环分离出来:

const surfaceArea = (r, h) => 2 * Math.PI * r * (r + h);
const volume = (r, h) => Math.PI * r * r * h;
for (const [r, h] of [[1, 1], [1, 2], [2, 1]]) {
  console.log(
    `Cylinder ${r} x ${h}`,
    `Surface area: ${surfaceArea(r, h)}`,
    `Volume: ${volume(r, h)}`);
}

这就是 DRY 原则:不要重复自己。这是你在软件开发中找到的最接近普适建议的东西。然而,虽然开发人员在代码中极力避免重复,但在类型中可能不会多加考虑:

interface Person {
  firstName: string;
  lastName: string;
}

interface PersonWithBirthDate {
  firstName: string;
  lastName: string;
  birth: Date;
}

类型中的重复问题与代码中的重复问题有许多相同的问题。如果你决定向Person添加一个可选的middleName字段会发生什么?现在Person和PersonWithBirthDate已经分歧了。

类型中重复更为常见的一个原因是,消除共享模式的机制不如代码那样熟悉:如何将共享模式因子化到类型中?通过学习如何映射类型,你可以将 DRY 的好处带到你的类型定义中。

减少重复的最简单方法是为你的类型命名。而不是像这样编写一个距离函数:

function distance(a: {x: number, y: number}, b: {x: number, y: number}) {
  return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}

为类型创建一个名称并使用它:

interface Point2D {
  x: number;
  y: number;
}
function distance(a: Point2D, b: Point2D) { /* ... */ }

这是将常量因子化而不是重复写它的类型系统等价物。重复的类型并不总是那么容易发现。有时它们可能被语法模糊化。例如,如果几个函数共享相同的类型签名:

function get(url: string, opts: Options): Promise<Response> { /* ... */ }
function post(url: string, opts: Options): Promise<Response> { /* ... */ }

然后你可以为此签名提取一个命名类型:

type HTTPFunction = (url: string, opts: Options) => Promise<Response>;
const get: HTTPFunction = (url, opts) => { /* ... */ };
const post: HTTPFunction = (url, opts) => { /* ... */ };

有关更多信息,请参阅 Item 12。

Person/PersonWithBirthDate的例子呢?你可以通过使一个接口扩展另一个来消除重复:

interface Person {
  firstName: string;
  lastName: string;
}

interface PersonWithBirthDate extends Person {
  birth: Date;
}

现在你只需要编写额外的字段。如果这两个接口共享它们字段的一个子集,那么你可以提取出一个仅包含这些共同字段的基类。继续类比代码重复的话,这类似于编写 PI 和 2*PI 而不是 3.141593 和 6.283185。

你还可以使用交集运算符(&)来扩展现有类型,尽管这种情况较少见:

type PersonWithBirthDate = Person & { birth: Date };

当你想要向联合类型添加一些额外属性时,这种技术最有用(不能extend)。有关更多信息,请参阅 Item 13。

你也可以走另一条路。如果你有一个代表整个应用程序状态的State类型,以及另一个代表部分状态的TopNavState类型呢?

interface State {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
  pageContents: string;
}
interface TopNavState {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
}

与通过扩展TopNavState来构建State不同,你希望将TopNavState定义为State字段的子集。这样你就可以保持一个单一的接口来定义整个应用程序的状态。

你可以通过索引到State中来消除属性类型的重复:

type TopNavState = {
  userId: State['userId'];
  pageTitle: State['pageTitle'];
  recentFiles: State['recentFiles'];
};

虽然这更长,但这确实是进步:在State的pageTitle类型变更将反映在TopNavState中。但仍然有重复。你可以通过映射类型做得更好:

type TopNavState = {
  [k in 'userId' | 'pageTitle' | 'recentFiles']: State[k]
};

将鼠标悬停在TopNavState上显示,这个定义实际上与前一个完全相同(见 Figure 2-10)。

efts 02in03

图 2-10 显示了在文本编辑器中扩展映射类型的详细版本。这与初始定义相同,但减少了重复。

映射类型是类型系统中类似于循环遍历数组字段的概念。这种特定模式如此普遍,以至于它被称为标准库中的Pick:

type Pick<T, K> = { [k in K]: T[k] };

(这个定义还不完全,如你将看到。)你可以这样使用它:

type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;

Pick是通用类型的一个示例。延续去除代码重复的类比,使用Pick等同于调用一个函数。Pick接受两个类型T和K,并返回第三个类型,就像函数可能接受两个值并返回第三个值一样。

标记联合也会导致另一种形式的重复。如果你只需要标签的类型呢?

interface SaveAction {
  type: 'save';
  // ...
}
interface LoadAction {
  type: 'load';
  // ...
}
type Action = SaveAction | LoadAction;
type ActionType = 'save' | 'load';  // Repeated types!

通过索引到Action联合,你可以定义ActionType而不重复自己:

type ActionType = Action['type'];  // Type is "save" | "load"

当你向Action联合添加更多类型时,ActionType将自动包含它们。这种类型与使用Pick得到的不同,后者将给你一个带有type属性的接口:

type ActionRec = Pick<Action, 'type'>;  // {type: "save" | "load"}

如果你正在定义一个可以初始化和稍后更新的类,那么更新方法的参数类型将可选包含与构造函数大部分相同的参数:

interface Options {
  width: number;
  height: number;
  color: string;
  label: string;
}
interface OptionsUpdate {
  width?: number;
  height?: number;
  color?: string;
  label?: string;
}
class UIWidget {
  constructor(init: Options) { /* ... */ }
  update(options: OptionsUpdate) { /* ... */ }
}

你可以使用映射类型和keyof从Options构建OptionsUpdate:

type OptionsUpdate = {[k in keyof Options]?: Options[k]};

keyof接受一个类型,并给你它的键的联合类型:

type OptionsKeys = keyof Options;
// Type is "width" | "height" | "color" | "label"

映射类型([k in keyof Options])会遍历这些属性,并在Options中查找相应的值类型。?使每个属性都变为可选。这种模式在标准库中以Partial的形式广泛存在:

class UIWidget {
  constructor(init: Options) { /* ... */ }
  update(options: Partial<Options>) { /* ... */ }
}

你可能还想要定义一个匹配值形状的类型:

const INIT_OPTIONS = {
  width: 640,
  height: 480,
  color: '#00FF00',
  label: 'VGA',
};
interface Options {
  width: number;
  height: number;
  color: string;
  label: string;
}

你可以使用typeof来实现:

type Options = typeof INIT_OPTIONS;

这明显地唤起了 JavaScript 运行时的typeof运算符,但它在 TypeScript 类型级别上操作,更加精确。有关typeof的更多信息,请参见 Item 8。然而,要小心从值推导类型。通常最好先定义类型,然后声明值可分配给它们。这样可以使你的类型更加明确,不受扩展的影响(见 Item 21)。

类似地,你可能希望为函数或方法的推断返回值创建一个命名类型:

function getUserInfo(userId: string) {
  // ...
  return {
    userId,
    name,
    age,
    height,
    weight,
    favoriteColor,
  };
}
// Return type inferred as { userId: string; name: string; age: number, ... }

直接做这件事需要条件类型(见 Item 50)。但正如之前所见,标准库为这种常见模式定义了通用类型。在这种情况下,ReturnType 泛型恰好能够满足你的需求:

type UserInfo = ReturnType<typeof getUserInfo>;

注意,ReturnType作用于typeof getUserInfo,即函数的类型,而不是getUserInfo,即函数的值。与typeof类似,要谨慎使用这种技术。不要混淆你的真相源。

泛型类型相当于类型的函数。函数对于逻辑的 DRY(不重复自己)是关键,因此对于类型的 DRY,泛型同样是关键。但这个类比还缺少一部分。你使用类型系统来约束你可以使用函数进行映射的值:你添加数字,而不是对象;你找到形状的面积,而不是数据库记录。那么如何约束泛型类型的参数呢?

你可以使用 extends 来做到这一点。你可以声明任何泛型参数 extends 一个类型。例如:

interface Name {
  first: string;
  last: string;
}
type DancingDuo<T extends Name> = [T, T];

const couple1: DancingDuo<Name> = [
  {first: 'Fred', last: 'Astaire'},
  {first: 'Ginger', last: 'Rogers'}
];  // OK
const couple2: DancingDuo<{first: string}> = [
                       // ~~~~~~~~~~~~~~~
                       // Property 'last' is missing in type
                       // '{ first: string; }' but required in type 'Name'
  {first: 'Sonny'},
  {first: 'Cher'}
];

{first: string} 不会扩展 Name,因此会出错。

注意

目前,TypeScript 总是要求你在声明中写出泛型参数。写 DancingDuo 而不是 DancingDuo<Name> 是不行的。如果你希望 TypeScript 推断泛型参数的类型,你可以使用一个精心编写的类型标识函数:

const dancingDuo = <T extends Name>(x: DancingDuo<T>) => x;
const couple1 = dancingDuo([
  {first: 'Fred', last: 'Astaire'},
  {first: 'Ginger', last: 'Rogers'}
]);
const couple2 = dancingDuo([
  {first: 'Bono'},
// ~~~~~~~~~~~~~~
  {first: 'Prince'}
// ~~~~~~~~~~~~~~~~
//     Property 'last' is missing in type
//     '{ first: string; }' but required in type 'Name'
]);

对于这种特别有用的变体,请参见 Item 26 中的 inferringPick。

你可以使用 extends 来完成之前对 Pick 的定义。如果你将原始版本通过类型检查器运行,会得到一个错误:

type Pick<T, K> = {
  [k in K]: T[k]
     // ~ Type 'K' is not assignable to type 'string | number | symbol'
};

K 在这种类型中是不受限制的,显然太宽泛了:它需要是可以用作索引的东西,即 string | number | symbol。但你可以更加具体——K 实际上应该是 T 的键的某个子集,即 keyof T:

type Pick<T, K extends keyof T> = {
  [k in K]: T[k]
};  // OK

将类型视为值的集合(Item 7),在这里将“extends”读作“子集”有助于理解。

当你处理越来越抽象的类型时,尽量不要忘记目标:接受有效的程序并拒绝无效的程序。在这种情况下,约束的结果是传递给 Pick 的错误键将产生一个错误:

type FirstLast = Pick<Name, 'first' | 'last'>;  // OK
type FirstMiddle = Pick<Name, 'first' | 'middle'>;
                           // ~~~~~~~~~~~~~~~~~~
                           // Type '"middle"' is not assignable
                           // to type '"first" | "last"'

在类型空间中,重复和复制/粘贴编码与值空间中一样糟糕。你用来避免在类型空间中重复的构造可能不如用于程序逻辑的构造那么熟悉,但学习它们是值得的。不要重复自己!

要记住的事情

  • DRY(不要重复自己)原则适用于类型,就像适用于逻辑一样。

  • 命名类型而不是重复它们。使用 extends 避免在接口中重复字段。

  • 建立 TypeScript 提供的工具之间的映射类型的理解。这些包括 keyof、typeof、索引和映射类型。

  • 泛型类型相当于类型的函数。使用它们在类型之间进行映射,而不是重复类型。使用 extends 约束泛型类型。

  • 熟悉标准库中定义的泛型类型,如 Pick、Partial 和 ReturnType。

Item 15:为动态数据使用索引签名

JavaScript 最好的特性之一是其方便的对象创建语法:

const rocket = {
  name: 'Falcon 9',
  variant: 'Block 5',
  thrust: '7,607 kN',
};

JavaScript 中的对象将字符串键映射到任何类型的值。通过在类型上指定索引签名,TypeScript 允许你表示这样的灵活映射:

type Rocket = {[property: string]: string};
const rocket: Rocket = {
  name: 'Falcon 9',
  variant: 'v1.0',
  thrust: '4,940 kN',
};  // OK

[property: string]: string 是索引签名。它指定了三件事:

键的名称

这纯粹是为了文档目的;它不会以任何方式被类型检查器使用。

键的类型

这需要是 string、number 或 symbol 的某种组合,但通常你只想使用 string(参见 Item 16)。

值的类型

这可以是任何内容。

虽然这样做可以进行类型检查,但它有一些缺点:

  • 它允许任何键,包括不正确的键。如果您写的是Name而不是name,它仍然是一个有效的Rocket类型。

  • 它不需要任何特定的键存在。{}也是一个有效的Rocket。

  • 它不能为不同的键有不同的类型。例如,thrust可能应该是一个number,而不是一个string。

  • TypeScript 的语言服务无法帮助您处理这样的类型。在输入name:时,没有自动完成,因为键可以是任何值。

简而言之,索引签名并不是非常精确。几乎总有更好的替代方案。在这种情况下,Rocket明显应该是一个interface:

interface Rocket {
  name: string;
  variant: string;
  thrust_kN: number;
}
const falconHeavy: Rocket = {
  name: 'Falcon Heavy',
  variant: 'v1',
  thrust_kN: 15_200
};

现在thrust_kN是一个number,TypeScript 会检查所有必需字段的存在。所有 TypeScript 提供的优秀语言服务都可用:自动完成、跳转到定义、重命名——它们都能正常工作。

你应该用索引签名做什么?典型情况是真正动态的数据。例如,这可能来自 CSV 文件,其中有一个标题行,并且希望将数据行表示为将列名映射到值的对象:

function parseCSV(input: string): {[columnName: string]: string}[] {
  const lines = input.split('\n');
  const [header, ...rows] = lines;
  return rows.map(rowStr => {
    const row: {[columnName: string]: string} = {};
    rowStr.split(',').forEach((cell, i) => {
      row[header[i]] = cell;
    });
    return row;
  });
}

在这样一个一般的设置中,没有办法预先知道列名是什么。因此,索引签名是合适的。如果parseCSV的用户在特定上下文中更了解列是什么,他们可能想使用断言来获得更具体的类型:

interface ProductRow {
  productId: string;
  name: string;
  price: string;
}

declare let csvData: string;
const products = parseCSV(csvData) as unknown as ProductRow[];

当然,不能保证在运行时列是否实际匹配您的预期。如果这是您关心的事情,可以将undefined添加到值类型中:

function safeParseCSV(
  input: string
): {[columnName: string]: string | undefined}[] {
  return parseCSV(input);
}

现在每次访问都需要检查:

const rows = parseCSV(csvData);
const prices: {[produt: string]: number} = {};
for (const row of rows) {
  prices[row.productId] = Number(row.price);
}

const safeRows = safeParseCSV(csvData);
for (const row of safeRows) {
  prices[row.productId] = Number(row.price);
      // ~~~~~~~~~~~~~ Type 'undefined' cannot be used as an index type
}

当然,这可能会使类型在使用时不太方便。请凭直觉使用。

如果你的类型具有有限的可能字段集,不要用索引签名建模。例如,如果你知道你的数据将有像 A、B、C、D 这样的键,但你不知道它们有多少,你可以用可选字段或者联合类型来建模:

interface Row1 { [column: string]: number }  // Too broad
interface Row2 { a: number; b?: number; c?: number; d?: number }  // Better
type Row3 =
    | { a: number; }
    | { a: number; b: number; }
    | { a: number; b: number; c: number;  }
    | { a: number; b: number; c: number; d: number };

最后一种形式最精确,但可能不太方便处理。

如果使用索引签名的问题是string类型太广泛,那么有几个替代方案。

其中一种是使用Record。这是一种通用类型,可以在键类型中提供更多灵活性。特别是,您可以传递string的子集:

type Vec3D = Record<'x' | 'y' | 'z', number>;
// Type Vec3D = {
//   x: number;
//   y: number;
//   z: number;
// }

另一种是使用映射类型。这使您可以为不同的键使用不同的类型:

type Vec3D = {[k in 'x' | 'y' | 'z']: number};
// Same as above
type ABC = {[k in 'a' | 'b' | 'c']: k extends 'b' ? string : number};
// Type ABC = {
//   a: number;
//   b: string;
//   c: number;
// }

要记住的事情

  • 当对象的属性在运行时无法预知时,请使用索引签名,例如,如果您从 CSV 文件加载它们。

  • 将undefined添加到索引签名的值类型,以提供更安全的访问。

  • 尽可能使用更精确的类型来替代索引签名:interface、Record或者映射类型。

条款 16:更喜欢使用数组、元组和类数组来代替数字索引签名。

JavaScript 是一个著名的古怪语言。其中一些最臭名昭著的怪癖涉及隐式类型转换:

> "0" == 0
true

但这些通常可以通过使用===和!==来避免它们的更强制性同类。

JavaScript 的对象模型也有其怪癖,了解这些更为重要,因为其中一些模型是由 TypeScript 的类型系统建模的。你已经在项目 10 中看到了一个这样的怪癖,它讨论了对象包装类型。本项目讨论另一个。

什么是对象?在 JavaScript 中,它是一组键/值对。键通常是字符串(在 ES2015 及更高版本中也可以是符号)。值可以是任何东西。

这比你在许多其他语言中找到的情况更为严格。JavaScript 没有类似于 Python 或 Java 中的“可哈希”对象的概念。如果尝试使用更复杂的对象作为键,它会通过调用其toString方法转换为字符串:

> x = {}
{}
> x[[1, 2, 3]] = 2
2
> x
{ '1,2,3': 1 }

特别是,numbers不能用作键。如果尝试使用数字作为属性名称,JavaScript 运行时将把它转换为字符串:

> { 1: 2, 3: 4}
{ '1': 2, '3': 4 }

那么数组是什么呢?它们肯定是对象:

> typeof []
'object'

然而,使用数值索引与它们一起是非常正常的:

> x = [1, 2, 3]
[ 1, 2, 3 ]
> x[0]
1

这些是否被转换成字符串?在所有最奇怪的怪癖之一中,答案是“是的”。你还可以使用字符串键访问数组的元素:

> x['1']
2

如果你使用Object.keys来列出数组的键,你会得到字符串:

> Object.keys(x)
[ '0', '1', '2' ]

TypeScript 试图通过允许数值键并区分它们与字符串来为此带来一些理智。如果你深入到Array的类型声明中(项目 6),你会在lib.es5.d.ts中找到这个:

interface Array<T> {
  // ...
  [n: number]: T;
}

这纯粹是虚构的——字符串键在运行时被接受,因为 ECMAScript 标准规定它们必须这样做——但它是一个有助于捕捉错误的有用方法:

const xs = [1, 2, 3];
const x0 = xs[0];  // OK
const x1 = xs['1'];
           // ~~~ Element implicitly has an 'any' type
           //      because index expression is not of type 'number'

function get<T>(array: T[], k: string): T {
  return array[k];
            // ~ Element implicitly has an 'any' type
            //   because index expression is not of type 'number'
}

虽然这个虚构有所帮助,但重要的是要记住它只是一个虚构。就像 TypeScript 类型系统的所有方面一样,在运行时被擦除了(项目 3)。这意味着像Object.keys这样的构造仍然返回字符串:

const keys = Object.keys(xs);  // Type is string[]
for (const key in xs) {
  key;  // Type is string
  const x = xs[key];  // Type is number
}

最后这种访问方式能够工作有些令人惊讶,因为string不能赋值给number。最好将其视为一种对这种在 JavaScript 中常见的数组迭代方式的实用让步。这并不是说这是一种循环数组的好方法。如果你不关心索引,可以使用 for-of:

for (const x of xs) {
  x;  // Type is number
}

如果你关心索引,你可以使用Array.prototype.forEach,它将其作为number提供给你:

xs.forEach((x, i) => {
  i;  // Type is number
  x;  // Type is number
});

如果你需要提前退出循环,最好使用 C 风格的for(;;)循环:

for (let i = 0; i < xs.length; i++) {
  const x = xs[i];
  if (x < 0) break;
}

如果类型不能说服你,也许性能可以:在大多数浏览器和 JavaScript 引擎中,for-in 循环比 for-of 或 C 风格 for 循环慢几个数量级。

这里的一般模式是,number索引签名意味着你放入的必须是number(除了 for-in 循环这个显著的例外),但是你得到的是一个string。

如果听起来令人困惑,那是因为确实如此!通常,使用number作为类型的索引签名而不是string没有太多理由。如果想指定将使用数字索引的东西,可能应该使用 Array 或元组类型。使用number作为索引类型可能会造成一个误解,即数字属性在 JavaScript 中是存在的,不管是对你自己还是对代码读者来说。

如果你不接受接受一个数组类型,因为它们具有许多其他属性(来自它们的原型),你可能不会使用,比如push和concat,那么很好——你在考虑结构!(如果你需要这方面的复习,请参考 Item 4。)如果你确实想接受任意长度的元组或任何类似数组的结构,TypeScript 有一个ArrayLike类型可以使用:

function checkedAccess<T>(xs: ArrayLike<T>, i: number): T {
  if (i < xs.length) {
    return xs[i];
  }
  throw new Error(`Attempt to access ${i} which is past end of array.`)
}

这只有一个length和数字索引签名。在这是你想要的罕见情况下,应该使用它。但记住键仍然是字符串!

const tupleLike: ArrayLike<string> = {
  '0': 'A',
  '1': 'B',
  length: 2,
};  // OK

要记住的事情

  • 理解数组是对象,因此它们的键是字符串,而不是数字。number作为索引签名是一种纯粹的 TypeScript 构造,旨在帮助捕捉错误。

  • 最好使用Array、元组或ArrayLike类型,而不是在索引签名中自己使用number。

项目 17:使用 readonly 避免与变异相关的错误

这里有一些代码来打印三角形数(1,1+2,1+2+3 等):

function printTriangles(n: number) {
  const nums = [];
  for (let i = 0; i < n; i++) {
    nums.push(i);
    console.log(arraySum(nums));
  }
}

这段代码看起来很简单。但运行时的实际情况是这样的:

> printTriangles(5)
0
1
2
3
4

问题在于你对arraySum做了一个假设,即它不修改nums。但这是我的实现:

function arraySum(arr: number[]) {
  let sum = 0, num;
  while ((num = arr.pop()) !== undefined) {
    sum += num;
  }
  return sum;
}

这个函数确实计算数组中数字的总和。但它还具有清空数组的副作用!TypeScript 可以接受这一点,因为 JavaScript 数组是可变的。

如果arraySum不修改数组,有些保证就会很好。这就是readonly类型修饰符的作用:

function arraySum(arr: readonly number[]) {
  let sum = 0, num;
  while ((num = arr.pop()) !== undefined) {
                 // ~~~ 'pop' does not exist on type 'readonly number[]'
    sum += num;
  }
  return sum;
}

这个错误消息值得深入研究。readonly number[]是一种类型,它与number[]在几个方面是不同的:

  • 你可以从它的元素中读取,但你不能向它们写入。

  • 你可以读取它的length,但你不能设置它(这会改变数组)。

  • 你不能调用pop或其他会改变数组的方法。

因为number[]比readonly number[]更能胜任,所以number[]是readonly number[]的子类型。(很容易搞错这一点——记住 Item 7!)所以你可以将一个可变数组赋给一个readonly数组,但反之则不行:

const a: number[] = [1, 2, 3];
const b: readonly number[] = a;
const c: number[] = b;
   // ~ Type 'readonly number[]' is 'readonly' and cannot be
   //   assigned to the mutable type 'number[]'

这是有道理的:如果你甚至不需要类型断言就能摆脱readonly修饰符,它就没什么用了。

当你声明一个参数readonly时,会发生一些事情:

  • TypeScript 检查函数体中参数是否被修改。

  • 调用者确信你的函数不会改变参数。

  • 调用者可能会向你的函数传递一个readonly数组。

在 JavaScript(和 TypeScript)中通常有一个假设,即函数不会突变它们的参数,除非显式说明。但正如我们将在本书中一次又一次地看到的(特别是条款 30 和 31),这些隐含的理解可能导致类型检查问题。最好使它们明确,既适合人类读者,也适合tsc。

arraySum的修复方法很简单:不要突变数组!

function arraySum(arr: readonly number[]) {
  let sum = 0;
  for (const num of arr) {
    sum += num;
  }
  return sum;
}

现在printTriangles正如你所期望的那样:

> printTriangles(5)
0
1
3
6
10

如果你的函数不突变其参数,那么你应该将它们声明为readonly。这几乎没有什么坏处:用户将能够使用更广泛的类型调用它们(条款 29),并且意外的突变将会被捕获。

其中一个缺点是你可能需要调用未标记其参数为readonly的函数。如果这些函数不突变其参数并且在你的控制下,那就将它们标记为readonly!readonly往往是具有感染性的:一旦你用readonly标记一个函数,你还需要标记它调用的所有函数。这是一件好事,因为它导致更清晰的合约和更好的类型安全性。但是,如果你调用另一个库中的函数,则可能无法更改其类型声明,并且可能必须使用类型断言(param as number[])。

readonly也可以用来捕获涉及局部变量的一整类突变错误。想象一下,你正在编写一个处理小说的工具。你获得一系列行,并希望将它们收集到由空白分隔的段落中:

Frankenstein; or, The Modern Prometheus
by Mary Shelley

You will rejoice to hear that no disaster has accompanied the commencement
of an enterprise which you have regarded with such evil forebodings. I arrived
here yesterday, and my first task is to assure my dear sister of my welfare and
increasing confidence in the success of my undertaking.

I am already far north of London, and as I walk in the streets of Petersburgh,
I feel a cold northern breeze play upon my cheeks, which braces my nerves and
fills me with delight.

这是一个尝试:^(1)

function parseTaggedText(lines: string[]): string[][] {
  const paragraphs: string[][] = [];
  const currPara: string[] = [];

  const addParagraph = () => {
    if (currPara.length) {
      paragraphs.push(currPara);
      currPara.length = 0;  // Clear the lines
    }
  };

  for (const line of lines) {
    if (!line) {
      addParagraph();
    } else {
      currPara.push(line);
    }
  }
  addParagraph();
  return paragraphs;
}

当你在该条款的开头示例上运行这段代码时,你会得到以下结果:

[ [], [], [] ]

好吧,这出问题了!

这段代码的问题在于别名化(条款 24)和突变的毒性组合。别名化发生在这一行上:

paragraphs.push(currPara);

而不是推送currPara的内容,这会推送数组的引用。当你向currPara推送一个新值或清除它时,这种变化也会反映在paragraphs中的条目中,因为它们指向同一个对象。

换句话说,这段代码的净效果是

paragraphs.push(currPara);
currPara.length = 0;  // Clear lines

将一个新段落推入paragraphs中,然后立即清除它。

问题在于设置currPara.length和调用currPara.push都会突变currPara数组。你可以通过声明它为readonly来禁止这种行为。这立即揭示了实现中的一些错误:

function parseTaggedText(lines: string[]): string[][] {
  const currPara: readonly string[] = [];
  const paragraphs: string[][] = [];

  const addParagraph = () => {
    if (currPara.length) {
      paragraphs.push(
        currPara
     // ~~~~~~~~ Type 'readonly string[]' is 'readonly' and
     //          cannot be assigned to the mutable type 'string[]'
      );
      currPara.length = 0;  // Clear lines
            // ~~~~~~ Cannot assign to 'length' because it is a read-only
            // property
    }
  };

  for (const line of lines) {
    if (!line) {
      addParagraph();
    } else {
      currPara.push(line);
            // ~~~~ Property 'push' does not exist on type 'readonly string[]'
    }
  }
  addParagraph();
  return paragraphs;
}

你可以通过用let声明currPara并使用非突变方法来修复两个错误:

let currPara: readonly string[] = [];
// ...
currPara = [];  // Clear lines
// ...
currPara = currPara.concat([line]);

与push不同,concat返回一个新数组,保持原始数组不变。通过从const更改为let并添加readonly声明,你已经将一种可变性换成了另一种。currPara变量现在可以自由更改它指向的数组,但这些数组本身不允许更改。

这解决了关于paragraphs的错误。您有三个选项来修复此问题。

首先,您可以复制currPara:

paragraphs.push([...currPara]);

这样修复了错误,因为虽然currPara保持readonly状态,但您可以自由地变异副本。

第二,您可以更改paragraphs(以及函数的返回类型)为一个readonly string[]数组:

const paragraphs: (readonly string[])[] = [];

(这里的分组很重要:readonly string[][]表示一个可变数组的readonly数组,而不是一个readonly数组的可变数组。)

这样做虽然有效,但似乎对parseTaggedText的用户有点粗鲁。你为什么关心函数返回后他们对段落做了什么?

第三,您可以使用断言来移除数组的readonly特性:

paragraphs.push(currPara as string[]);

因为在下一条语句中,您仍然将currPara分配给一个新数组,所以这似乎不是最具攻击性的断言。

关于readonly的一个重要警告是它是浅层的。您在之前使用readonly string[][]时看到了这一点。如果您有一个对象的readonly数组,则对象本身不是readonly:

const dates: readonly Date[] = [new Date()];
dates.push(new Date());
   // ~~~~ Property 'push' does not exist on type 'readonly Date[]'
dates[0].setFullYear(2037);  // OK

类似的考虑也适用于对象的readonly表亲,即Readonly泛型:

interface Outer {
  inner: {
    x: number;
  }
}
const o: Readonly<Outer> = { inner: { x: 0 }};
o.inner = { x: 1 };
// ~~~~ Cannot assign to 'inner' because it is a read-only property
o.inner.x = 1;  // OK

您可以创建一个类型别名,然后在编辑器中检查它,以了解发生的确切情况:

type T = Readonly<Outer>;
// Type T = {
//   readonly inner: {
//     x: number;
//   };
// }

要注意的重要事项是inner上的readonly修饰符,而不是x上的。在此写作时,没有深层readonly类型的内置支持,但可以创建一个泛型来实现此功能。正确实现这一点很棘手,因此建议使用库而不是自己编写。ts-essentials中的DeepReadonly泛型是其中一个实现。

您还可以在索引签名上写readonly。这将防止写入但允许读取:

let obj: {readonly [k: string]: number} = {};
// Or Readonly<{[k: string]: number}
obj.hi = 45;
//  ~~ Index signature in type ... only permits reading
obj = {...obj, hi: 12};  // OK
obj = {...obj, bye: 34};  // OK

这可以防止涉及对象而不是数组的别名和变异问题。

要记住的事情

  • 如果您的函数不修改其参数,则声明它们为readonly。这使其合同更清晰,并防止在其实现中意外修改。

  • 使用readonly可以防止通过变异引起的错误,并查找代码中发生变异的位置。

  • 了解const和readonly之间的区别。

  • 理解readonly是浅层的。

条款 18:使用映射类型保持值同步

假设您正在编写一个用于绘制散点图的 UI 组件。它具有控制其显示和行为的几种不同类型的属性:

interface ScatterProps {
  // The data
  xs: number[];
  ys: number[];

  // Display
  xRange: [number, number];
  yRange: [number, number];
  color: string;

  // Events
  onClick: (x: number, y: number, index: number) => void;
}

为了避免不必要的工作,您希望只在需要时重新绘制图表。更改数据或显示属性将需要重新绘制,但更改事件处理程序则不会。这种优化在 React 组件中很常见,其中事件处理程序可能在每次渲染时设置为新的箭头函数。^(2)

这是实现此优化的一种方式:

function shouldUpdate(
  oldProps: ScatterProps,
  newProps: ScatterProps
) {
  let k: keyof ScatterProps;
  for (k in oldProps) {
    if (oldProps[k] !== newProps[k]) {
      if (k !== 'onClick') return true;
    }
  }
  return false;
}

(有关此循环中keyof声明的解释,请参见条款 54。)

当你或你的同事添加新属性时会发生什么?shouldUpdate 函数将在它发生变化时重新绘制图表。你可以称之为保守或“失败关闭”方法。优点是图表始终看起来正确。缺点是可能会重新绘制得太频繁。

“失败开放”方法可能看起来像这样:

function shouldUpdate(
  oldProps: ScatterProps,
  newProps: ScatterProps
) {
  return (
    oldProps.xs !== newProps.xs ||
    oldProps.ys !== newProps.ys ||
    oldProps.xRange !== newProps.xRange ||
    oldProps.yRange !== newProps.yRange ||
    oldProps.color !== newProps.color
    // (no check for onClick)
  );
}

使用这种方法不会有不必要的重绘,但可能会丢弃一些必要的绘制。这违反了优化的“首先不要造成伤害”原则,因此不太常见。

两种方法都不是理想的。你真正想要的是在添加新属性时强制你的同事或未来的自己做出决定。你可以尝试添加一条注释:

interface ScatterProps {
  xs: number[];
  ys: number[];
  // ...
  onClick: (x: number, y: number, index: number) => void;

  // Note: if you add a property here, update shouldUpdate!
}

但你真的希望这样能够工作吗?如果类型检查器可以为你强制执行这一点会更好。

如果你正确地设置了它,它可以。关键在于使用映射类型和一个对象:

const REQUIRES_UPDATE: {[k in keyof ScatterProps]: boolean} = {
  xs: true,
  ys: true,
  xRange: true,
  yRange: true,
  color: true,
  onClick: false,
};

function shouldUpdate(
  oldProps: ScatterProps,
  newProps: ScatterProps
) {
  let k: keyof ScatterProps;
  for (k in oldProps) {
    if (oldProps[k] !== newProps[k] && REQUIRES_UPDATE[k]) {
      return true;
    }
  }
  return false;
}

[k in keyof ScatterProps] 告诉类型检查器 REQUIRES_UPDATES 应该具有与 ScatterProps 完全相同的所有属性。如果未来你向 ScatterProps 添加了新属性:

interface ScatterProps {
  // ...
  onDoubleClick: () => void;
}

然后这将在 REQUIRES_UPDATE 的定义中产生一个错误:

const REQUIRES_UPDATE: {[k in keyof ScatterProps]: boolean} = {
  //  ~~~~~~~~~~~~~~~ Property 'onDoubleClick' is missing in type
  // ...
};

这肯定会强制问题!删除或重命名属性将导致类似的错误。

在这里我们使用一个具有布尔值的对象是很重要的。如果我们使用了一个数组:

const PROPS_REQUIRING_UPDATE: (keyof ScatterProps)[] = [
  'xs',
  'ys',
  // ...
];

那么我们将被迫做出相同的“失败开放/失败关闭”选择。

如果你想让一个对象具有与另一个完全相同的属性,映射类型是理想的选择。就像这个例子中一样,你可以使用它来让 TypeScript 在你的代码上强制执行约束。

需要记住的事情

  • 使用映射类型来保持相关的值和类型同步。

  • 考虑使用映射类型来在接口中添加新属性时强制进行选择。

^(1) 在实践中,你可能只需编写 lines.join('\n').split(/\n\n+/),但请耐心等待。

^(2) React 的 useCallback 钩子是避免在每次渲染时创建新函数的另一种技术。

第三章:类型推断

对于在工业中使用的编程语言,“静态类型”和“显式类型”传统上是同义的。C、C++、Java:它们都要求你明确地写出你的类型。但学术语言从未将这两个概念混为一谈:像 ML 和 Haskell 这样的语言长期以来就有复杂的类型推断系统,并且在过去的十年中开始渗透到工业语言中。C++ 添加了 auto,Java 添加了 var。

TypeScript 广泛使用类型推断。合理使用它可以显著减少代码中所需的类型注释。要从一个 TypeScript 初学者和经验丰富的用户中区分开来,最简单的方法之一就是看类型注释的数量。一个有经验的 TypeScript 开发者会使用相对较少的注释(但用得很有效),而初学者可能会在代码中大量使用冗余的类型注释。

本章向你展示了类型推断可能出现的问题以及如何解决。阅读完本章后,你应该对 TypeScript 如何推断类型、仍需编写类型声明的时机以及在可以推断类型时编写类型声明是否明智有了深刻的理解。

Item 19: 避免用可推断的类型使你的代码杂乱无章

当你将一个代码库从 JavaScript 转换为 TypeScript 时,许多新的 TypeScript 开发者会做的第一件事就是用类型注释填充它。毕竟,TypeScript 是关于类型的!但在 TypeScript 中,许多注释都是不必要的。为所有变量声明类型是适得其反的,也被认为是不良风格。

不要写:

let x: number = 12;

相反,只需写:

let x = 12;

如果你在编辑器中将鼠标悬停在x上,你会发现它的类型被推断为number(如图 3-1 所示)。

efts 03in01

图 3-1. 一个文本编辑器显示了 x 的推断类型为 number。

显式类型注释是多余的。额外添加它只会增加噪音。如果你对类型不确定,可以在编辑器中检查它。

TypeScript 还可以推断出更复杂对象的类型。而不是:

const person: {
  name: string;
  born: {
    where: string;
    when: string;
  };
  died: {
    where: string;
    when: string;
  }
} = {
  name: 'Sojourner Truth',
  born: {
    where: 'Swartekill, NY',
    when: 'c.1797',
  },
  died: {
    where: 'Battle Creek, MI',
    when: 'Nov. 26, 1883'
  }
};

你可以简单地写:

const person = {
  name: 'Sojourner Truth',
  born: {
    where: 'Swartekill, NY',
    when: 'c.1797',
  },
  died: {
    where: 'Battle Creek, MI',
    when: 'Nov. 26, 1883'
  }
};

再次强调,这些类型完全相同。在这里除了值之外再写上类型只会增加噪音。(Item 21 进一步讨论了对象字面量的类型推断。)

对于数组来说,对象的情况也是如此。TypeScript 没有任何问题来基于其输入和操作来推断此函数的返回类型:

function square(nums: number[]) {
  return nums.map(x => x * x);
}
const squares = square([1, 2, 3, 4]); // Type is number[]

TypeScript 可能会推断出比你预期的更精确的内容。这通常是件好事。例如:

const axis1: string = 'x';  // Type is string
const axis2 = 'y';  // Type is "y"

"y"对于axis变量来说是更精确的类型。Item 21 提供了一个示例,展示了如何修复类型错误。

允许类型被推断也有助于重构。假设你有一个Product类型和一个用于记录它的函数:

interface Product {
  id: number;
  name: string;
  price: number;
}

function logProduct(product: Product) {
  const id: number = product.id;
  const name: string = product.name;
  const price: number = product.price;
  console.log(id, name, price);
}

在某个时候,你发现产品 ID 可能除了数字外还包含字母。因此,你改变了Product中id的类型。因为你在logProduct中的所有变量上都包含了显式注释,这导致了一个错误:

interface Product {
  id: string;
  name: string;
  price: number;
}

function logProduct(product: Product) {
  const id: number = product.id;
     // ~~ Type 'string' is not assignable to type 'number'
  const name: string = product.name;
  const price: number = product.price;
  console.log(id, name, price);
}

如果你在logProduct函数体中省略了所有注释,代码将通过类型检查器而不需要修改。

更好的logProduct实现会使用解构赋值(Item 58):

function logProduct(product: Product) {
  const {id, name, price} = product;
  console.log(id, name, price);
}

这个版本允许推断所有局部变量的类型。相应的带有显式类型注释的版本是重复的和混乱的:

function logProduct(product: Product) {
  const {id, name, price}: {id: string; name: string; price: number } = product;
  console.log(id, name, price);
}

在一些情况下,TypeScript 仍然需要显式的类型注释,因为它没有足够的上下文来自动确定类型。你之前见过其中之一:函数参数。

一些语言会根据参数的最终使用情况推断类型,但 TypeScript 不会。在 TypeScript 中,变量的类型通常在首次引入时确定。

理想的 TypeScript 代码包括函数/方法签名的类型注释,但不包括在其主体中创建的局部变量。这样可以将噪音降到最低,让读者专注于实现逻辑。

也有一些情况下,你可以省略函数参数的类型注释。例如,当有默认值时:

function parseNumber(str: string, base=10) {
  // ...
}

这里base的类型被推断为number,因为默认值为10。

当函数被用作带有类型声明的库的回调时,参数类型通常可以被推断。在这个使用 express HTTP 服务器库的例子中,request和response上的声明是不需要的:

// Don't do this:
app.get('/health', (request: express.Request, response: express.Response) => {
  response.send('OK');
});

// Do this:
app.get('/health', (request, response) => {
  response.send('OK');
});

Item 26 更深入地探讨了上下文在类型推断中的使用。

有一些情况下,即使类型可以被推断,你仍然可能想要指定一个类型。

其中一种情况是当你定义一个对象字面量时:

const elmo: Product = {
  name: 'Tickle Me Elmo',
  id: '048188 627152',
  price: 28.99,
};

当你在定义中指定类型时,你启用了多余属性检查(Item 11)。这可以帮助捕捉错误,特别是对于具有可选字段的类型。

你还增加了错误报告在正确位置的几率。如果省略注释,对象定义中的错误将导致类型错误在使用它的地方而不是定义它的地方报告:

const furby = {
  name: 'Furby',
  id: 630509430963,
  price: 35,
};
logProduct(furby);
        // ~~~~~ Argument .. is not assignable to parameter of type 'Product'
        //         Types of property 'id' are incompatible
        //         Type 'number' is not assignable to type 'string'

使用注释,你可以在错误发生的地方得到更简洁的错误提示:

 const furby: Product = {
   name: 'Furby',
   id: 630509430963,
// ~~ Type 'number' is not assignable to type 'string'
   price: 35,
 };
 logProduct(furby);

类似的考虑也适用于函数的返回类型。即使可以推断出类型,你可能仍然想要注释这一点,以确保实现错误不会泄漏到函数的使用中。

假设你有一个用于获取股票报价的函数:

function getQuote(ticker: string) {
  return fetch(`https://quotes.example.com/?q=${ticker}`)
      .then(response => response.json());
}

你决定添加一个缓存以避免重复的网络请求:

const cache: {[ticker: string]: number} = {};
function getQuote(ticker: string) {
  if (ticker in cache) {
    return cache[ticker];
  }
  return fetch(`https://quotes.example.com/?q=${ticker}`)
      .then(response => response.json())
      .then(quote => {
        cache[ticker] = quote;
        return quote;
      });
}

在这个实现中有一个错误:你应该真正返回 Promise.resolve(cache[ticker]) 以确保 getQuote 总是返回一个 Promise。这个错误很可能会产生一个错误... 但是在调用 getQuote 的代码中,而不是在 getQuote 本身:

getQuote('MSFT').then(considerBuying);
              // ~~~~ Property 'then' does not exist on type
              //        'number | Promise<any>'
              //      Property 'then' does not exist on type 'number'

如果你已经注释了预期的返回类型(Promise<number>),错误将会在正确的位置报告:

const cache: {[ticker: string]: number} = {};
function getQuote(ticker: string): Promise<number> {
  if (ticker in cache) {
    return cache[ticker];
        // ~~~~~~~~~~~~~ Type 'number' is not assignable to 'Promise<number>'
  }
  // ...
}

当你注释返回类型时,它可以防止实现错误表现为用户代码中的错误(请参阅 条目 25 讨论 async 函数,这是避免与 Promise 相关特定错误的有效方法)。

写出返回类型还可以帮助你更清晰地思考你的函数:在实现之前,你应该知道它的输入和输出类型是什么。虽然实现可能会有些变化,但函数的契约(其类型签名)通常不应该改变。这与测试驱动开发(TDD)的精神类似,即在实现之前编写测试来测试一个函数。首先写出完整的类型签名有助于获得你想要的函数,而不是实现使其变得方便的函数。

注释返回值的最后一个原因是,如果你想使用一个命名类型。例如,你可能选择不为这个函数写返回类型:

interface Vector2D { x: number; y: number; }
function add(a: Vector2D, b: Vector2D) {
  return { x: a.x + b.x, y: a.y + b.y };
}

TypeScript 推断返回类型为 { x: number; y: number; }。这与 Vector2D 兼容,但当用户看到 Vector2D 作为输入类型而不是输出类型时(如 图 3-2 所示),可能会感到惊讶。

efts 03in02

图 3-2. add 函数的参数有命名类型,而推断的返回值没有。

如果你给返回类型加上注释,演示就会更加直观。如果你在类型的文档中写了文档(条目 48),那么它也将与返回值关联起来。随着推断的返回类型复杂性增加,提供一个名称将变得越来越有帮助。

如果你在使用 linter,eslint 规则 no-inferrable-types(注意变体拼写)可以帮助确保所有的类型注释确实是必要的。

记住的事情

  • 当 TypeScript 可以推断相同类型时,避免写入类型注释。

  • 理想情况下,你的代码在函数/方法签名中有类型注释,但在其主体中的局部变量则没有。

  • 考虑在对象字面量和函数返回类型上使用显式注释,即使它们可以被推断。这将有助于防止实施错误在用户代码中显现。

条目 20:使用不同的变量来表示不同的类型

在 JavaScript 中,重用一个变量以保存不同类型的值用于不同的目的是没有问题的:

let id = "12-34-56";
fetchProduct(id);  // Expects a string

id = 123456;
fetchProductBySerialNumber(id);  // Expects a number

在 TypeScript 中,这会导致两个错误:

   let id = "12-34-56";
   fetchProduct(id);

   id = 123456;
// ~~ '123456' is not assignable to type 'string'.
   fetchProductBySerialNumber(id);
                           // ~~ Argument of type 'string' is not assignable to
                           //    parameter of type 'number'

在编辑器中悬停在第一个id上会给出关于正在发生的提示(参见 图 3-3)。

efts 03in03

图 3-3。id 的推断类型为字符串。

基于值 "12-34-56",TypeScript 推断出 id 的类型为 string。您不能将 number 分配给 string,因此会出错。

这使我们得出 TypeScript 中变量的一个关键见解:虽然变量的值可以改变,但其类型通常不会改变。类型可以变窄(Item 22),但这涉及到类型变得更小,而不是扩展以包括新值。这个规则有一些重要的例外情况(Item 41),但它们是例外而不是规则。

如何使用这个想法修复示例?为了使 id 的类型不改变,它必须足够广泛以包括 string 和 number。这正是联合类型 string|number 的定义:

let id: string|number = "12-34-56";
fetchProduct(id);

id = 123456;  // OK
fetchProductBySerialNumber(id);  // OK

这修复了错误。有趣的是 TypeScript 能够确定第一次调用中 id 真正是一个 string,而在第二次调用中真正是一个 number。它根据赋值缩小了联合类型。

虽然联合类型确实有效,但它可能会在以后引起更多问题。与简单类型(如 string 或 number)相比,联合类型更难处理,因为通常必须在对它们进行任何操作之前检查它们的类型。

更好的解决方案是引入一个新变量:

const id = "12-34-56";
fetchProduct(id);

const serial = 123456;  // OK
fetchProductBySerialNumber(serial);  // OK

在先前的版本中,第一个和第二个 id 在语义上并不相关。它们只是通过变量重用相关联。这对类型检查器来说很令人困惑,对人类读者也是如此。

有两个变量的版本在很多方面都更好:

  • 它解开了两个不相关的概念(ID 和序列号)。

  • 它允许您使用更具体的变量名。

  • 它改善了类型推断。不需要类型注解。

  • 它导致更简单的类型(string 和 number,而不是 string|number)。

  • 它允许您将变量声明为 const 而不是 let。这样更容易让人和类型检查器理解。

尽量避免改变类型的变量。如果可以为不同的概念使用不同的名称,将使您的代码对人类读者和类型检查器更清晰。

这与此示例中的“遮蔽”变量不同,请注意区分。

const id = "12-34-56";
fetchProduct(id);

{
  const id = 123456;  // OK
  fetchProductBySerialNumber(id);  // OK
}

尽管这两个 id 共享一个名称,但它们实际上是两个彼此无关的变量。它们具有不同的类型是可以的。尽管 TypeScript 不会对此感到困惑,但您的人类读者可能会。总的来说,最好为不同的概念使用不同的名称。许多团队选择通过 linter 规则禁止此类变量遮蔽。

此项专注于标量值,但类似的考虑也适用于对象。有关更多信息,请参阅 Item 23。

要记住的事情

  • 虽然变量的值可以改变,但其类型通常不会改变。

  • 为了避免混淆,无论是对人类读者还是对类型检查器,都应避免为不同类型的值重用变量。

第 21 条:理解类型扩展

正如第 7 条所解释的,在运行时,每个变量只有一个值。但在静态分析时,当 TypeScript 检查你的代码时,变量有一组可能的值,即它的类型。当你用常量初始化一个变量但没有提供类型时,类型检查器需要决定一个。换句话说,它需要从你指定的单一值中确定一组可能的值。在 TypeScript 中,这个过程称为扩展。理解这一点将帮助你理解错误并更有效地使用类型注解。

假设你正在编写一个用于处理向量的库。你定义了一个三维向量的类型以及一个获取其任何分量值的函数:

interface Vector3 { x: number; y: number; z: number; }
function getComponent(vector: Vector3, axis: 'x' | 'y' | 'z') {
  return vector[axis];
}

但是当你尝试使用它时,TypeScript 会标记一个错误:

let x = 'x';
let vec = {x: 10, y: 20, z: 30};
getComponent(vec, x);
               // ~ Argument of type 'string' is not assignable to
               //   parameter of type '"x" | "y" | "z"'

这段代码可以正常运行,那么为什么会有错误呢?

问题在于x的类型被推断为string,而getComponent函数期望其第二个参数有更具体的类型。这就是扩展的工作方式,这里导致了一个错误。

这个过程在某种意义上是模糊的,因为对于任何给定值,可能有许多可能的类型。例如,在这个声明中:

const mixed = ['x', 1];

mixed的类型应该是什么呢?以下是一些可能性:

  • ('x' | 1)[]

  • ['x', 1]

  • [string, number]

  • readonly [string, number]

  • (string|number)[]

  • readonly (string|number)[]

  • [any, any]

  • any[]

没有更多的上下文,TypeScript 无法知道哪一个是“正确”的。它必须猜测你的意图。(在这种情况下,它猜测为(string|number)[]。)虽然 TypeScript 很聪明,但它无法读取你的思想。它不会在 100%的情况下做出正确的猜测。结果是意外的错误,就像我们刚才看到的那个。

在初始示例中,x的类型被推断为string,因为 TypeScript 选择允许这样的代码存在:

let x = 'x';
x = 'a';
x = 'Four score and seven years ago...';

但是,如果你将其作为 JavaScript 来写也是有效的:

let x = 'x';
x = /x|y|z/;
x = ['x', 'y', 'z'];

当 TypeScript 将x的类型推断为string时,它试图在具体性和灵活性之间取得平衡。一般规则是变量的类型在声明后不应更改(第 20 条),因此string比string|RegExp、string|string[]或any更合理。

TypeScript 提供了一些方法来控制扩展的过程。其中之一是const。如果你用const声明一个变量,而不是let,它的类型会更窄。事实上,使用const修复了我们原始示例中的错误:

const x = 'x';  // type is "x"
let vec = {x: 10, y: 20, z: 30};
getComponent(vec, x);  // OK

因为x不能重新赋值,TypeScript 能够在没有风险地不经意地标记后续赋值错误的情况下推断出更窄的类型。而且因为字符串文字类型"x"可以分配给"x"|"y"|"z",所以代码通过了类型检查器。

然而,const并非万能良药。对于对象和数组,仍然存在模糊性。这里的mixed示例说明了数组的问题:TypeScript 应该推断一个元组类型吗?它应该为元素推断什么类型?类似的问题也会出现在对象上。这段代码在 JavaScript 中是可以的:

const v = {
  x: 1,
};
v.x = 3;
v.x = '3';
v.y = 4;
v.name = 'Pythagoras';

变量 v 的类型可以在特异性的光谱中的任何位置被推断出来。特定端是 {readonly x: 1}。更一般的是 {x: number}。更一般的还包括 {[key: string]: number} 或 object。对于对象来说,TypeScript 的扩展算法将每个元素视为使用 let 分配的。因此,变量 v 的类型最终变为 {x: number}。这使您可以重新赋值 v.x 为不同的数字,但不能为字符串。并且阻止您添加其他属性。(这是一种一次性构建对象的好理由,详见条目 23。)

因此,最后三个语句是错误的:

 const v = {
   x: 1,
 };
 v.x = 3;  // OK
 v.x = '3';
// ~ Type '"3"' is not assignable to type 'number'
 v.y = 4;
// ~ Property 'y' does not exist on type '{ x: number; }'
 v.name = 'Pythagoras';
// ~~~~ Property 'name' does not exist on type '{ x: number; }'

再次强调,TypeScript 正在努力在特异性和灵活性之间取得平衡。它需要推断出足够具体的类型以捕捉错误,但不能太具体以至于造成误报。它通过推断出对于像 1 这样初始化的属性的类型为 number 来实现这一点。

如果您了解更多,请覆盖 TypeScript 的默认行为的几种方法之一是提供显式的类型注解:

const v: {x: 1|3|5} = {
  x: 1,
};  // Type is { x: 1 | 3 | 5; }

另一种方法是为类型检查器提供额外的上下文(例如,将值作为函数参数传递)。关于上下文在类型推断中的作用,详见条目 26。

第三种方法是使用 const 断言。这与 let 和 const 不同,它引入值空间中的符号。这是一个纯粹的类型级别构造。查看这些变量的不同推断类型:

const v1 = {
  x: 1,
  y: 2,
};  // Type is { x: number; y: number; }

const v2 = {
  x: 1 as const,
  y: 2,
};  // Type is { x: 1; y: number; }

const v3 = {
  x: 1,
  y: 2,
} as const;  // Type is { readonly x: 1; readonly y: 2; }

当您在值后面写上 as const 时,TypeScript 将为其推断出最窄可能的类型。没有扩展。对于真正的常量,这通常是您想要的。您还可以在数组中使用 as const 来推断元组类型:

const a1 = [1, 2, 3];  // Type is number[]
const a2 = [1, 2, 3] as const;  // Type is readonly [1, 2, 3]

如果您因扩展而得到错误的错误消息,请考虑添加一些显式的类型注解或 const 断言。检查编辑器中的类型是建立对此直觉的关键(参见条目 6)。

要记住的事情

  • 了解 TypeScript 如何通过扩展推断常量的类型。

  • 熟悉您可以影响此行为的方式:const、类型注解、上下文和 as const。

条目 22:理解类型缩小

扩展的反义是缩小。这是 TypeScript 从广泛类型到较窄类型的过程。这个过程中最常见的例子可能是空值检查:

const el = document.getElementById('foo'); // Type is HTMLElement | null
if (el) {
  el // Type is HTMLElement
  el.innerHTML = 'Party Time'.blink();
} else {
  el // Type is null
  alert('No element #foo');
}

如果 el 是 null,则第一个分支中的代码将不会执行。因此,TypeScript 能够从此块中的类型联合中排除 null,从而导致一个更窄的类型,更容易处理。类型检查器通常在这类条件语句中缩小类型方面表现良好,尽管有时可能会被别名所挫败(详见条目 24)。

您还可以通过在分支中抛出或返回来缩小变量在块的其余部分的类型。例如:

const el = document.getElementById('foo'); // Type is HTMLElement | null
if (!el) throw new Error('Unable to find #foo');
el; // Now type is HTMLElement
el.innerHTML = 'Party Time'.blink();

有许多方法可以缩小类型。使用 instanceof 是有效的:

function contains(text: string, search: string|RegExp) {
  if (search instanceof RegExp) {
    search  // Type is RegExp
    return !!search.exec(text);
  }
  search  // Type is string
  return text.includes(search);
}

属性检查也是如此:

interface A { a: number }
interface B { b: number }
function pickAB(ab: A | B) {
  if ('a' in ab) {
    ab // Type is A
  } else {
    ab // Type is B
  }
  ab // Type is A | B
}

一些内置函数如 Array.isArray 能够缩小类型:

function contains(text: string, terms: string|string[]) {
  const termList = Array.isArray(terms) ? terms : [terms];
  termList // Type is string[]
  // ...
}

在条件语句中,TypeScript 通常非常擅长跟踪类型。在添加断言之前,考虑两次——它可能会捕捉到你所未见的东西!例如,这是排除 null 从联合类型中的错误方法:

const el = document.getElementById('foo'); // type is HTMLElement | null
if (typeof el === 'object') {
  el;  // Type is HTMLElement | null
}

因为在 JavaScript 中,typeof null 是 "object",所以实际上这种检查并没有排除 null!类似的意外可能来自假值原始值:

function foo(x?: number|string|null) {
  if (!x) {
    x;  // Type is string | number | null | undefined
  }
}

因为空字符串和 0 都是假值,因此在该分支中 x 仍然可能是 string 或 number。TypeScript 是正确的!

另一种帮助类型检查器缩小类型范围的常见方法是为它们显式地打上“标签”:

interface UploadEvent { type: 'upload'; filename: string; contents: string }
interface DownloadEvent { type: 'download'; filename: string; }
type AppEvent = UploadEvent | DownloadEvent;

function handleEvent(e: AppEvent) {
  switch (e.type) {
    case 'download':
      e  // Type is DownloadEvent
      break;
    case 'upload':
      e;  // Type is UploadEvent
      break;
  }
}

这种模式称为“标记联合”或“辨识联合”,在 TypeScript 中是无处不在的。

如果 TypeScript 无法推断类型,甚至可以引入自定义函数来帮助它:

function isInputElement(el: HTMLElement): el is HTMLInputElement {
  return 'value' in el;
}

function getElementContent(el: HTMLElement) {
  if (isInputElement(el)) {
    el; // Type is HTMLInputElement
    return el.value;
  }
  el; // Type is HTMLElement
  return el.textContent;
}

这被称为“用户定义的类型保护”。el is HTMLInputElement 作为返回类型告诉类型检查器,如果函数返回 true,则可以缩小参数的类型。

一些函数能够使用类型保护来跨数组或对象执行类型缩小。例如,如果在数组中进行一些查找,可能会得到一个可空类型的数组:

const jackson5 = ['Jackie', 'Tito', 'Jermaine', 'Marlon', 'Michael'];
const members = ['Janet', 'Michael'].map(
  who => jackson5.find(n => n === who)
);  // Type is (string | undefined)[]

如果使用 filter 过滤掉 undefined 值,TypeScript 将无法跟踪:

const members = ['Janet', 'Michael'].map(
  who => jackson5.find(n => n === who)
).filter(who => who !== undefined);  // Type is (string | undefined)[]

但如果使用类型保护,它可以:

function isDefined<T>(x: T | undefined): x is T {
  return x !== undefined;
}
const members = ['Janet', 'Michael'].map(
  who => jackson5.find(n => n === who)
).filter(isDefined);  // Type is string[]

如常,在编辑器中检查类型是建立对缩小工作原理直觉的关键。

理解 TypeScript 中类型缩小的原理将帮助你建立对类型推断工作的直觉,理解错误,并通常更有效地与类型检查器交互。

需要记住的事项

  • 了解 TypeScript 如何根据条件和其他类型控制流缩小类型范围。

  • 使用标记/辨识联合和用户定义的类型保护来帮助缩小过程。

条款 23:一次性创建对象

正如 Item 20 所述,虽然变量的值可能会改变,在 TypeScript 中它的类型通常不会改变。这使得一些 JavaScript 模式在 TypeScript 中更容易建模。特别是,这意味着你应该更倾向于一次性创建对象,而不是逐步创建。

这是在 JavaScript 中表示二维点对象的一种方式:

const pt = {};
pt.x = 3;
pt.y = 4;

在 TypeScript 中,这将在每次赋值时产生错误:

const pt = {};
pt.x = 3;
// ~ Property 'x' does not exist on type '{}'
pt.y = 4;
// ~ Property 'y' does not exist on type '{}'

这是因为第一行中 pt 的类型是根据其值 {} 推断出来的,并且你只能分配已知的属性。

如果定义了一个 Point 接口,你会得到相反的问题:

interface Point { x: number; y: number; }
const pt: Point = {};
   // ~~ Type '{}' is missing the following properties from type 'Point': x, y
pt.x = 3;
pt.y = 4;

解决方案是一次性定义对象:

const pt = {
  x: 3,
  y: 4,
};  // OK

如果必须逐步构建对象,可以使用类型断言(as)来消除类型检查器的警告:

const pt = {} as Point;
pt.x = 3;
pt.y = 4;  // OK

但更好的方式是一次性构建对象,并使用声明(参见 Item 9):

const pt: Point = {
  x: 3,
  y: 4,
};

如果你需要从较小的对象构建一个较大的对象,避免分步进行:

const pt = {x: 3, y: 4};
const id = {name: 'Pythagoras'};
const namedPoint = {};
Object.assign(namedPoint, pt, id);
namedPoint.name;
        // ~~~~ Property 'name' does not exist on type '{}'

你可以使用 对象扩展操作符 ... 一次性构建更大的对象:

const namedPoint = {...pt, ...id};
namedPoint.name;  // OK, type is string

你也可以使用对象扩展操作符以一种类型安全的方式逐个字段地构建对象。关键是在每次更新时使用一个新变量,以便每个变量都获得一个新类型:

const pt0 = {};
const pt1 = {...pt0, x: 3};
const pt: Point = {...pt1, y: 4};  // OK

尽管这是一种绕弯子的方式来构建这样一个简单的对象,但它可以是一种有用的技术,用于向对象添加属性并允许 TypeScript 推断出新类型。

要以类型安全的方式有条件地向对象添加属性,你可以使用带有 null 或 {} 的对象扩展:

declare let hasMiddle: boolean;
const firstLast = {first: 'Harry', last: 'Truman'};
const president = {...firstLast, ...(hasMiddle ? {middle: 'S'} : {})};

如果在编辑器中悬停鼠标在president上,你会看到它的类型被推断为一个联合类型:

const president: {
    middle: string;
    first: string;
    last: string;
} | {
    first: string;
    last: string;
}

如果你希望 middle 是一个可选字段,这可能会让你感到惊讶。例如,你无法从这种类型中读取 middle:

president.middle
       // ~~~~~~ Property 'middle' does not exist on type
       //        '{ first: string; last: string; }'

如果你在有条件地添加多个属性,联合类型确实更准确地表示可能的值集合(条款 32)。但可选字段会更容易处理。你可以用一个辅助函数来获取它:

function addOptional<T extends object, U extends object>(
  a: T, b: U | null
): T & Partial<U> {
  return {...a, ...b};
}

const president = addOptional(firstLast, hasMiddle ? {middle: 'S'} : null);
president.middle  // OK, type is string | undefined

有时候你想通过转换另一个对象或数组来构建一个对象或数组。在这种情况下,“一次性构建对象”的等价方法是使用内置的函数式构造或类似 Lodash 这样的实用库,而不是使用循环。详见 条款 27 了解更多信息。

需要记住的事情

  • 最好一次性构建对象,而不是逐步构建。使用对象扩展 ({...a, ...b}) 以一种类型安全的方式添加属性。

  • 知道如何有条件地向对象添加属性。

条款 24:在使用别名时要保持一致性

当你为一个值引入一个新名称时:

const borough = {name: 'Brooklyn', location: [40.688, -73.979]};
const loc = borough.location;

你已经创建了一个 别名。对别名上属性的更改也会在原始值上可见:

> loc[0] = 0;
> borough.location
[0, -73.979]

别名是所有语言中编译器编写者的祸根,因为它们使得控制流分析变得困难。如果你在使用别名时考虑周到,TypeScript 将能够更好地理解你的代码并帮助你找到更多真正的错误。

假设你有一个表示多边形的数据结构:

interface Coordinate {
  x: number;
  y: number;
}

interface BoundingBox {
  x: [number, number];
  y: [number, number];
}

interface Polygon {
  exterior: Coordinate[];
  holes: Coordinate[][];
  bbox?: BoundingBox;
}

多边形的几何形状由 exterior 和 holes 属性指定。bbox 属性是一种可能有可能没有的优化。你可以用它来加速点在多边形内的检查:

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  if (polygon.bbox) {
    if (pt.x < polygon.bbox.x[0] || pt.x > polygon.bbox.x[1] ||
        pt.y < polygon.bbox.y[1] || pt.y > polygon.bbox.y[1]) {
      return false;
    }
  }

  // ... more complex check
}

这段代码可以工作(并且类型检查通过),但有点重复:polygon.bbox 在三行中出现了五次!下面试图因子化一个中间变量以减少重复:

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const box = polygon.bbox;
  if (polygon.bbox) {
    if (pt.x < box.x[0] || pt.x > box.x[1] ||
        //     ~~~                ~~~  Object is possibly 'undefined'
        pt.y < box.y[1] || pt.y > box.y[1]) {
        //     ~~~                ~~~  Object is possibly 'undefined'
      return false;
    }
  }
  // ...
}

(我假设你已经启用了 strictNullChecks。)

这段代码仍然可以工作,那么为什么会有错误?通过因子化 box 变量,你创建了一个对 polygon.bbox 的别名,这阻碍了在第一个示例中悄悄工作的控制流分析。

你可以检查 box 和 polygon.bbox 的类型,看看发生了什么:

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  polygon.bbox  // Type is BoundingBox | undefined
  const box = polygon.bbox;
  box  // Type is BoundingBox | undefined
  if (polygon.bbox) {
    polygon.bbox  // Type is BoundingBox
    box  // Type is BoundingBox | undefined
  }
}

属性检查精化了polygon.bbox的类型,但未精化box,从而导致错误。这带我们来到别名的黄金法则:如果你引入了一个别名,请一致地使用它。

在属性检查中使用box修复了错误:

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const box = polygon.bbox;
  if (box) {
    if (pt.x < box.x[0] || pt.x > box.x[1] ||
        pt.y < box.y[1] || pt.y > box.y[1]) {  // OK
      return false;
    }
  }
  // ...
}

类型检查器现在很满意,但对于人类读者来说存在问题。我们为同一事物使用了两个名称:box和bbox。这是一个没有实质差别的区分(Item 36)。

对象解构语法通过更紧凑的语法奖励一致的命名。你甚至可以在数组和嵌套结构上使用它:

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const {bbox} = polygon;
  if (bbox) {
    const {x, y} = bbox;
    if (pt.x < x[0] || pt.x > x[1] ||
        pt.y < x[0] || pt.y > y[1]) {
      return false;
    }
  }
  // ...
}

还有几点需要注意:

  • 如果x和y属性是可选的而不是整个bbox属性,那么这段代码将需要更多的属性检查。我们受益于遵循 Item 31 的建议,该建议讨论了将空值推送到类型的边缘的重要性。

  • 对于bbox来说,可选属性是合适的,但对于holes来说则不适用。如果holes是可选的,那么它可能会不存在,或者是一个空数组([])。这是一个没有实质差别的区分。空数组是表示“没有孔”的良好方式。

在与类型检查器的交互中,不要忘记别名可能会引入运行时的混淆:

const {bbox} = polygon;
if (!bbox) {
  calculatePolygonBbox(polygon);  // Fills in polygon.bbox
  // Now polygon.bbox and bbox refer to different values!
}

TypeScript 的控制流分析对局部变量而言通常非常好。但对于属性,你应该保持警惕:

function fn(p: Polygon) { /* ... */ }

polygon.bbox  // Type is BoundingBox | undefined
if (polygon.bbox) {
  polygon.bbox  // Type is BoundingBox
  fn(polygon);
  polygon.bbox  // Type is still BoundingBox
}

函数调用fn(polygon)可能会导致polygon.bbox被取消设置,因此将类型回归为BoundingBox | undefined会更安全。但这样会变得很烦人:每次调用函数时都需要重复进行属性检查。因此,TypeScript 作出了务实的选择,假设函数不会使其类型精化失效。但它可能会。如果你将局部变量bbox拆分出来,而不是使用polygon.bbox,那么bbox的类型将保持准确,但它可能不再是polygon.box的相同值。

需要记住的事情

  • 别名可能会阻止 TypeScript 缩小类型范围。如果为变量创建了一个别名,请一致使用它。

  • 使用解构语法来鼓励一致的命名。

  • 注意函数调用如何使属性的类型精化失效。相信对局部变量的精化胜过对属性的精化。

项目 25:使用异步函数而不是回调来处理异步代码

经典 JavaScript 使用回调函数模拟异步行为。这导致了臭名昭著的“金字塔形式的嵌套”:

fetchURL(url1, function(response1) {
  fetchURL(url2, function(response2) {
    fetchURL(url3, function(response3) {
      // ...
      console.log(1);
    });
    console.log(2);
  });
  console.log(3);
});
console.log(4);

// Logs:
// 4
// 3
// 2
// 1

正如你从日志中看到的那样,执行顺序与代码顺序相反。这使得回调代码难以阅读。如果你想要并行运行请求或在出现错误时放弃执行,情况会更加混乱。

ES2015 引入了 Promise 的概念来打破嵌套的金字塔形式。Promise 表示将来会可用的东西(它们有时也被称为“期货”)。以下是使用 Promise 的相同代码:

const page1Promise = fetch(url1);
page1Promise.then(response1 => {
  return fetch(url2);
}).then(response2 => {
  return fetch(url3);
}).then(response3 => {
  // ...
}).catch(error => {
  // ...
});

现在嵌套更少,执行顺序更直接地匹配代码顺序。同时更容易整合错误处理和使用Promise.all这样的高阶工具。

ES2017 引入了async和await关键字,使得事情变得更简单:

async function fetchPages() {
  const response1 = await fetch(url1);
  const response2 = await fetch(url2);
  const response3 = await fetch(url3);
  // ...
}

await关键字暂停fetchPages函数的执行,直到每个 Promise 解决。在async函数中,await一个抛出异常的 Promise。这让你可以使用通常的 try/catch 机制:

async function fetchPages() {
  try {
    const response1 = await fetch(url1);
    const response2 = await fetch(url2);
    const response3 = await fetch(url3);
    // ...
  } catch (e) {
    // ...
  }
}

当你的目标是 ES5 或更早版本时,TypeScript 编译器将执行一些复杂的转换使async和await工作。换句话说,无论你的运行时是什么,使用 TypeScript 你都可以使用async/await。

有几个好的理由优先选择 Promises 或async/await而不是回调:

  • Promises 比回调更容易组合。

  • 类型更容易通过 Promises 流动而不是回调。

如果你想并行获取页面,你可以使用Promise.all组合 Promises:

async function fetchPages() {
  const [response1, response2, response3] = await Promise.all([
    fetch(url1), fetch(url2), fetch(url3)
  ]);
  // ...
}

在这种情况下,使用await与解构赋值特别好。

TypeScript 能够推断每个response变量的类型为Response。与回调一起并行请求的等效代码需要更多的机制和类型注释:

function fetchPagesCB() {
  let numDone = 0;
  const responses: string[] = [];
  const done = () => {
    const [response1, response2, response3] = responses;
    // ...
  };
  const urls = [url1, url2, url3];
  urls.forEach((url, i) => {
    fetchURL(url, r => {
      responses[i] = url;
      numDone++;
      if (numDone === urls.length) done();
    });
  });
}

将错误处理扩展到包括或作为Promise.all一样通用是有挑战的。

类型推断与Promise.race一起工作良好,它在其输入 Promise 中第一个解决时解决。你可以用这个通用方式为 Promises 添加超时:

function timeout(millis: number): Promise<never> {
  return new Promise((resolve, reject) => {
     setTimeout(() => reject('timeout'), millis);
  });
}

async function fetchWithTimeout(url: string, ms: number) {
  return Promise.race([fetch(url), timeout(ms)]);
}

fetchWithTimeout的返回类型被推断为Promise<Response>,不需要类型注释。深入研究为什么这样工作是有趣的:Promise.race的返回类型是其输入类型的联合,这种情况下是Promise<Response | never>。但与never(空集)的联合是一个无操作,因此这简化为Promise<Response>。当你使用 Promises 时,所有的 TypeScript 类型推断机制都会为你获取正确的类型。

有时候你需要使用原始的 Promises,尤其是在包装回调 API 像setTimeout的时候。但如果可以选择,通常你应该优先选择async/await,因为有两个原因:

  • 它通常产生更简洁和直接的代码。

  • 它强制async函数总是返回 Promises。

async函数总是返回一个Promise,即使它不涉及await任何东西。TypeScript 可以帮助你建立这种直觉:

// function getNumber(): Promise<number>
async function getNumber() {
  return 42;
}

你也可以创建async箭头函数:

const getNumber = async () => 42;  // Type is () => Promise<number>

原始的 Promise 等价物是:

const getNumber = () => Promise.resolve(42);  // Type is () => Promise<number>

尽管对于一个立即可用的值返回 Promise 可能看起来有些奇怪,但这实际上有助于强制执行一个重要的规则:一个函数应该要么总是同步运行,要么总是异步运行。它不应该混合两者。例如,如果你想给fetchURL函数添加缓存会怎样?这里是一个尝试:

// Don't do this!
const _cache: {[url: string]: string} = {};
function fetchWithCache(url: string, callback: (text: string) => void) {
  if (url in _cache) {
    callback(_cache[url]);
  } else {
    fetchURL(url, text => {
      _cache[url] = text;
      callback(text);
    });
  }
}

虽然这看起来像是一种优化,但函数现在对客户端使用变得极其困难:

let requestStatus: 'loading' | 'success' | 'error';
function getUser(userId: string) {
  fetchWithCache(`/user/${userId}`, profile => {
    requestStatus = 'success';
  });
  requestStatus = 'loading';
}

调用getUser后,requestStatus的值将是什么?这完全取决于是否缓存了配置文件。如果没有,requestStatus将设置为“success”。如果有,它将设置为“success”,然后再设置回“loading”。糟糕!

对于所有函数都使用async强制保持一致的行为:

const _cache: {[url: string]: string} = {};
async function fetchWithCache(url: string) {
  if (url in _cache) {
    return _cache[url];
  }
  const response = await fetch(url);
  const text = await response.text();
  _cache[url] = text;
  return text;
}

let requestStatus: 'loading' | 'success' | 'error';
async function getUser(userId: string) {
  requestStatus = 'loading';
  const profile = await fetchWithCache(`/user/${userId}`);
  requestStatus = 'success';
}

现在完全明确requestStatus将以“success”结尾。使用回调或原始 Promise 很容易意外生成半同步代码,但使用async则不同。

注意,如果你从async函数返回一个 Promise,它不会被包装在另一个 Promise 中:返回类型将是Promise<T>而不是Promise<Promise<T>>。再次强调,TypeScript 会帮助你建立对此的直觉:

// Function getJSON(url: string): Promise<any>
async function getJSON(url: string) {
  const response = await fetch(url);
  const jsonPromise = response.json();  // Type is Promise<any>
  return jsonPromise;
}

记住的事情

  • 为了更好地组合和类型流动,推荐使用 Promise 而不是回调函数。

  • 尽可能使用async和await而不是原始的 Promise。它们产生更简洁、直接的代码,并消除了许多错误。

  • 如果函数返回一个 Promise,声明它为async。

条款 26:理解上下文在类型推断中的使用方式

TypeScript 不仅基于值推断类型,还考虑值出现的上下文。这通常效果很好,但有时会带来意外。理解上下文在类型推断中的使用方式将帮助你识别并解决这些意外。

在 JavaScript 中,你可以将表达式提取为常量而不改变代码行为(只要不改变执行顺序)。换句话说,以下两个语句是等价的:

// Inline form
setLanguage('JavaScript');

// Reference form
let language = 'JavaScript';
setLanguage(language);

在 TypeScript 中,这种重构仍然有效:

function setLanguage(language: string) { /* ... */ }

setLanguage('JavaScript');  // OK

let language = 'JavaScript';
setLanguage(language);  // OK

现在假设你真心接受了条款 33 的建议,并用更精确的字符串字面量类型替换了字符串类型:

type Language = 'JavaScript' | 'TypeScript' | 'Python';
function setLanguage(language: Language) { /* ... */ }

setLanguage('JavaScript');  // OK

let language = 'JavaScript';
setLanguage(language);
         // ~~~~~~~~ Argument of type 'string' is not assignable
         //          to parameter of type 'Language'

发生了什么?使用内联形式时,TypeScript 可以从函数声明中知道参数应该是Language类型。字符串字面量'JavaScript'可以赋值给这种类型,所以没问题。但是当你提取一个变量时,TypeScript 必须在赋值时推断其类型。在这种情况下,它推断为string,这与Language类型不兼容,因此出错。

(某些语言能够根据变量的最终使用推断类型。但这也可能令人困惑。TypeScript 的创建者 Anders Hejlsberg 将其称为“远距离的不可思议行为”。总的来说,TypeScript 通常在变量首次引入时确定其类型。关于此规则的一个显著例外,请参阅条款 41。)

有两种解决这个问题的好方法。一种是使用类型声明约束language的可能值:

let language: Language = 'JavaScript';
setLanguage(language);  // OK

这也有助于在语言拼写错误时标记错误,例如'Typescript'(应为大写“S”)。

另一种解决方案是将变量声明为常量:

const language = 'JavaScript';
setLanguage(language);  // OK

使用 const,我们告诉类型检查器这个变量是不可变的。因此 TypeScript 可以推断出更精确的类型 language,即字符串字面量类型 "JavaScript"。这是可分配给 Language 的,因此代码通过类型检查。当然,如果你确实需要重新分配 language,那么你需要使用类型声明。(更多信息请参见 项目 21。)

这里的根本问题是我们已将值从其使用的上下文中分离出来。有时这样做没问题,但通常不行。本条目的其余部分将逐步介绍几种情况,其中这种上下文丢失可能会导致错误,并向你展示如何修复它们。

元组类型

除了字符串字面量类型外,元组类型也可能出现问题。假设你正在使用一个地图可视化工具,可以编程地移动地图:

// Parameter is a (latitude, longitude) pair.
function panTo(where: [number, number]) { /* ... */ }

panTo([10, 20]);  // OK

const loc = [10, 20];
panTo(loc);
//    ~~~ Argument of type 'number[]' is not assignable to
//        parameter of type '[number, number]'

和之前一样,你已将值从其上下文中分离出来。在第一个例子中 [10, 20] 可以赋值给元组类型 [number, number]。而在第二个例子中,TypeScript 推断出 loc 的类型为 number[](即一个未知长度的数字数组)。这不能赋值给元组类型,因为许多数组具有错误的元素数量。

那么如何在不使用 any 的情况下修复这个错误?你已经声明它为 const,所以这行不通。但是你仍然可以提供一个类型声明,让 TypeScript 精确地了解你的意图:

const loc: [number, number] = [10, 20];
panTo(loc);  // OK

另一种方法是提供一个“常量上下文”。这告诉 TypeScript 你打算使值在深层次上是常量的,而不是 const 给出的浅层常量:

const loc = [10, 20] as const;
panTo(loc);
   // ~~~ Type 'readonly [10, 20]' is 'readonly'
   //     and cannot be assigned to the mutable type '[number, number]'

如果你在编辑器中悬停在 loc 上,你会看到它的类型现在被推断为 readonly [10, 20],而不是 number[]。不幸的是,这太精确了!panTo 函数的类型签名没有承诺不修改其 where 参数的内容。由于 loc 参数有一个 readonly 类型,这行不通。在这里的最佳解决方案是为 panTo 函数添加一个 readonly 注解:

function panTo(where: readonly [number, number]) { /* ... */ }
const loc = [10, 20] as const;
panTo(loc);  // OK

如果类型签名不在你的控制之外,那么你需要使用一个注解。

const 上下文可以很好地解决推断中丢失上下文的问题,但它们确实有一个不幸的缺点:如果在定义中出现错误(比如你向元组添加了第三个元素),那么错误将在调用点而不是定义处标记。这可能会令人困惑,特别是如果错误发生在深度嵌套的对象中:

const loc = [10, 20, 30] as const;  // error is really here.
panTo(loc);
//    ~~~ Argument of type 'readonly [10, 20, 30]' is not assignable to
//        parameter of type 'readonly [number, number]'
//          Types of property 'length' are incompatible
//            Type '3' is not assignable to type '2'

对象

当你将常量从包含一些字符串字面量或元组的大对象中分离出来时,也会出现分离值与其使用上下文的问题。例如:

type Language = 'JavaScript' | 'TypeScript' | 'Python';
interface GovernedLanguage {
  language: Language;
  organization: string;
}

function complain(language: GovernedLanguage) { /* ... */ }

complain({ language: 'TypeScript', organization: 'Microsoft' });  // OK

const ts = {
  language: 'TypeScript',
  organization: 'Microsoft',
};
complain(ts);
//       ~~ Argument of type '{ language: string; organization: string; }'
//            is not assignable to parameter of type 'GovernedLanguage'
//          Types of property 'language' are incompatible
//            Type 'string' is not assignable to type 'Language'

在 ts 对象中,language 的类型被推断为 string。与之前一样,解决方案是添加类型声明 (const ts: GovernedLanguage = ...) 或使用常量断言 (as const)。

回调函数

当你将回调函数传递给另一个函数时,TypeScript 使用上下文来推断回调的参数类型:

function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
  fn(Math.random(), Math.random());
}

callWithRandomNumbers((a, b) => {
  a;  // Type is number
  b;  // Type is number
  console.log(a + b);
});

由于callWithRandom的类型声明,a和b的类型被推断为number。如果您将回调函数提取为常量,则会失去此上下文,并出现noImplicitAny错误:

const fn = (a, b) => {
         // ~    Parameter 'a' implicitly has an 'any' type
         //    ~ Parameter 'b' implicitly has an 'any' type
  console.log(a + b);
}
callWithRandomNumbers(fn);

在每种情况下的解决方案都是为参数添加类型注释:

const fn = (a: number, b: number) => {
  console.log(a + b);
}
callWithRandomNumbers(fn);

或者应用于整个函数表达式的类型声明(如果有的话)。请参见 Item 12。

记住的事情

  • 注意上下文在类型推断中的使用方式。

  • 如果将变量分解引入类型错误,请考虑添加类型声明。

  • 如果变量确实是常量,请使用常量断言 (as const)。但请注意,这可能导致错误在使用时而非定义时浮出水面。

Item 27: 使用函数构造和库帮助类型流动

JavaScript 从未包含像 Python、C 或 Java 中那样的标准库。多年来,许多库试图填补这一空白。jQuery 不仅提供了与 DOM 交互的帮助程序,还提供了对对象和数组进行迭代和映射的功能。Underscore 更专注于提供通用的实用函数,而 Lodash 在此基础上进行了扩展。今天,像 Ramda 这样的库继续将函数式编程的思想引入到 JavaScript 世界中。

这些库的一些功能,如map、flatMap、filter和reduce,已经融入到 JavaScript 语言本身中。尽管这些构造(以及 Lodash 提供的其他构造)在 JavaScript 中非常有帮助,通常比手动编写的循环更可取,但是当您将 TypeScript 加入到混合中时,这种优势往往更加明显。这是因为它们的类型声明确保类型在这些构造中流动。对于手动编写的循环,您需要自己负责类型。

例如,考虑解析一些 CSV 数据。您可以在纯 JavaScript 中以某种命令式风格完成它:

const csvData = "...";
const rawRows = csvData.split('\n');
const headers = rawRows[0].split(',');

const rows = rawRows.slice(1).map(rowStr => {
  const row = {};
  rowStr.split(',').forEach((val, j) => {
    row[headers[j]] = val;
  });
  return row;
});

更注重功能的 JavaScript 开发者可能更喜欢使用 reduce 来构建行对象:

const rows = rawRows.slice(1)
    .map(rowStr => rowStr.split(',').reduce(
        (row, val, i) => (row[headers[i]] = val, row),
        {}));

这个版本节省了三行(几乎 20 个非空格字符!),但根据您的感受可能更加神秘。Lodash 的 zipObject 函数可以进一步缩短它,通过“压缩”键和值数组来形成对象:

import _ from 'lodash';
const rows = rawRows.slice(1)
    .map(rowStr => _.zipObject(headers, rowStr.split(',')));

这是所有方法中最清晰的。但是,为了将第三方库添加到您的项目中是否值得增加依赖的成本?如果您没有使用捆绑器且这样做的开销很大,那么答案可能是“不”。

当您将 TypeScript 加入到混合中时,它开始更加倾向于 Lodash 解决方案。

CSV 解析器的两个原始 JS 版本在 TypeScript 中产生相同的错误:

const rowsA = rawRows.slice(1).map(rowStr => {
  const row = {};
  rowStr.split(',').forEach((val, j) => {
    row[headers[j]] = val;
 // ~~~~~~~~~~~~~~~ No index signature with a parameter of
 //                 type 'string' was found on type '{}'
  });
  return row;
});
const rowsB = rawRows.slice(1)
  .map(rowStr => rowStr.split(',').reduce(
      (row, val, i) => (row[headers[i]] = val, row),
                     // ~~~~~~~~~~~~~~~ No index signature with a parameter of
                     //                 type 'string' was found on type '{}'
      {}));

在每种情况下的解决方案是为{}提供类型注释,可以是{[column: string]: string}或Record<string, string>。

另一方面,Lodash 版本通过类型检查器而无需修改:

const rows = rawRows.slice(1)
    .map(rowStr => _.zipObject(headers, rowStr.split(',')));
    // Type is _.Dictionary<string>[]

Dictionary 是 Lodash 的一个类型别名。Dictionary<string> 等同于 {[key: string]: string} 或 Record<string, string>。这里重要的是,rows 的类型完全正确,无需类型注释。

随着数据处理变得更加复杂,这些优势变得更加显著。例如,假设您有所有 NBA 球队阵容的列表:

interface BasketballPlayer {
  name: string;
  team: string;
  salary: number;
}
declare const rosters: {[team: string]: BasketballPlayer[]};

要使用循环构建一个扁平化的列表,您可以使用 concat 和一个数组。这段代码可以正常运行,但无法通过类型检查:

let allPlayers = [];
 // ~~~~~~~~~~ Variable 'allPlayers' implicitly has type 'any[]'
 //            in some locations where its type cannot be determined
for (const players of Object.values(rosters)) {
  allPlayers = allPlayers.concat(players);
            // ~~~~~~~~~~ Variable 'allPlayers' implicitly has an 'any[]' type
}

要修复错误,您需要为 allPlayers 添加类型注释:

let allPlayers: BasketballPlayer[] = [];
for (const players of Object.values(rosters)) {
  allPlayers = allPlayers.concat(players);  // OK
}

但更好的解决方案是使用 Array.prototype.flat:

const allPlayers = Object.values(rosters).flat();
// OK, type is BasketballPlayer[]

flat 方法可以将多维数组扁平化。其类型签名大致为 T[][] => T[]。这个版本最简洁,不需要类型注释。作为额外的奖励,您可以使用 const 而不是 let 来防止将来对 allPlayers 变量的突变。

假设您想从 allPlayers 开始,按薪水排序制作每支球队中薪水最高的球员列表。

这是一个没有使用 Lodash 的解决方案。在不使用函数式构造的情况下,它需要一个类型注释:

const teamToPlayers: {[team: string]: BasketballPlayer[]} = {};
for (const player of allPlayers) {
  const {team} = player;
  teamToPlayers[team] = teamToPlayers[team] || [];
  teamToPlayers[team].push(player);
}

for (const players of Object.values(teamToPlayers)) {
  players.sort((a, b) => b.salary - a.salary);
}

const bestPaid = Object.values(teamToPlayers).map(players => players[0]);
bestPaid.sort((playerA, playerB) => playerB.salary - playerA.salary);
console.log(bestPaid);

这里是输出结果:

[
  { team: 'GSW', salary: 37457154, name: 'Stephen Curry' },
  { team: 'HOU', salary: 35654150, name: 'Chris Paul' },
  { team: 'LAL', salary: 35654150, name: 'LeBron James' },
  { team: 'OKC', salary: 35654150, name: 'Russell Westbrook' },
  { team: 'DET', salary: 32088932, name: 'Blake Griffin' },
  ...
]

这是使用 Lodash 的等效方法:

const bestPaid = _(allPlayers)
  .groupBy(player => player.team)
  .mapValues(players => _.maxBy(players, p => p.salary)!)
  .values()
  .sortBy(p => -p.salary)
  .value()  // Type is BasketballPlayer[]

除了长度减半外,这段代码更清晰,并且仅需要一个非空断言(类型检查器不知道传递给 _.maxBy 的 players 数组是非空的)。它利用了“链”,这是 Lodash 和 Underscore 中的一个概念,让您按照更自然的顺序编写一系列操作。而不是写成:

_.a(_.b(_.c(v)))

您写:

_(v).a().b().c().value()

_(v) “包装”值,.value() “解包”值。

您可以检查链中的每个函数调用以查看包装值的类型。它始终是正确的。

甚至在 Lodash 中一些更奇特的快捷方式也可以在 TypeScript 中准确地建模。例如,为什么要使用 _.map 而不是内置的 Array.prototype.map?一个原因是,您可以传递属性的名称而不是回调函数。这些调用都会产生相同的结果:

const namesA = allPlayers.map(player => player.name)  // Type is string[]
const namesB = _.map(allPlayers, player => player.name)  // Type is string[]
const namesC = _.map(allPlayers, 'name');  // Type is string[]

TypeScript 的类型系统之所以能够准确地模拟这样的构造,这是其复杂性的证明,但它自然地由字符串文字类型和索引类型的组合而来(参见 Item 14)。如果您习惯于 C++ 或 Java,这种类型推断可能会感觉非常神奇!

const salaries = _.map(allPlayers, 'salary');  // Type is number[]
const teams = _.map(allPlayers, 'team');  // Type is string[]
const mix = _.map(allPlayers, Math.random() < 0.5 ? 'name' : 'salary');
  // Type is (string | number)[]

类型在内置的函数式构造和像 Lodash 这样的库中如此流畅并不是巧合。通过避免突变并从每次调用中返回新值,它们能够生成新的类型(参见 Item 20)。在很大程度上,TypeScript 的发展驱动力是试图准确地模拟 JavaScript 库在实际应用中的行为。利用所有这些工作并加以利用!

要记住的事情

  • 使用内置的功能性构造和像 Lodash 这样的实用库中的构造,而不是手工编写的构造,可以改善类型流动,增加可读性,并减少显式类型注解的需要。

第四章:类型设计

给我看你的流程图,隐藏你的表格,我将继续感到困惑。给我看你的表格,我通常就不需要你的流程图了;它们会很明显。

弗雷德·布鲁克斯,《人月神话》

弗雷德·布鲁克斯的引用语言已经过时,但情感仍然如此:如果你看不到代码操作的数据或数据类型,代码就很难理解。这就是类型系统的一个巨大优势之一:通过编写类型,你使得它们对代码读者可见。这使得你的代码易于理解。

其他章节涵盖了 TypeScript 类型的基础知识:如何使用它们、推断它们以及编写声明。本章讨论了类型本身的设计。本章的示例都是以 TypeScript 为基础编写的,但大部分思想都具有更广泛的适用性。

如果你能良好地设计你的类型,你的流程图也应该是显而易见的。

第 28 条:更喜欢总是代表有效状态的类型

如果你设计得好,你的代码写起来应该很直观。但如果你的类型设计不好,再聪明或是再多的文档也救不了你。你的代码将会令人困惑且容易出错。

有效类型设计的关键是制定只能表示有效状态的类型。本条目通过几个例子详细介绍了这些问题,并向你展示如何修复它们。

假设你正在构建一个网页应用程序,它允许你选择页面,加载该页面的内容,然后显示它。你可能会这样写状态:

interface State {
  pageText: string;
  isLoading: boolean;
  error?: string;
}

当你编写代码来渲染页面时,你需要考虑所有这些字段:

function renderPage(state: State) {
  if (state.error) {
    return `Error! Unable to load ${currentPage}: ${state.error}`;
  } else if (state.isLoading) {
    return `Loading ${currentPage}...`;
  }
  return `<h1>${currentPage}</h1>\n${state.pageText}`;
}

不过这样做对吗?如果isLoading和error都被设置了呢?那意味着什么?是显示加载消息还是错误消息更好呢?很难说!没有足够的信息可用。

或者,如果你正在编写一个changePage函数呢?以下是一种尝试:

async function changePage(state: State, newPage: string) {
  state.isLoading = true;
  try {
    const response = await fetch(getUrlForPage(newPage));
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
    }
    const text = await response.text();
    state.isLoading = false;
    state.pageText = text;
  } catch (e) {
    state.error = '' + e;
  }
}

这有许多问题!以下是一些例子:

  • 在错误情况下,我们忘记将state.isLoading设置为false。

  • 我们没有清除state.error,所以如果先前的请求失败了,你会继续看到那个错误消息而不是加载消息。

  • 如果用户在页面加载时再次更改页面,谁知道会发生什么。他们可能会看到一个新页面然后是一个错误,或者第一个页面但不是第二个,这取决于响应返回的顺序。

问题在于状态包含了太少的信息(哪个请求失败了?哪个正在加载?)和太多的信息:State类型允许同时设置isLoading和error,即使这代表了一个无效的状态。这使得render()和changePage()都难以实现良好。

这里有一种更好的方式来表示应用程序的状态:

interface RequestPending {
  state: 'pending';
}
interface RequestError {
  state: 'error';
  error: string;
}
interface RequestSuccess {
  state: 'ok';
  pageText: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;

interface State {
  currentPage: string;
  requests: {[page: string]: RequestState};
}

这使用了一个标记联合(也称为“辨识联合”)来明确地模拟网络请求可能处于的不同状态。这个状态的版本要长三到四倍,但它有一个巨大的优势,即不允许无效状态。当前页面和你发出的每一个请求的状态都被显式地建模了。因此,renderPage和changePage函数很容易实现:

function renderPage(state: State) {
  const {currentPage} = state;
  const requestState = state.requests[currentPage];
  switch (requestState.state) {
    case 'pending':
      return `Loading ${currentPage}...`;
    case 'error':
      return `Error! Unable to load ${currentPage}: ${requestState.error}`;
    case 'ok':
      return `<h1>${currentPage}</h1>\n${requestState.pageText}`;
  }
}

async function changePage(state: State, newPage: string) {
  state.requests[newPage] = {state: 'pending'};
  state.currentPage = newPage;
  try {
    const response = await fetch(getUrlForPage(newPage));
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
    }
    const pageText = await response.text();
    state.requests[newPage] = {state: 'ok', pageText};
  } catch (e) {
    state.requests[newPage] = {state: 'error', error: '' + e};
  }
}

第一个实现中的歧义完全消失了:当前页面是明确的,每个请求都处于完全确定的状态中。如果用户在发出请求后更改页面,这也不是问题。旧的请求仍然完成,但不会影响 UI。

举个简单但更加严峻的例子,考虑一下 Air France 447 航班的命运,这是一架空客 A330 飞机,于 2009 年 6 月 1 日在大西洋上消失。这架空客是一种电传飞行飞机,意味着飞行员的控制输入先经过计算机系统,然后再影响飞机的物理控制面。在事故发生后,人们对依赖计算机做出生死决策的智慧提出了许多质疑。两年后,当黑匣子记录器被找到时,它们揭示了导致事故的许多因素。但其中一个关键因素是糟糕的状态设计。

空客 A330 的驾驶舱有一个独立的控制系统,用于飞行员和副驾驶员。“侧置杆”控制进场角。向后拉会使飞机上升,而向前推会使其俯冲。空客 A330 使用了称为“双输入”模式的系统,这让两个侧置杆可以独立移动。下面是在 TypeScript 中模拟其状态的方式:

interface CockpitControls {
  /** Angle of the left side stick in degrees, 0 = neutral, + = forward */
  leftSideStick: number;
  /** Angle of the right side stick in degrees, 0 = neutral, + = forward */
  rightSideStick: number;
}

假设你被提供了这个数据结构,并被要求编写一个getStickSetting函数来计算当前的杆位设置。你会如何做?

一种方法是假设坐在左侧的飞行员控制着:

function getStickSetting(controls: CockpitControls) {
  return controls.leftSideStick;
}

但如果副驾驶员已经掌控了呢?也许你应该使用远离零点的那个控制杆:

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  }
  return leftSideStick;
}

但是这种实现存在一个问题:我们只能确保在右侧设置为中性时才返回左侧的设置。所以你应该检查一下:

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  } else if (rightSideStick === 0) {
    return leftSideStick;
  }
  // ???
}

如果它们两个都非零,你该怎么办?希望它们大致相同,那么你可以简单地对它们求平均:

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  } else if (rightSideStick === 0) {
    return leftSideStick;
  }
  if (Math.abs(leftSideStick - rightSideStick) < 5) {
    return (leftSideStick + rightSideStick) / 2;
  }
  // ???
}

但如果它们不是呢?你能抛出错误吗?实际上不能:副翼必须设置某个角度!

在 Air France 447 航班上,副驾驶员在飞机进入风暴时默默地向后拉他的侧置杆。飞机升高了,但最终失去了速度并进入了失速状态,这种状态下飞机速度过慢,无法有效产生升力。它开始下降。

为了摆脱失速,飞行员们接受过训练,向前推动控制杆使飞机俯冲并重新获得速度。这正是飞行员所做的。但副驾驶员仍在默默地向后拉他的侧置杆。而空客的功能看起来是这样的:

function getStickSetting(controls: CockpitControls) {
  return (controls.leftSideStick + controls.rightSideStick) / 2;
}

即使飞行员全力向前推杆,效果平均为零。他不知道飞机为何不俯冲。等到副驾驶透露他所做的时候,飞机已经失去太多高度无法恢复,并坠入海中,机上 228 人全部遇难。

所有这些的要点是,在给定输入情况下没有好方法实现 getStickSetting!这个函数已经被设置为失败。在大多数飞机上,这两套控制装置是机械连接的。如果副驾驶拉后,飞行员的控制装置也会拉后。这些控制装置的状态很容易表达:

interface CockpitControls {
  /** Angle of the stick in degrees, 0 = neutral, + = forward */
  stickAngle: number;
}

现在,正如本章开头的 Fred Brooks 的引言中所言,我们的流程图显而易见。您根本不需要 getStickSetting 函数。

在设计类型时,请仔细考虑包含哪些值和排除哪些值。如果只允许表示有效状态的值,编写代码将更容易,TypeScript 也会更轻松地检查它。这是一个非常普遍的原则,本章中的几个其他条目将涵盖它的具体表现形式。

要记住的事情

  • 表示有效和无效状态的类型可能会导致混乱和容易出错的代码。

  • 更倾向于仅表示有效状态的类型。即使它们更长或更难表达,最终它们会为您节省时间和痛苦!

项目 29:在接受的事物上要宽容,在生产的事物上要严格。

这个想法被称为健壮性原则或Postel's Law,以 TCP 的编写者 Jon Postel 的名字命名:

TCP 实现应遵循一个总体原则:在所做之事上要保守,从他人那里接受的东西要宽容。

函数的契约也适用类似的规则。函数在接受输入时可以很宽泛,但在产生输出时通常应更为具体。

例如,3D 映射 API 可能会提供一种定位摄像机和计算边界框视口的方法:

declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;

viewportForBounds 的结果可以直接传递给 setCamera 来定位摄像机非常方便。

让我们来看看这些类型的定义:

interface CameraOptions {
  center?: LngLat;
  zoom?: number;
  bearing?: number;
  pitch?: number;
}
type LngLat =
  { lng: number; lat: number; } |
  { lon: number; lat: number; } |
  [number, number];

CameraOptions 中的字段都是可选的,因为您可能只想设置中心或缩放而不改变方位或俯仰。LngLat 类型也使得 setCamera 在接受参数时宽容:您可以传递 {lng, lat} 对象,{lon, lat} 对象,或者 [lng, lat] 对象,只要您有信心顺序正确即可。这些调整使得函数易于调用。

viewportForBounds 函数接受另一种“宽容”类型:

type LngLatBounds =
  {northeast: LngLat, southwest: LngLat} |
  [LngLat, LngLat] |
  [number, number, number, number];

您可以使用命名的角落、一对经纬度,或者如果您确信顺序正确,还可以使用四元组指定边界。由于 LngLat 已经适应了三种形式,因此 LngLatBounds 至少有 19 种可能的形式。确实宽容!

现在让我们编写一个函数,调整视口以适应 GeoJSON Feature,并将新的视口存储在 URL 中(有关calculateBoundingBox的定义,请参见 Item 31):

function focusOnFeature(f: Feature) {
  const bounds = calculateBoundingBox(f);
  const camera = viewportForBounds(bounds);
  setCamera(camera);
  const {center: {lat, lng}, zoom} = camera;
               // ~~~      Property 'lat' does not exist on type ...
               //      ~~~ Property 'lng' does not exist on type ...
  zoom;  // Type is number | undefined
  window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

糟糕!只有zoom属性存在,但其类型被推断为number|undefined,这也是个问题。问题在于viewportForBounds的类型声明表明它不仅在接受的内容上很宽松,而且在生成的内容上也是如此。使用camera结果的唯一类型安全方式是为联合类型的每个组件引入代码分支(Item 22)。

具有大量可选属性和联合类型的返回类型使得viewportForBounds难以使用。它的宽泛参数类型很方便,但其宽泛的返回类型则不是。更便捷的 API 应该在其生成的内容上严格控制。

一种方法是为坐标定义一个规范格式。按照 JavaScript 的约定区分“Array”和“Array-like”(Item 16),你可以区分LngLat和LngLatLike。你还可以区分完全定义的Camera类型和setCamera接受的部分版本:

interface LngLat { lng: number; lat: number; };
type LngLatLike = LngLat | { lon: number; lat: number; } | [number, number];

interface Camera {
  center: LngLat;
  zoom: number;
  bearing: number;
  pitch: number;
}
interface CameraOptions extends Omit<Partial<Camera>, 'center'> {
  center?: LngLatLike;
}
type LngLatBounds =
  {northeast: LngLatLike, southwest: LngLatLike} |
  [LngLatLike, LngLatLike] |
  [number, number, number, number];

declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): Camera;

松散的CameraOptions类型适应了严格的Camera类型(Item 14)。

在setCamera中使用Partial<Camera>作为参数类型在这里行不通,因为你确实希望允许center属性为LngLatLike对象。而且你不能写"CameraOptions extends Partial<Camera>",因为LngLatLike是LngLat的超集,而不是子集(Item 7)。如果这看起来太复杂,你也可以明确地写出类型,尽管会有一些重复:

interface CameraOptions {
  center?: LngLatLike;
  zoom?: number;
  bearing?: number;
  pitch?: number;
}

无论哪种情况,使用这些新的类型声明,focusOnFeature函数都能通过类型检查器:

function focusOnFeature(f: Feature) {
  const bounds = calculateBoundingBox(f);
  const camera = viewportForBounds(bounds);
  setCamera(camera);
  const {center: {lat, lng}, zoom} = camera;  // OK
  zoom;  // Type is number
  window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

这次zoom的类型是number,而不是number|undefined。viewportForBounds函数现在使用起来简单多了。如果还有其他生成边界框的函数,你还需要引入一个规范形式,并区分LngLatBounds和LngLatBoundsLike之间的区别。

允许 19 种可能形式的边界框是一个好的设计吗?也许不是。但如果你正在为执行此操作的库编写类型声明,你需要模拟其行为。只是不要有 19 种返回类型!

记住的事情

  • 输入类型往往比输出类型更广泛。在参数类型中,可选属性和联合类型比返回类型更常见。

  • 要在参数和返回类型之间重复使用类型,请引入一个规范形式(用于返回类型)和一个较松散的形式(用于参数)。

Item 30:不要在文档中重复类型信息

这段代码有什么问题?

/**
 * Returns a string with the foreground color.
 * Takes zero or one arguments. With no arguments, returns the
 * standard foreground color. With one argument, returns the foreground color
 * for a particular page.
 */
function getForegroundColor(page?: string) {
  return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0};
}

代码和注释不一致!在没有更多上下文的情况下很难说哪个是正确的,但显然有些问题。正如我的一位教授曾经说过的:“当你的代码和你的注释不一致时,它们都是错误的!”

让我们假设代码表示了期望的行为。这个注释有几个问题:

  • 它说这个函数返回一个string类型的颜色,但实际上返回的是一个{r, g, b}对象。

  • 它解释说这个函数接受零个或一个参数,这从类型签名中已经很清楚了。

  • 它显得很啰嗦:注释比函数声明和实现还要长!

TypeScript 的类型注解系统设计得紧凑、描述性和可读性强。其开发人员是具有数十年经验的语言专家。这几乎肯定是比你的散文更好地表达函数输入和输出类型的方法!

因为你的类型注解是由 TypeScript 编译器检查的,它们永远不会与实现不同步。也许getForegroundColor曾经返回一个字符串,但后来改成返回一个对象。做出更改的人可能忘记更新长注释。

如果不强制保持同步,什么都不会保持同步。通过类型注解,TypeScript 的类型检查器就是这种强制!如果你将类型信息放在注解中而不是文档中,你就大大增加了代码在演变过程中保持正确性的信心。

更好的注释可能是这样的:

/** Get the foreground color for the application or a specific page. */
function getForegroundColor(page?: string): Color {
  // ...
}

如果要描述特定的参数,请使用@param JSDoc 注释。有关更多信息,请参见 Item 48。

关于缺乏突变的评论也值得怀疑。不要只是说你不修改一个参数:

/** Does not modify nums */
function sort(nums: number[]) { /* ... */ }

相反,将其声明为readonly(Item 17),并让 TypeScript 强制执行契约:

function sort(nums: readonly number[]) { /* ... */ }

对于变量名也是如此。避免在变量名中放置类型信息:而不是将一个变量命名为ageNum,命名为age并确保它确实是一个number。

有一个例外是具有单位的数字。如果不清楚单位是什么,您可能希望在变量或属性名中包含它们。例如,timeMs比time更清晰,temperatureC比temperature更清晰。Item 37 描述了“品牌”,这提供了一种更类型安全的方法来建模单位。

要记住的事情

  • 避免在注释和变量名中重复类型信息。在最好的情况下,它是类型声明的重复,而在最坏的情况下,它将导致冲突的信息。

  • 如果变量名不清楚其单位(例如timeMs或temperatureC),考虑在变量名中包含单位。

条目 31:将空值推送到类型的边缘

当您第一次打开strictNullChecks时,似乎您必须在整个代码中添加大量检查null和undefined值的 if 语句。这通常是因为空值和非空值之间的关系是隐含的:当变量 A 为非 null 时,您知道变量 B 也是非 null,反之亦然。这些隐含的关系对您代码的人类读者和类型检查器来说都很令人困惑。

当数值完全为 null 或完全为非 null 时,处理起来更加容易,而不是混合。您可以通过将空值推到结构的边缘来建模这一点。

假设您想要计算一组数字的最小值和最大值。我们称之为“extent”。这是一个尝试:

function extent(nums: number[]) {
  let min, max;
  for (const num of nums) {
    if (!min) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);
    }
  }
  return [min, max];
}

代码类型检查通过(不使用strictNullChecks),并且推断的返回类型为number[],这似乎没问题。但它存在一个 bug 和一个设计缺陷:

  • 如果最小值或最大值为零,则可能会被覆盖。例如,extent([0, 1, 2])将返回[1, 2]而不是[0, 2]。

  • 如果nums数组为空,则函数将返回[undefined, undefined]。这种带有几个undefined的对象对客户端来说很难处理,正是这种类型的问题所在。从源代码阅读中我们知道,min和max要么都是undefined,要么都不是,但这种信息并没有在类型系统中表示出来。

打开strictNullChecks选项会让这些问题更加明显:

function extent(nums: number[]) {
  let min, max;
  for (const num of nums) {
    if (!min) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);
                  // ~~~ Argument of type 'number | undefined' is not
                  //     assignable to parameter of type 'number'
    }
  }
  return [min, max];
}

现在extent的返回类型被推断为(number | undefined)[],这使得设计缺陷更加明显。无论您在何处调用extent,这很可能表现为类型错误:

const [min, max] = extent([0, 1, 2]);
const span = max - min;
          // ~~~   ~~~ Object is possibly 'undefined'

extent实现中的错误是因为您排除了min的undefined值,但没有排除max的undefined值。这两者是一起初始化的,但这些信息并未在类型系统中呈现出来。您可以通过添加对max的检查来解决这个问题,但这将是对 bug 的双重下注。

更好的解决方案是将最小值和最大值放在同一个对象中,并使该对象完全为null或完全为非null:

function extent(nums: number[]) {
  let result: [number, number] | null = null;
  for (const num of nums) {
    if (!result) {
      result = [num, num];
    } else {
      result = [Math.min(num, result[0]), Math.max(num, result[1])];
    }
  }
  return result;
}

现在返回类型是[number, number] | null,这对客户端来说更容易处理。最小值和最大值可以使用非 null 断言来检索:

const [min, max] = extent([0, 1, 2])!;
const span = max - min;  // OK

或者单一检查:

const range = extent([0, 1, 2]);
if (range) {
  const [min, max] = range;
  const span = max - min;  // OK
}

通过使用单个对象来跟踪范围,我们改进了设计,帮助 TypeScript 理解了空值之间的关系,并修复了 bug:现在if (!result)检查已经没有问题了。

null 和非 null 值的混合也可能导致类中的问题。例如,假设您有一个同时表示用户及其在论坛上的帖子的类:

class UserPosts {
  user: UserInfo | null;
  posts: Post[] | null;

  constructor() {
    this.user = null;
    this.posts = null;
  }

  async init(userId: string) {
    return Promise.all([
      async () => this.user = await fetchUser(userId),
      async () => this.posts = await fetchPostsForUser(userId)
    ]);
  }

  getUserName() {
    // ...?
  }
}

当两个网络请求加载时,user和posts属性将为null。在任何时候,它们可能都是null,一个可能是null,或者它们都可能是非null。有四种可能性。这种复杂性会渗入类的每个方法中。这种设计几乎肯定会导致混乱,大量的null检查和错误。

更好的设计应该等到类使用的所有数据都可用时:

class UserPosts {
  user: UserInfo;
  posts: Post[];

  constructor(user: UserInfo, posts: Post[]) {
    this.user = user;
    this.posts = posts;
  }

  static async init(userId: string): Promise<UserPosts> {
    const [user, posts] = await Promise.all([
      fetchUser(userId),
      fetchPostsForUser(userId)
    ]);
    return new UserPosts(user, posts);
  }

  getUserName() {
    return this.user.name;
  }
}

现在UserPosts类已完全非null,编写正确方法变得轻而易举。当然,如果您需要在数据部分加载时执行操作,则需要处理多个null和非null状态。

(不要试图用 Promise 替换可为空的属性。这往往会导致更加混乱的代码,并迫使所有方法都变成异步。Promise 可以澄清加载数据的代码,但对于使用该数据的类则有相反的效果。)

记住的事情

  • 避免设计中一个值的null或非null与另一个值的null或非null有隐式关联。

  • 将null值推向 API 的边缘,通过使较大的对象要么为null要么完全为非null,可以使代码对人类读者和类型检查器更清晰。

  • 考虑创建一个完全非null的类,并在所有值可用时进行构造。

  • 虽然strictNullChecks可能会标记代码中的许多问题,但它对于显示函数在空值方面的行为是不可或缺的。

项目 32:优先使用接口的联合而不是联合的接口

如果您创建一个属性为联合类型的接口,则应该考虑该类型是否作为更精确接口的联合更合理。

假设您正在构建一个矢量绘图程序,并希望为具有特定几何类型的图层定义一个接口:

interface Layer {
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

layout字段控制形状的绘制方式和位置(圆角?直线?),而paint字段控制样式(线条是否蓝色?粗细?虚线?)。

是否有意义创建一个其layout为LineLayout但paint属性为FillPaint的图层?可能不太合适。允许这种可能性会增加库的使用错误率,并使接口难以使用。

更好的建模方式是为每种类型的图层创建单独的接口:

interface FillLayer {
  layout: FillLayout;
  paint: FillPaint;
}
interface LineLayer {
  layout: LineLayout;
  paint: LinePaint;
}
interface PointLayer {
  layout: PointLayout;
  paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;

通过这种方式定义Layer,您已排除了混合layout和paint属性的可能性。这是遵循项目 28 建议的一个例子,即优先使用仅表示有效状态的类型。

此模式的最常见示例是“标签联合”(或“区分联合”)。在这种情况下,属性之一是字符串文字类型的联合:

interface Layer {
  type: 'fill' | 'line' | 'point';
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

与之前一样,是否有意义拥有type: 'fill',但接着是LineLayout和PointPaint?当然不。将Layer转换为接口联合以排除这种可能性:

interface FillLayer {
  type: 'fill';
  layout: FillLayout;
  paint: FillPaint;
}
interface LineLayer {
  type: 'line';
  layout: LineLayout;
  paint: LinePaint;
}
interface PointLayer {
  type: 'paint';
  layout: PointLayout;
  paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;

type属性是“标签”,可用于确定您在运行时正在处理哪种类型的Layer。TypeScript 还能够根据标签来缩小Layer的类型:

function drawLayer(layer: Layer) {
  if (layer.type === 'fill') {
    const {paint} = layer;  // Type is FillPaint
    const {layout} = layer;  // Type is FillLayout
  } else if (layer.type === 'line') {
    const {paint} = layer;  // Type is LinePaint
    const {layout} = layer;  // Type is LineLayout
  } else {
    const {paint} = layer;  // Type is PointPaint
    const {layout} = layer;  // Type is PointLayout
  }
}

通过正确地建模这种类型中属性之间的关系,您可以帮助 TypeScript 检查您代码的正确性。涉及初始Layer定义的相同代码使用类型断言会显得混乱。

因为它们与 TypeScript 的类型检查器配合得很好,标记联合在 TypeScript 代码中随处可见。当你能够使用标记联合来表示 TypeScript 中的数据类型时,通常是一个很好的想法。如果你将可选字段看作其类型与undefined的联合,则它们也符合这种模式。考虑这种类型:

interface Person {
  name: string;
  // These will either both be present or not be present
  placeOfBirth?: string;
  dateOfBirth?: Date;
}

具有类型信息的注释是可能存在问题的强烈迹象(Item 30)。placeOfBirth和dateOfBirth字段之间存在关系,但您并没有告诉 TypeScript。

一种更好的建模方法是将这两个属性移到一个单独的对象中。这类似于将null值移到边缘位置(Item 31):

interface Person {
  name: string;
  birth?: {
    place: string;
    date: Date;
  }
}

现在 TypeScript 会抱怨具有地点但没有出生日期的值:

const alanT: Person = {
  name: 'Alan Turing',
  birth: {
// ~~~~ Property 'date' is missing in type
//      '{ place: string; }' but required in type
//      '{ place: string; date: Date; }'
    place: 'London'
  }
}

此外,仅需要对Person对象进行一次检查的函数:

function eulogize(p: Person) {
  console.log(p.name);
  const {birth} = p;
  if (birth) {
    console.log(`was born on ${birth.date} in ${birth.place}.`);
  }
}

如果类型的结构不在你的控制之外(例如,它来自 API),那么你仍然可以使用现熟悉的接口联合来建模这些字段之间的关系:

interface Name {
  name: string;
}

interface PersonWithBirth extends Name {
  placeOfBirth: string;
  dateOfBirth: Date;
}

type Person = Name | PersonWithBirth;

现在您可以获得与嵌套对象相同的一些好处:

function eulogize(p: Person) {
  if ('placeOfBirth' in p) {
    p // Type is PersonWithBirth
    const {dateOfBirth} = p  // OK, type is Date
  }
}

在这两种情况下,类型定义使属性之间的关系更加清晰。

要记住的事情

  • 具有多个属性的联合类型接口通常是一个错误,因为它们模糊了这些属性之间的关系。

  • 接口的联合更加精确,并且可以被 TypeScript 理解。

  • 考虑为你的结构添加一个“标签”以便于 TypeScript 的控制流分析。因为标记联合在 TypeScript 代码中得到了很好的支持,它们随处可见。

Item 33: 更倾向于比字符串类型更精确的替代方案

string类型的领域很大:"x"和"y"属于其中,但完整的Moby Dick文本也属于其中(它以"Call me Ishmael…"开头,约 120 万个字符长)。当您声明一个string类型的变量时,您应该询问是否更适合使用一个更窄的类型。

假设您正在构建一个音乐收藏,并希望为专辑定义一个类型。以下是一种尝试:

interface Album {
  artist: string;
  title: string;
  releaseDate: string;  // YYYY-MM-DD
  recordingType: string;  // E.g., "live" or "studio"
}

string类型的普遍性以及注释中的类型信息(见 Item 30)表明,这个interface可能并不完全正确。以下是可能出错的情况:

const kindOfBlue: Album = {
  artist: 'Miles Davis',
  title: 'Kind of Blue',
  releaseDate: 'August 17th, 1959',  // Oops!
  recordingType: 'Studio',  // Oops!
};  // OK

releaseDate 字段格式不正确(根据注释),并且 "Studio" 首字母大写,应为小写。但这些值确实都是字符串,因此这个对象可以分配给 Album,类型检查器不会抱怨。

这些广义的 string 类型也可能掩盖了对有效 Album 对象的错误。例如:

function recordRelease(title: string, date: string) { /* ... */ }
recordRelease(kindOfBlue.releaseDate, kindOfBlue.title);  // OK, should be error

调用 recordRelease 时参数顺序颠倒了,但两者都是字符串,因此类型检查器不会抱怨。由于 string 类型的普遍存在,这样的代码有时被称为“字符串类型化”。

您能否将类型变得更窄以防止此类问题?尽管完整的 Moby Dick 文本可能成为一个冗长的艺术家名称或专辑标题,但至少是可能的。因此,对于这些字段,使用 string 是合适的。对于 releaseDate 字段,最好只使用 Date 对象,避免格式化问题。最后,对于 recordingType 字段,可以定义一个仅包含两个值的联合类型(您也可以使用 enum,但我通常建议避免这样做;参见 Item 53):

type RecordingType = 'studio' | 'live';

interface Album {
  artist: string;
  title: string;
  releaseDate: Date;
  recordingType: RecordingType;
}

通过这些更改,TypeScript 能够进行更彻底的错误检查:

const kindOfBlue: Album = {
  artist: 'Miles Davis',
  title: 'Kind of Blue',
  releaseDate: new Date('1959-08-17'),
  recordingType: 'Studio'
// ~~~~~~~~~~~~ Type '"Studio"' is not assignable to type 'RecordingType'
};

这种方法的优势不仅限于更严格的检查。首先,显式定义类型确保其含义在传递过程中不会丢失。例如,如果您只想查找某一录音类型的专辑,可能会定义如下函数:

function getAlbumsOfType(recordingType: string): Album[] {
  // ...
}

调用此函数的人如何知道 recordingType 应该是什么?它只是一个 string。解释其值为 "studio" 或 "live" 的评论隐藏在 Album 的定义中,用户可能不会注意到这一点。

其次,显式定义类型允许您为其附加文档(参见 Item 48):

/** What type of environment was this recording made in?  */
type RecordingType = 'live' | 'studio';

将 getAlbumsOfType 修改为接受 RecordingType 后,调用者可以点击并查看文档(见 Figure 4-1)。

efts 04in01

图 4-1. 使用命名类型而不是字符串使得可以将文档附加到编辑器中显示的类型。

另一个常见的 string 误用是在函数参数中。假设您想编写一个函数,从数组中提取单个字段的所有值。Underscore 库将此称为“pluck”:

function pluck(records, key) {
  return record.map(record => record[key]);
}

您如何为此添加类型?以下是一个初始尝试:

function pluck(record: any[], key: string): any[] {
  return record.map(r => r[key]);
}

这种类型检查是可以的,但并不完美。any 类型存在问题,特别是对于返回值(参见 Item 38)。改进类型签名的第一步是引入泛型类型参数:

function pluck<T>(record: T[], key: string): any[] {
  return record.map(r => r[key]);
                      // ~~~~~~ Element implicitly has an 'any' type
                      //        because type '{}' has no index signature
}

TypeScript 现在抱怨 key 的 string 类型过于广泛。它是正确的:如果传入一个 Album 数组,那么 key 只有四个有效值(“artist,” “title,” “releaseDate,” 和 “recordingType”),而不是广泛的字符串集合。这正是 keyof Album 类型的用途:

type K = keyof Album;
// Type is "artist" | "title" | "releaseDate" | "recordingType"

因此修复方法是将 string 替换为 keyof T:

function pluck<T>(record: T[], key: keyof T) {
  return record.map(r => r[key]);
}

这将通过类型检查器。我们还让 TypeScript 推断返回类型。它的表现如何?如果在编辑器中将鼠标悬停在pluck上,推断的类型是:

function pluck<T>(record: T[], key: keyof T): T[keyof T][]

T[keyof T]是T中任何可能值的类型。如果您将单个字符串作为key传入,这太广泛了。例如:

const releaseDates = pluck(albums, 'releaseDate'); // Type is (string | Date)[]

类型应为Date[],而不是(string | Date)[]。虽然keyof T比string窄得多,但仍然太广泛了。要进一步缩小范围,我们需要引入第二个泛型参数,它是keyof T的子集(可能是单个值):

function pluck<T, K extends keyof T>(record: T[], key: K): T[K][] {
  return record.map(r => r[key]);
}

(有关此上下文中extends的更多信息,请参见 Item 14。)

类型签名现在完全正确。我们可以通过几种不同的方式调用pluck来进行检查:

pluck(albums, 'releaseDate'); // Type is Date[]
pluck(albums, 'artist');  // Type is string[]
pluck(albums, 'recordingType');  // Type is RecordingType[]
pluck(albums, 'recordingDate');
           // ~~~~~~~~~~~~~~~ Argument of type '"recordingDate"' is not
           //                 assignable to parameter of type ...

语言服务甚至能够为Album的键提供自动完成(如 Figure 4-2 所示)。

efts 04in02

图 4-2。在您的编辑器中使用Album的参数类型为keyof而不是字符串可以获得更好的自动完成。

string与any存在一些相同的问题:当不适当使用时,它允许无效值并隐藏类型之间的关系。这会妨碍类型检查器,并且可能隐藏真正的错误。TypeScript 定义string子集的能力是将类型安全引入 JavaScript 代码的强大方式。使用更精确的类型将捕获错误并提高代码的可读性。

记住这些事项

  • 避免“字符串类型”代码。在不是每个string都可能的情况下,请优先选择更合适的类型。

  • 如果更准确地描述变量的域,更倾向于使用字符串字面类型的联合而不是string。这将提供更严格的类型检查并改善开发体验。

  • 对于预期为对象属性的函数参数,最好使用keyof T而不是string。

项目 34:更喜欢不完整类型而不是不准确类型

在编写类型声明时,您将不可避免地遇到可以以更精确或不太精确的方式建模行为的情况。类型的精确性通常是件好事,因为它将帮助用户捕获错误并利用 TypeScript 提供的工具。但是在增加类型声明的精确性时要小心:容易犯错,不正确的类型可能比没有类型更糟糕。

假设您正在为 GeoJSON 编写类型声明,这是我们在 Item 31 中已经见过的格式。GeoJSON 几何可以是几种类型之一,每种类型都有不同形状的坐标数组:

interface Point {
  type: 'Point';
  coordinates: number[];
}
interface LineString {
  type: 'LineString';
  coordinates: number[][];
}
interface Polygon {
  type: 'Polygon';
  coordinates: number[][][];
}
type Geometry = Point | LineString | Polygon;  // Also several others

这样做没问题,但是对于坐标来说,number[]有点不够精确。实际上这些是纬度和经度,因此也许元组类型会更好:

type GeoPosition = [number, number];
interface Point {
  type: 'Point';
  coordinates: GeoPosition;
}
// Etc.

你发布了更精确的类型到世界上,并等待赞美之声的涌现。不幸的是,有用户抱怨说你的新类型搞乱了一切。即使你以前只用过纬度和经度,GeoJSON 中的位置可以有第三个元素,即高度,甚至可能更多。为了使类型声明更精确,你走得太远了,使类型变得不准确!为了继续使用你的类型声明,用户将不得不引入类型断言或完全使用as any静默类型检查。

举个例子,考虑尝试为 JSON 中定义的类似 Lisp 的语言编写类型声明:

12
"red"
["+", 1, 2]  // 3
["/", 20, 2]  // 10
["case", [">", 20, 10], "red", "blue"]  // "red"
["rgb", 255, 0, 127]  // "#FF007F"

Mapbox 库使用类似的系统来确定在许多设备上地图要素的外观。你可以尝试使用各种精度来定义这个:

  1. 允许任何内容。

  2. 允许字符串、数字和数组。

  3. 允许字符串、数字和以已知函数名称开头的数组。

  4. 确保每个函数都得到正确数量的参数。

  5. 确保每个函数都得到正确类型的参数。

前两个选项很简单:

type Expression1 = any;
type Expression2 = number | string | any[];

此外,你应该引入一组表达式的测试集,包括有效和无效表达式。随着类型的精确度提高,这将有助于防止退化(见 Item 52):

const tests: Expression2[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression2'
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],  // Too many values
  ["**", 2, 31],  // Should be an error: no "**" function
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]  // Too many values
];

要提高精度,你可以将字符串字面类型的联合作为元组的第一个元素:

type FnName = '+' | '-' | '*' | '/' | '>' | '<' | 'case' | 'rgb';
type CallExpression = [FnName, ...any[]];
type Expression3 = number | string | CallExpression;

const tests: Expression3[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression3'
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],
  ["**", 2, 31],
// ~~~~~~~~~~~ Type '"**"' is not assignable to type 'FnName'
  ["rgb", 255, 128, 64]
];

出现了一个新的捕获错误,没有退化。相当不错!

如果你想确保每个函数都得到正确数量的参数怎么办?这会变得更复杂,因为现在类型需要递归到所有函数调用中。截至 TypeScript 3.6,为了使其工作,你需要引入至少一个interface。由于interface不能是联合类型,你将不得不使用interface编写调用表达式。这有点尴尬,因为固定长度数组最容易表示为元组类型。但你可以做到:

type Expression4 = number | string | CallExpression;

type CallExpression = MathCall | CaseCall | RGBCall;

interface MathCall {
  0: '+' | '-' | '/' | '*' | '>' | '<';
  1: Expression4;
  2: Expression4;
  length: 3;
}

interface CaseCall {
  0: 'case';
  1: Expression4;
  2: Expression4;
  3: Expression4;
  length: 4 | 6 | 8 | 10 | 12 | 14 | 16 // etc.
}

interface RGBCall {
  0: 'rgb';
  1: Expression4;
  2: Expression4;
  3: Expression4;
  length: 4;
}

const tests: Expression4[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression4'
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//  Type '["case", [">", ...], ...]' is not assignable to type 'string'
  ["**", 2, 31],
// ~~~~~~~~~~~~ Type '["**", number, number]' is not assignable to type 'string
  ["rgb", 255, 128, 64],
  ["rgb", 255, 128, 64, 73]
// ~~~~~~~~~~~~~~~~~~~~~~~~ Type '["rgb", number, number, number, number]'
//                          is not assignable to type 'string'
];

现在所有无效表达式都会产生错误。有趣的是,你可以使用 TypeScript 的interface表达“偶数长度的数组”。但这些错误消息并不是很好,而且关于**的错误自上次键入以来已经恶化了很多。

这是否比之前不太精确的类型有所改进?对于一些不正确的用法能够得到错误提示确实是一种胜利,但这些错误会使得这种类型更难处理。语言服务与 TypeScript 的开发体验一样重要(见 Item 6),因此建议查看由类型声明导致的错误消息,并尝试在应该起作用的情况下使用自动补全。如果你的新类型声明更精确但破坏了自动补全,那么它们将使 TypeScript 的开发体验变得不那么愉快。

此类型声明的复杂性还增加了错误蔓延的可能性。例如,Expression4 要求所有数学运算符都需要两个参数,但 Mapbox 表达规范说 + 和 * 可以接受更多参数。此外,- 可以接受单个参数,在这种情况下它会对其输入取反。Expression4 错误地标记了所有这些情况:

 const okExpressions: Expression4[] = [
   ['-', 12],
// ~~~~~~~~~ Type '["-", number]' is not assignable to type 'string'
   ['+', 1, 2, 3],
// ~~~~~~~~~~~~~~ Type '["+", number, ...]' is not assignable to type 'string'
   ['*', 2, 3, 4],
// ~~~~~~~~~~~~~~ Type '["*", number, ...]' is not assignable to type 'string'
 ];

再次强调,在试图变得更精确时,我们超前并变得不准确了。这些不准确之处可以纠正,但您需要扩展测试集以确保没有漏掉其他问题。复杂的代码通常需要更多的测试,类型也是如此。

在您优化类型时,思考“不可信峡谷”的隐喻可能会有所帮助。优化非常不精确的类型(如 any)通常是有帮助的。但是随着您的类型变得更精确,对其准确性的期望也会增加。您将开始依赖这些类型,因此不准确性将导致更大的问题。

要记住的事情

  • 避免类型安全的不可信峡谷:错误的类型通常比没有类型更糟糕。

  • 如果无法准确建模类型,请不要使用不准确的模型!使用 any 或 unknown 来承认这些空白。

  • 注意错误消息和自动完成,随着类型越来越精确。这不仅关乎正确性:开发者体验也很重要。

第 35 条:从 API 和规范生成类型,而不是从数据生成

本章的其他内容讨论了设计良好类型的许多好处,并展示了如果不这样做可能会出现的问题。一个设计良好的类型使得 TypeScript 使用起来非常愉快,而一个设计不佳的类型可能会让人感到痛苦。但是这确实对类型设计施加了相当大的压力。如果您不必自己这样做,那不是挺好的吗?

您的一些类型可能来自于程序外部:文件格式、API 或规范。在这些情况下,您可以通过生成类型而不是编写类型来避免。如果这样做,关键在于从规范生成类型,而不是从示例数据生成。当您从规范生成类型时,TypeScript 将帮助确保您没有遗漏任何情况。当您从数据生成类型时,您只考虑到您看到的示例。您可能会忽略可能导致程序出错的重要边缘情况。

在 Item 31 中,我们编写了一个函数来计算 GeoJSON Feature 的边界框。以下是它的样子:

function calculateBoundingBox(f: GeoJSONFeature): BoundingBox | null {
  let box: BoundingBox | null = null;

  const helper = (coords: any[]) => {
    // ...
  };

  const {geometry} = f;
  if (geometry) {
    helper(geometry.coordinates);
  }

  return box;
}

GeoJSONFeature 类型从未明确定义过。您可以使用 Item 31 中的一些示例来编写它。但更好的方法是使用正式的 GeoJSON 规范。^(1) 幸运的是,DefinitelyTyped 上已经有了 TypeScript 类型声明。您可以按照通常的方式添加这些:

$ npm install --save-dev @types/geojson
+ @types/geojson@7946.0.7

当您插入 GeoJSON 声明时,TypeScript 立即标记出错:

import {Feature} from 'geojson';

function calculateBoundingBox(f: Feature): BoundingBox | null {
  let box: BoundingBox | null = null;

  const helper = (coords: any[]) => {
    // ...
  };

  const {geometry} = f;
  if (geometry) {
    helper(geometry.coordinates);
                 // ~~~~~~~~~~~
                 // Property 'coordinates' does not exist on type 'Geometry'
                 //   Property 'coordinates' does not exist on type
                 //   'GeometryCollection'
  }

  return box;
}

问题在于你的代码假定几何图形将具有 coordinates 属性。这对于许多几何图形(包括点、线和多边形)是正确的。但 GeoJSON 几何图形也可以是 GeometryCollection,这是其他几何图形的异构集合,不具有 coordinates 属性。

如果你对一个具有 GeometryCollection 几何图形的要素调用 calculateBoundingBox,它将抛出一个关于无法读取 undefined 的 0 属性的错误。这是一个真实的错误!我们通过规范中的类型定义捕获了它。

修复它的一个选择是显式禁止 GeometryCollection,如下所示:

const {geometry} = f;
if (geometry) {
  if (geometry.type === 'GeometryCollection') {
    throw new Error('GeometryCollections are not supported.');
  }
  helper(geometry.coordinates);  // OK
}

TypeScript 能够根据检查来细化 geometry 的类型,因此允许对 geometry.coordinates 的引用。即使没有其他内容,这也会为用户提供更清晰的错误消息。

但更好的解决方案是支持所有类型的几何图形!你可以通过提取另一个辅助函数来实现这一点:

const geometryHelper = (g: Geometry) => {
  if (geometry.type === 'GeometryCollection') {
    geometry.geometries.forEach(geometryHelper);
  } else {
    helper(geometry.coordinates);  // OK
  }
}

const {geometry} = f;
if (geometry) {
  geometryHelper(geometry);
}

如果你自己为 GeoJSON 编写类型声明,那么你会基于自己对格式的理解和经验进行。这可能不包括 GeometryCollection,并会导致对代码正确性的虚假安全感。使用基于规范的类型可以确保你的代码适用于所有值,而不仅仅是你见过的值,这让你更加自信。

对 API 调用也适用类似的考虑:如果你能从 API 规范生成类型,通常建议这样做。这在与自身具有类型的 API(如 GraphQL)特别有效。

GraphQL API 是一个带有模式的 API,使用一种类似 TypeScript 的类型系统指定所有可能的查询和接口。你编写查询请求这些接口中特定的字段。例如,要使用 GitHub GraphQL API 获取有关存储库的信息,你可能会写成:

query {
  repository(owner: "Microsoft", name: "TypeScript") {
    createdAt
    description
  }
}

结果如下:

{
  "data": {
    "repository": {
      "createdAt": "2014-06-17T15:28:39Z",
      "description":
        "TypeScript is a superset of JavaScript that compiles to JavaScript."
    }
  }
}

这种方法的美妙之处在于,你可以为你的特定查询生成 TypeScript 类型。就像 GeoJSON 示例一样,这有助于确保你准确地建模类型之间的关系及其可空性。

下面是一个获取 GitHub 存储库开源许可证的查询:

query getLicense($owner:String!, $name:String!){
  repository(owner:$owner, name:$name) {
    description
    licenseInfo {
      spdxId
      name
    }
  }
}

$owner 和 $name 是 GraphQL 变量,它们本身也有类型。类型语法与 TypeScript 类似,这可能会使人们在两者之间感到困惑。String 是 GraphQL 类型,在 TypeScript 中会是 string(参见 Item 10)。而 TypeScript 类型不可为空,而 GraphQL 类型可以为空。类型后面的 ! 表示它保证不为空。

有许多工具可以帮助你从 GraphQL 查询生成 TypeScript 类型。其中之一是 Apollo。以下是如何使用它:

$ apollo client:codegen \
    --endpoint https://api.github.com/graphql \
    --includes license.graphql \
    --target typescript
Loading Apollo Project
Generating query files with 'typescript' target - wrote 2 files

你需要一个 GraphQL 模式来为查询生成类型。Apollo 从 api.github.com/graphql 端点获取这些信息。输出看起来像这样:

export interface getLicense_repository_licenseInfo {
  __typename: "License";
  /** Short identifier specified by <https://spdx.org/licenses> */
  spdxId: string | null;
  /** The license full name specified by <https://spdx.org/licenses> */
  name: string;
}

export interface getLicense_repository {
  __typename: "Repository";
  /** The description of the repository. */
  description: string | null;
  /** The license associated with the repository */
  licenseInfo: getLicense_repository_licenseInfo | null;
}

export interface getLicense {
  /** Lookup a given repository by the owner and repository name. */
  repository: getLicense_repository | null;
}

export interface getLicenseVariables {
  owner: string;
  name: string;
}

这里需要注意的重要点是:

  • 接口为查询参数(getLicenseVariables)和响应(getLicense)生成。

  • 可空性信息从架构传递到响应接口。 repository、description、licenseInfo 和 spdxId 字段可为空,而许可证 name 和查询变量则不可为空。

  • 文档以 JSDoc 形式传输,这样它就会出现在你的编辑器中(第 48 项)。这些注释来自 GraphQL 架构本身。

这些类型信息有助于确保正确使用 API。如果查询变更,类型也会变更。如果架构变更,类型也会跟着变。由于它们都来自单一的真实来源:GraphQL 架构,不存在类型与现实不符的风险。

如果没有规范或官方架构可用怎么办?那么你将不得不从数据生成类型。像 quicktype 这样的工具可以帮助你。但要注意,你的类型可能与现实不匹配:可能会有你忽略的边缘情况。

即使你可能不知道,你已经从代码生成中受益。 TypeScript 对浏览器 DOM API 的类型声明是从官方接口生成的(见第 55 项)。这确保它们正确地模拟了一个复杂的系统,并帮助 TypeScript 在你自己的代码中捕获错误和误解。

需记住的事情

  • 考虑为 API 调用和数据格式生成类型,以确保类型安全性覆盖到代码的边缘。

  • 更倾向于根据规范而不是数据生成代码。罕见情况很重要!

第 36 条:使用问题域的语言命名类型。

计算机科学中只有两个难题:缓存失效和命名事物。

Phil Karlton

本书对类型的形状和其域中值的集合有很多讨论,但对于你如何为类型命名却说得很少。但这也是类型设计的重要部分。选择恰当的类型、属性和变量名称能够澄清意图并提高代码和类型的抽象级别。选择不当的类型可能会使你的代码变得晦涩难懂,导致错误的心理模型。

假设你正在构建一个动物数据库。你创建一个接口来表示一个:

interface Animal {
  name: string;
  endangered: boolean;
  habitat: string;
}

const leopard: Animal = {
  name: 'Snow Leopard',
  endangered: false,
  habitat: 'tundra',
};

这里存在几个问题:

  • name 是一个非常一般的术语。你期望什么样的名称?科学名称?通用名称?

  • 布尔值字段 endangered 也存在歧义。如果一个动物已经灭绝了怎么办?这里的意图是“濒危或更糟?”还是字面上的濒危?

  • habitat 字段非常模糊,不仅因为过于广泛的 string 类型(第 33 项),而且还不清楚“栖息地”指的是什么。

  • 变量名为 leopard,但 name 属性的值是 “Snow Leopard”。这种区分是否有意义?

这里有一个更少歧义的类型声明和值:

interface Animal {
  commonName: string;
  genus: string;
  species: string;
  status: ConservationStatus;
  climates: KoppenClimate[];
}
type ConservationStatus = 'EX' | 'EW' | 'CR' | 'EN' | 'VU' | 'NT' | 'LC';
type KoppenClimate = |
  'Af' | 'Am' | 'As' | 'Aw' |
  'BSh' | 'BSk' | 'BWh' | 'BWk' |
  'Cfa' | 'Cfb' | 'Cfc' | 'Csa' | 'Csb' | 'Csc' | 'Cwa' | 'Cwb' | 'Cwc' |
  'Dfa' | 'Dfb' | 'Dfc' | 'Dfd' |
  'Dsa' | 'Dsb' | 'Dsc' | 'Dwa' | 'Dwb' | 'Dwc' | 'Dwd' |
  'EF' | 'ET';
const snowLeopard: Animal = {
  commonName: 'Snow Leopard',
  genus: 'Panthera',
  species: 'Uncia',
  status: 'VU',  // vulnerable
  climates: ['ET', 'EF', 'Dfd'],  // alpine or subalpine
};

这带来了一些改进:

  • 名称(name)已被更具体的术语替代:通用名称(commonName)、属(genus)和种(species)。

  • 濒危(endangered)已成为保护状态(conservationStatus),并使用了 IUCN 的标准分类系统。

  • 栖息地(habitat)已变为气候(climates),并使用了另一个标准分类系统,即 Köppen 气候分类。

如果你需要更多关于第一版此类字段的信息,你将不得不去找编写它们的人并询问。很可能他们已经离开公司或忘记了。更糟糕的是,你可能会用git blame来找出写这些糟糕类型的人,结果发现那个人是你自己!

情况在第二版中得到了显著改善。如果你想了解更多关于 Köppen 气候分类系统的信息,或追溯保护状态的精确含义,那么网络上有无数资源可以帮助你。

每个领域都有专门的词汇来描述其主题。与其创造自己的术语,不如尝试重用与问题领域相关的术语。这些词汇往往经过多年、几十年甚至几个世纪的磨练,被领域内的人们充分理解。使用这些术语将有助于与用户沟通,并提高类型的清晰度。

确保准确使用领域词汇:将领域语言用来表达不同含义比创造自己的术语更加令人困惑。

在命名类型、属性和变量时,请记住以下几条规则:

  • 使区别具有意义。在写作和演讲中,反复使用同一个词可能很乏味。我们引入同义词来打破单调。这使得散文更易读,但对代码的影响则相反。如果你使用了两个不同的术语,请确保你在进行有意义的区分。如果不是,你应该使用相同的术语。

  • 避免使用模糊、无意义的名称,如“数据(data)”、“信息(info)”、“东西(thing)”、“项目(item)”、“对象(object)”或广受欢迎的“实体(entity)”。如果在你的领域中“实体(entity)”有特定的含义,那没问题。但如果你使用它是因为不想想出一个更有意义的名称,那么最终你会遇到麻烦。

  • 命名物品要根据它们的实际内容或计算方式,而不是它们包含的内容或计算方式。目录(Directory)比INodeList更有意义。它使你可以把目录作为一个概念来思考,而不是只考虑它的实现方式。良好的命名可以增加抽象级别,并减少无意中的命名冲突风险。

需要记住的事项

  • 在可能的情况下,重复使用问题领域中的名称,以提高代码的可读性和抽象级别。

  • 避免对同一事物使用不同的名称:使名称的区别具有意义。

第 37 条目:考虑“品牌”用于名义类型

第 4 条目讨论了结构(“鸭子”)类型和它有时可能导致令人惊讶的结果:

interface Vector2D {
  x: number;
  y: number;
}
function calculateNorm(p: Vector2D) {
  return Math.sqrt(p.x * p.x + p.y * p.y);
}

calculateNorm({x: 3, y: 4});  // OK, result is 5
const vec3D = {x: 3, y: 4, z: 1};
calculateNorm(vec3D);  // OK! result is also 5

如果你希望calculateNorm拒绝 3D 向量怎么办?这与 TypeScript 的结构化类型模型相悖,但在数学上是更加正确的。

实现这一点的一种方法是使用名称类型。使用名称类型,一个值是Vector2D,是因为你这么说,而不是因为它具有正确的形状。要在 TypeScript 中近似此功能,你可以引入一个“品牌”(想想牛,而不是可口可乐):

interface Vector2D {
  _brand: '2d';
  x: number;
  y: number;
}
function vec2D(x: number, y: number): Vector2D {
  return {x, y, _brand: '2d'};
}
function calculateNorm(p: Vector2D) {
  return Math.sqrt(p.x * p.x + p.y * p.y);  // Same as before
}

calculateNorm(vec2D(3, 4)); // OK, returns 5
const vec3D = {x: 3, y: 4, z: 1};
calculateNorm(vec3D);
           // ~~~~~ Property '_brand' is missing in type...

品牌确保向量来自正确的位置。虽然你可以在vec3D值中添加_brand: '2d',但这是从意外变成恶意的一步。这种品牌通常足以捕捉函数的无意误用。

有趣的是,你可以在仅在类型系统中操作的情况下获得与显式品牌相同的许多好处。这消除了运行时开销,还允许你对内置类型如string或number进行品牌化,无法附加附加属性的地方。

例如,如果你有一个在文件系统上操作并需要绝对路径(而不是相对路径)的函数,这在运行时很容易检查(路径是否以“/”开头?)但在类型系统中不那么容易。

这里是一种使用品牌的方法:

type AbsolutePath = string & {_brand: 'abs'};
function listAbsolutePath(path: AbsolutePath) {
  // ...
}
function isAbsolutePath(path: string): path is AbsolutePath {
  return path.startsWith('/');
}

你无法构造一个既是string又具有_brand属性的对象。这纯粹是在类型系统中玩游戏。

如果你有一个可能是绝对路径或相对路径的string路径,你可以使用类型守卫进行检查,这将细化其类型:

function f(path: string) {
  if (isAbsolutePath(path)) {
    listAbsolutePath(path);
  }
  listAbsolutePath(path);
                // ~~~~ Argument of type 'string' is not assignable
                //      to parameter of type 'AbsolutePath'
}

这种方法可能有助于记录哪些函数期望绝对路径或相对路径以及每个变量持有的路径类型。但这并非铁证保证:path as AbsolutePath将对任何string成功。但如果避免这些断言,那么获取AbsolutePath的唯一方法就是被给予一个或检查,这正是你想要的。

这种方法可用于建模许多无法在类型系统中表示的属性。例如,使用二分搜索在列表中查找元素:

function binarySearch<T>(xs: T[], x: T): boolean {
  let low = 0, high = xs.length - 1;
  while (high >= low) {
    const mid = low + Math.floor((high - low) / 2);
    const v = xs[mid];
    if (v === x) return true;
    [low, high] = x > v ? [mid + 1, high] : [low, mid - 1];
  }
  return false;
}

如果列表已排序,则此方法有效,但如果列表未排序,则会导致错误的负面结果。在 TypeScript 的类型系统中无法表示排序列表。但你可以创建一个品牌:

type SortedList<T> = T[] & {_brand: 'sorted'};

function isSorted<T>(xs: T[]): xs is SortedList<T> {
  for (let i = 1; i < xs.length; i++) {
    if (xs[i] > xs[i - 1]) {
      return false;
    }
  }
  return true;
}

function binarySearch<T>(xs: SortedList<T>, x: T): boolean {
  // ...
}

为了调用这个版本的binarySearch,你要么需要得到一个SortedList(即具有列表已排序的证明),要么用isSorted自行证明它已排序。线性扫描不是很好,但至少你会安全!

这是对类型检查器的一种有益的视角。例如,为了在对象上调用方法,你要么需要获得一个非null对象,要么用条件证明它不是null。

你也可以对number类型进行品牌化——例如,附加单位:

type Meters = number & {_brand: 'meters'};
type Seconds = number & {_brand: 'seconds'};

const meters = (m: number) => m as Meters;
const seconds = (s: number) => s as Seconds;

const oneKm = meters(1000);  // Type is Meters
const oneMin = seconds(60);  // Type is Seconds

这在实践中可能会有些尴尬,因为算术操作会使数字忘记它们的品牌:

const tenKm = oneKm * 10;  // Type is number
const v = oneKm / oneMin;  // Type is number

如果你的代码涉及大量带有混合单位的数字,这种方式仍然可能是记录数字参数预期类型的一种吸引人的方法。

要记住的事情

  • TypeScript 使用结构化(“鸭子”)类型,有时会导致令人意外的结果。如果你需要名义上的类型,请考虑为你的值附加“品牌”以区分它们。

  • 在某些情况下,你可以完全在类型系统中附加品牌,而不是在运行时附加。你可以使用这种技术来建模 TypeScript 类型系统之外的属性。

^(1) GeoJSON 也被称为 RFC 7946。这个非常易读的规范可以在 http://geojson.org 找到。

第五章:使用 any 类型

类型系统传统上是二进制的:语言要么具有完全静态的类型系统,要么具有完全动态的类型系统。TypeScript 模糊了这条界线,因为它的类型系统是可选的和逐步的。您可以向程序的某些部分添加类型,而另一些部分则不添加。

对于逐步将现有 JavaScript 代码库迁移到 TypeScript 非常重要(参见第八章)。其中的关键在于 any 类型,它有效地禁用了代码的部分类型检查。这既强大又容易滥用。学会如何明智地使用 any 对于编写有效的 TypeScript 至关重要。本章将指导您如何在保留其优点的同时限制 any 的缺点。

条款 38: 尽可能使用最狭隘的范围来处理 any 类型

考虑这段代码:

function processBar(b: Bar) { /* ... */ }

function f() {
  const x = expressionReturningFoo();
  processBar(x);
  //         ~ Argument of type 'Foo' is not assignable to
  //           parameter of type 'Bar'
}

如果您从上下文中某种方式知道 x 既可分配给 Foo,也可分配给 Bar,则可以通过两种方式强制 TypeScript 接受此代码:

function f1() {
  const x: any = expressionReturningFoo();  // Don't do this
  processBar(x);
}

function f2() {
  const x = expressionReturningFoo();
  processBar(x as any);  // Prefer this
}

其中,第二种形式更可取。为什么?因为 any 类型仅限于函数参数中的单个表达式。它不会影响到该参数或该行之外的代码。如果 processBar 调用后的代码引用 x,它的类型仍然是 Foo,仍然能触发类型错误,而在第一个示例中,其类型为 any,直到函数结束时才会超出范围。

如果从该函数中返回 x,风险会显著增加。看看会发生什么:

function f1() {
  const x: any = expressionReturningFoo();
  processBar(x);
  return x;
}

function g() {
  const foo = f1();  // Type is any
  foo.fooMethod();  // This call is unchecked!
}

any 返回类型是“传染性”的,它可以在代码库中传播开来。由于我们对 f 的更改,g 中悄然出现了 any 类型。如果使用更狭隘范围的 f2,这种情况就不会发生。

(这是考虑包括显式返回类型注释的一个好理由,即使返回类型可以推断出来。它防止 any 类型“逃逸”。参见 条款 19 中的讨论。)

我们在这里使用 any 来消除我们认为不正确的错误。另一种方法是使用 @ts-ignore:

function f1() {
  const x = expressionReturningFoo();
  // @ts-ignore
  processBar(x);
  return x;
}

这将消除下一行的错误,保持 x 的类型不变。尽量不要过多依赖 @ts-ignore:类型检查器通常有充分的理由抱怨。这也意味着,如果下一行的错误变得更加严重,您将无法得知。

您可能还会遇到仅在较大对象的一个属性上出现类型错误的情况:

const config: Config = {
  a: 1,
  b: 2,
  c: {
    key: value
 // ~~~ Property ... missing in type 'Bar' but required in type 'Foo'
  }
};

您可以通过在整个 config 对象周围加上 as any 来消除此类错误:

const config: Config = {
  a: 1,
  b: 2,
  c: {
    key: value
  }
} as any;  // Don't do this!

但这样做的副作用是禁用了其他属性(a 和 b)的类型检查。使用更狭隘范围的 any 可以限制损害:

const config: Config = {
  a: 1,
  b: 2,  // These properties are still checked
  c: {
    key: value as any
  }
};

要记住的事情

  • 尽可能将您对 any 的使用范围尽可能狭隘,以避免在代码的其他地方意外损失类型安全性。

  • 永远不要从函数中返回 any 类型。这将悄悄导致任何调用该函数的客户端失去类型安全性。

  • 如果需要消除一个错误,可以考虑@ts-ignore作为any的替代方案。

项目 39:更喜欢任意类型的更精确变体而不是纯粹的任意类型

any类型包括 JavaScript 中可以表达的所有值。这是一个广泛的集合!它不仅包括所有数字和字符串,还包括所有数组、对象、正则表达式、函数、类和 DOM 元素,更不用说null和undefined了。当您使用any类型时,请问自己是否真的考虑了更具体的东西。是否可以传递正则表达式或函数?

通常答案是“不”,在这种情况下,您可能可以通过使用更具体的类型来保留一些类型安全性:

function getLengthBad(array: any) {  // Don't do this!
  return array.length;
}

function getLength(array: any[]) {
  return array.length;
}

较后一种版本,使用any[]而不是any,在三个方面都更好:

  • 函数体中对array.length的引用经过类型检查。

  • 函数的返回类型被推断为number而不是any。

  • 对getLength的调用将被检查以确保参数是一个数组:

getLengthBad(/123/);  // No error, returns undefined
getLength(/123/);
       // ~~~~~ Argument of type 'RegExp' is not assignable
       //       to parameter of type 'any[]'

如果您期望参数是数组的数组,但不关心其类型,可以使用any[][]。如果您期望某种对象但不知道其值将是什么,可以使用{[key: string]: any}:

function hasTwelveLetterKey(o: {[key: string]: any}) {
  for (const key in o) {
    if (key.length === 12) {
      return true;
    }
  }
  return false;
}

在这种情况下,您还可以使用object类型,该类型包括所有非原始类型。这略有不同,因为虽然您仍然可以枚举键,但无法访问其中任何一个的值:

function hasTwelveLetterKey(o: object) {
  for (const key in o) {
    if (key.length === 12) {
      console.log(key, o[key]);
                   //  ~~~~~~ Element implicitly has an 'any' type
                   //         because type '{}' has no index signature
      return true;
    }
  }
  return false;
}

如果这种类型符合您的需求,您可能还会对unknown类型感兴趣。请参见项目 42。

如果期望一个函数类型,请避免使用any。在这里您有几个选项,具体取决于您希望有多精确:

type Fn0 = () => any;  // any function callable with no params
type Fn1 = (arg: any) => any;  // With one param
type FnN = (...args: any[]) => any;  // With any number of params
                                     // same as "Function" type

所有这些比any更精确,因此更可取。请注意在最后一个示例中使用any[]作为剩余参数的类型。any也可以在这里工作,但不够精确:

const numArgsBad = (...args: any) => args.length; // Returns any
const numArgsGood = (...args: any[]) => args.length;  // Returns number

这可能是any[]类型最常见的用法。

要记住的事情

  • 当您使用any时,请考虑任何 JavaScript 值是否真的是允许的。

  • 优先使用更精确的形式,如any[]或{[id: string]: any}或() => any,如果它们更准确地模拟您的数据。

项目 40:在类型正确的函数中隐藏不安全类型断言

有许多函数,其类型签名易于编写,但其类型安全的实现却相当困难。尽管编写类型安全的实现是一个高尚的目标,但处理你的代码中不会出现的边缘情况可能并不值得。如果尝试编写类型安全的实现不起作用,可以在具有正确类型签名的函数内部使用隐藏的不安全类型断言。隐藏在类型正确的函数内部的不安全断言比散布在代码中的不安全断言要好得多。

假设你想让一个函数缓存其最后一次调用。这是一种用于消除使用像 React 这样的框架时昂贵函数调用的常见技术。[¹] 编写一个通用的cacheLast包装器为任何函数添加此行为将是一个好主意。其声明很容易写出来:

declare function cacheLast<T extends Function>(fn: T): T;

这是一个实现的尝试:

function cacheLast<T extends Function>(fn: T): T {
  let lastArgs: any[]|null = null;
  let lastResult: any;
  return function(...args: any[]) {
      // ~~~~~~~~~~~~~~~~~~~~~~~~~~
      //          Type '(...args: any[]) => any' is not assignable to type 'T'
    if (!lastArgs || !shallowEqual(lastArgs, args)) {
      lastResult = fn(...args);
      lastArgs = args;
    }
    return lastResult;
  };
}

错误是有道理的:TypeScript 没有理由相信这个非常宽松的函数与T有任何关系。但你知道类型系统将强制要求它以正确的参数调用,并且其返回值给予正确的类型。所以如果在这里添加类型断言,你不应该期望出现太多问题:

function cacheLast<T extends Function>(fn: T): T {
  let lastArgs: any[]|null = null;
  let lastResult: any;
  return function(...args: any[]) {
    if (!lastArgs || !shallowEqual(lastArgs, args)) {
      lastResult = fn(...args);
      lastArgs = args;
    }
    return lastResult;
  } as unknown as T;
}

这确实对于任何你传递给它的简单函数都非常有效。在这个实现中隐藏了相当多的any类型,但你已经把它们从类型签名中排除了,所以调用cacheLast的代码不会知道。

(这实际上安全吗?这个实现有一些真实的问题:它不检查连续调用的this值是否相同。如果原始函数有定义在其上的属性,那么包装函数将没有这些属性,因此它将不具有相同的类型。但如果你知道在你的代码中不会遇到这些情况,这个实现就可以接受。这个函数可以以类型安全的方式编写,但这是一个更复杂的练习,留给读者自行探索。)

前一个示例中的shallowEqual函数在两个数组上操作并且易于输入和实现。但对象变体更有趣。与cacheLast一样,编写其类型签名很容易:

declare function shallowObjectEqual<T extends object>(a: T, b: T): boolean;

实现需要小心,因为没有保证a和b具有相同的键(参见条款 54):

function shallowObjectEqual<T extends object>(a: T, b: T): boolean {
  for (const [k, aVal] of Object.entries(a)) {
    if (!(k in b) || aVal !== b[k]) {
                           // ~~~~ Element implicitly has an 'any' type
                           //      because type '{}' has no index signature
      return false;
    }
  }
  return Object.keys(a).length === Object.keys(b).length;
}

TypeScript 抱怨访问b[k]尽管你刚刚检查过k in b为真有点令人惊讶。但它确实如此,所以你别无选择,只能进行类型转换:

function shallowObjectEqual<T extends object>(a: T, b: T): boolean {
  for (const [k, aVal] of Object.entries(a)) {
    if (!(k in b) || aVal !== (b as any)[k]) {
      return false;
    }
  }
  return Object.keys(a).length === Object.keys(b).length;
}

这种类型断言是无害的(因为你已经检查过k in b),你得到了一个带有清晰类型签名的正确函数。这比在代码中分散迭代和断言以检查对象相等性要好得多!

需要记住的事情:

  • 有时不安全的类型断言是必要或方便的。当你需要使用时,将其隐藏在一个具有正确签名的函数内部。

条款 41:理解不断演化的any

在 TypeScript 中,变量的类型通常在声明时确定。之后,它可以通过精化(例如检查是否为null)来细化,但不能扩展以包含新值。然而,有一个显著的例外涉及any类型。

在 JavaScript 中,你可以像这样编写一个生成数字范围的函数:

function range(start, limit) {
  const out = [];
  for (let i = start; i < limit; i++) {
    out.push(i);
  }
  return out;
}

当你将这个转换成 TypeScript 时,它将按你预期的方式工作:

function range(start: number, limit: number) {
  const out = [];
  for (let i = start; i < limit; i++) {
    out.push(i);
  }
  return out;  // Return type inferred as number[]
}

然而仔细检查时,令人惊讶的是它竟然工作正常!TypeScript 如何知道 out 的类型是 number[],当它被初始化为 [] 时,这可能是任何类型的数组?

检查 out 的三个出现来揭示其推断类型开始讲述这个故事:

function range(start: number, limit: number) {
  const out = [];  // Type is any[]
  for (let i = start; i < limit; i++) {
    out.push(i);  // Type of out is any[]
  }
  return out;  // Type is number[]
}

out 的类型开始是 any[],一个未区分的数组。但随着我们推送 number 值进去,它的类型“演变”成为 number[]。

这与缩小范围(条目 22)是不同的。数组的类型可以通过推送不同的元素而扩展:

const result = [];  // Type is any[]
result.push('a');
result  // Type is string[]
result.push(1);
result  // Type is (string | number)[]

在条件语句中,类型甚至可以在不同分支间变化。这里我们展示了与简单值相同的行为,而不是数组:

let val;  // Type is any
if (Math.random() < 0.5) {
  val = /hello/;
  val  // Type is RegExp
} else {
  val = 12;
  val  // Type is number
}
val  // Type is number | RegExp

最后一个触发这种“演变 any”行为的情况是,如果变量最初是 null。在 try/catch 块中设置值时经常遇到这种情况:

let val = null;  // Type is any
try {
  somethingDangerous();
  val = 12;
  val  // Type is number
} catch (e) {
  console.warn('alas!');
}
val  // Type is number | null

有趣的是,这种行为只在变量的类型隐式为 any 并且设置了 noImplicitAny 时才会发生!增加 显式 的 any 可以保持类型恒定:

let val: any;  // Type is any
if (Math.random() < 0.5) {
  val = /hello/;
  val  // Type is any
} else {
  val = 12;
  val  // Type is any
}
val  // Type is any
注意

这种行为可能在编辑器中跟踪起来会令人困惑,因为类型只在你分配或推送元素之后“演变”。检查赋值行上的类型仍将显示 any 或 any[]。

如果在赋值之前使用一个值,你将会遇到隐式的 any 错误:

function range(start: number, limit: number) {
  const out = [];
  //    ~~~ Variable 'out' implicitly has type 'any[]' in some
  //        locations where its type cannot be determined
  if (start === limit) {
    return out;
    //     ~~~ Variable 'out' implicitly has an 'any[]' type
  }
  for (let i = start; i < limit; i++) {
    out.push(i);
  }
  return out;
}

换句话说,“演变”的 any 类型只有在你 写 到它们时才是 any。如果你试图在它们仍然是 any 的情况下 读 取它们,你会得到一个错误。

隐式的 any 类型不会通过函数调用而演变。箭头函数在这里会导致推断出错:

function makeSquares(start: number, limit: number) {
  const out = [];
     // ~~~ Variable 'out' implicitly has type 'any[]' in some locations
  range(start, limit).forEach(i => {
    out.push(i * i);
  });
  return out;
      // ~~~ Variable 'out' implicitly has an 'any[]' type
}

在这种情况下,你可能希望考虑使用数组的 map 和 filter 方法来在单个语句中构建数组,避免迭代和完全避免演变的 any。参见条目 23 和 27。

any 的演变带来了关于类型推断的所有常规警告。你的数组的正确类型真的应该是 (string|number)[] 吗?或者它应该是 number[],而你错误地推送了一个 string?你可能仍然希望提供显式类型注解以获得更好的错误检查,而不是使用演变的 any。

记住的事情

  • 虽然 TypeScript 类型通常只是 细化,但隐式的 any 和 any[] 类型是允许 演变 的。当出现这种情况时,你应该能够识别和理解这种结构。

  • 为了更好的错误检查,考虑提供显式的类型注解,而不是使用演变的 any。

条目 42:对于未知类型的值,使用 unknown 而不是 any

假设你想写一个 YAML 解析器(YAML 可以表示与 JSON 相同的值集,但允许 JSON 语法的超集)。你的 parseYAML 方法的返回类型应该是什么?像 JSON.parse 一样使用 any 是很诱人的:

function parseYAML(yaml: string): any {
  // ...
}

但这与 条目 38 避免“传染性” any 类型的建议相矛盾,特别是不要从函数中返回它们。

理想情况下,你希望用户立即将结果分配给另一种类型:

interface Book {
  name: string;
  author: string;
}
const book: Book = parseYAML(`
 name: Wuthering Heights
 author: Emily Brontë
`);

虽然没有类型声明,book变量将默认成为any类型,在使用它时会绕过类型检查:

const book = parseYAML(`
 name: Jane Eyre
 author: Charlotte Brontë
`);
alert(book.title);  // No error, alerts "undefined" at runtime
book('read');  // No error, throws "TypeError: book is not a
               // function" at runtime

更安全的选择是让parseYAML返回一个unknown类型:

function safeParseYAML(yaml: string): unknown {
  return parseYAML(yaml);
}
const book = safeParseYAML(`
 name: The Tenant of Wildfell Hall
 author: Anne Brontë
`);
alert(book.title);
   // ~~~~ Object is of type 'unknown'
book("read");
// ~~~~~~~~~~ Object is of type 'unknown'

要理解unknown类型,有助于从可分配性的角度思考any。any的力量和危险来自两个属性:

  • 任何类型都可以赋给any类型。

  • any类型可以赋给任何其他类型。^(2)

在“将类型视为值集合”(Item 7)的背景下,any显然不适合于类型系统,因为一个集合不能同时是所有其他集合的子集和超集。这是any强大之处,但也是其问题所在。由于类型检查器是基于集合的,使用any实际上会使其失效。

unknown类型是any的替代品,可以适应类型系统。它具有第一个属性(任何类型都可以赋给unknown),但不具备第二个属性(unknown只能赋给unknown和当然any)。相反的是never类型:它具有第二个属性(可以赋给任何其他类型),但不具备第一个属性(没有类型可以赋给never)。

试图访问具有unknown类型值的属性是错误的。尝试调用它或进行算术运算也是错误的。你不能对unknown做太多事情,这正是关键所在。关于unknown类型的错误将促使你添加适当的类型:

const book = safeParseYAML(`
 name: Villette
 author: Charlotte Brontë
`) as Book;
alert(book.title);
        // ~~~~~ Property 'title' does not exist on type 'Book'
book('read');
// ~~~~~~~~~ this expression is not callable

这些错误更合理。因为unknown不能赋值给其他类型,所以需要类型断言。但这也是合适的:我们确实比 TypeScript 更了解结果对象的类型。

当你知道会有一个值但不知道其类型时,适合使用unknown。parseYAML的结果就是一个例子,但还有其他情况。例如,在 GeoJSON 规范中,Feature 的properties属性是任何可 JSON 序列化的东西的集合。因此unknown是有意义的:

interface Feature {
  id?: string | number;
  geometry: Geometry;
  properties: unknown;
}

类型断言并不是从unknown对象中恢复类型的唯一方法。instanceof检查也可以:

function processValue(val: unknown) {
  if (val instanceof Date) {
    val  // Type is Date
  }
}

你也可以使用用户定义的类型保护:

function isBook(val: unknown): val is Book {
  return (
      typeof(val) === 'object' && val !== null &&
      'name' in val && 'author' in val
  );
}
function processValue(val: unknown) {
  if (isBook(val)) {
    val;  // Type is Book
  }
}

TypeScript 需要相当多的证据来缩小unknown类型:为了避免在in检查中出现错误,首先必须证明val是对象类型并且非null(因为typeof null === 'object')。

有时你会看到使用泛型参数而不是unknown。你可以这样声明safeParseYAML函数:

function safeParseYAML<T>(yaml: string): T {
  return parseYAML(yaml);
}

然而,这在 TypeScript 中通常被认为是不好的风格。它看起来与类型断言不同,但实际上功能上是相同的。最好只返回unknown并强制用户使用断言或缩小到他们想要的类型。

unknown在“双重断言”中也可以替代any使用:

declare const foo: Foo;
let barAny = foo as any as Bar;
let barUnk = foo as unknown as Bar;

这两种方法在功能上是等效的,但是 unknown 形式在进行重构并拆分两个断言时风险较小。在这种情况下,any 可能会逃逸和扩散。如果 unknown 类型逃逸,它可能只会产生一个错误。

最后一点,你可能会看到使用 object 或 {} 的代码,类似于本条目中描述 unknown 的方式。它们也是广义类型,但比 unknown 稍微窄一些:

  • {} 类型包含除了 null 和 undefined 之外的所有值。

  • object 类型包括所有非原始类型。这不包括 true 或 12 或 "foo",但包括对象和数组。

在引入 unknown 类型之前,使用 {} 更为常见。今天的使用情况有些罕见:只有在你确实知道 null 和 undefined 不可能存在时,才使用 {} 而不是 unknown。

需要记住的事情

  • unknown 类型是 any 的类型安全替代方案。当你知道有一个值但不知道其类型时,请使用它。

  • 使用 unknown 强制用户进行类型断言或进行类型检查。

  • 理解 {}、object 和 unknown 之间的区别。

项目 43:更喜欢类型安全的方法来进行 Monkey Patching

JavaScript 最著名的特性之一是其对象和类是“开放的”,这意味着你可以向它们添加任意属性。有时会使用这种方式在网页上创建全局变量,通过分配给 window 或 document:

window.monkey = 'Tamarin';
document.monkey = 'Howler';

或将数据附加到 DOM 元素:

const el = document.getElementById('colobus');
el.home = 'tree';

这种风格在使用 jQuery 的代码中特别常见。

你甚至可以向内置原型附加属性,有时会产生令人惊讶的结果:

>  `RegExp``.``prototype``.``monkey` `=` `'Capuchin'`
"Capuchin"
> `/123/``.``monkey`
"Capuchin"

这些方法通常不是良好的设计。当你将数据附加到 window 或 DOM 节点时,你实际上是将其变成全局变量。这样做容易无意中在程序的各个部分之间引入依赖,并意味着每次调用函数时都需要考虑副作用。

添加 TypeScript 会引入另一个问题:虽然类型检查器知道 Document 和 HTMLElement 的内置属性,但它肯定不知道你添加的属性:

document.monkey = 'Tamarin';
      // ~~~~~~ Property 'monkey' does not exist on type 'Document'

修复这个错误的最直接方式是使用 any 断言:

(document as any).monkey = 'Tamarin';  // OK

这会满足类型检查器,但是,也应该不会让你感到惊讶,它也有一些缺点。与任何使用 any 的情况一样,你将失去类型安全和语言服务:

(document as any).monky = 'Tamarin';  // Also OK, misspelled
(document as any).monkey = /Tamarin/;  // Also OK, wrong type

最佳解决方案是将数据移出 document 或 DOM。但如果不能这样做(可能你正在使用需要它的库或正在迁移 JavaScript 应用程序),那么还有一些次优的选择可用。

一种方法是使用增强功能,这是 interface 的特殊能力之一(第 13 项):

interface Document {
  /** Genus or species of monkey patch */
  monkey: string;
}

document.monkey = 'Tamarin';  // OK

这比在几个方面使用 any 要好:

  • 你可以获得类型安全。类型检查器将标记拼写错误或错误类型的赋值。

  • 你可以将文档附加到属性 (Item 48)。

  • 您可以在属性上获得自动完成。

  • 此增强的确切记录是有的。

在模块上下文中(即使用 import / export 的 TypeScript 文件),您需要添加 declare global 才能使其工作:

export {};
declare global {
  interface Document {
    /** Genus or species of monkey patch */
    monkey: string;
  }
}
document.monkey = 'Tamarin';  // OK

使用增强的主要问题与作用域有关。首先,增强是全局应用的。您无法将其隐藏在代码的其他部分或库中。其次,如果在应用程序运行时分配属性,则无法在此之后仅引入增强。当您补丁 HTML 元素时,这尤为问题,因为页面上的某些元素将具有属性,而其他元素则不会。因此,您可能希望声明该属性为 string|undefined。这更加准确,但使得类型使用起来不那么方便。

另一种方法是使用更精确的类型断言:

interface MonkeyDocument extends Document {
  /** Genus or species of monkey patch */
  monkey: string;
}

(document as MonkeyDocument).monkey = 'Macaque';

TypeScript 对类型断言可以接受,因为 Document 和 MonkeyDocument 共享属性 (Item 9)。并且在赋值时会获得类型安全。作用域问题也更容易管理:没有全局修改 Document 类型,只是引入了一个新类型(只有在引入时才在作用域内)。每次引用 Monkey patched 属性时,必须编写断言(或引入新变量)。但你可以把这视作重构为更结构化代码的鼓励。Monkey patching 不应该太容易!

需要记住的事情

  • 更倾向于结构化代码,而不是在全局变量或 DOM 上存储数据。

  • 如果必须在内置类型上存储数据,请使用其中一种类型安全的方法(增强或断言自定义接口)。

  • 理解增强的作用域问题。

Item 44: 跟踪类型覆盖以防止类型安全性回归

一旦为隐式 any 类型的值添加类型注释并启用 noImplicitAny,你是否免受与任何类型相关问题的影响?答案是否定的;any 类型仍然可以通过两种主要方式进入您的程序:

显式 any 类型

即使您遵循 38 和 39 的建议,使您的 any 类型变得更窄和更具体,它们仍然是 any 类型。特别是像 any[] 和 {[key: string]: any} 这样的类型一旦索引进入,结果的 any 类型就可以流经您的代码。

来自第三方类型声明

特别是从 @types 声明文件中,any 类型会悄无声息地进入:即使您已启用了 noImplicitAny 并且从未输入过 any,any 类型仍然会在代码中流动。

由于 any 类型对类型安全性和开发者体验的负面影响(条目 5),在代码库中跟踪它们的数量是个好主意。有许多方法可以做到这一点,包括 npm 上的 type-coverage 包:

$ npx type-coverage
9985 / 10117 98.69%

这意味着,在这个项目中的 10,117 个符号中,有 9,985 个(98.69%)具有 any 之外的类型或 any 的别名。如果一个变更无意中引入了 any 类型并且它在你的代码中传播,你会看到这个百分比相应下降。

在某种程度上,这个百分比是用来评估你在本章中遵循其他条目建议的程度。使用范围狭窄的 any 将减少具有 any 类型的符号数量,使用诸如 any[] 这样更具体的形式也是如此。通过数字跟踪这一点有助于确保随着时间的推移事情只会变得更好。

即使只收集一次类型覆盖信息也是有益的。使用 --detail 标志运行 type-coverage 将打印出你的代码中每个 any 类型出现的位置:

$ npx type-coverage --detail
path/to/code.ts:1:10 getColumnInfo
path/to/module.ts:7:1 pt2
...

这些值得调查,因为它们很可能会揭示你没有考虑到的 any 的来源。让我们看几个例子。

明确的 any 类型通常是你之前为了便利而做出的选择的结果。也许你得到了一个你不想花时间解决的类型错误。或者也许这个类型是你还没有写出来的。或者你可能只是匆忙之间。

使用 any 的类型断言可能会阻止类型流向其本应流向的地方。也许你构建了一个处理表格数据的应用程序,并且需要一个单参数函数来构建某种列描述:

function getColumnInfo(name: string): any {
  return utils.buildColumnInfo(appState.dataSchema, name);  // Returns any
}

utils.buildColumnInfo 函数在某个时候返回了 any。作为提醒,你在函数中添加了一个注释和一个显式的“: any”注解。

然而,在过去的几个月里,你还为 ColumnInfo 添加了一个类型,utils.buildColumnInfo 不再返回 any。现在的 any 注解正在丢弃宝贵的类型信息。去掉它!

第三方 any 类型可以有几种形式,但最极端的是当你给整个模块一个 any 类型时:

declare module 'my-module';

现在你可以从 my-module 导入任何东西而不会出错。这些符号都具有 any 类型,如果通过它们传递值,将导致更多的 any 类型:

import {someMethod, someSymbol} from 'my-module';  // OK

const pt1 = {
  x: 1,
  y: 2,
};  // type is {x: number, y: number}
const pt2 = someMethod(pt1, someSymbol);  // OK, pt2's type is any

由于使用看起来与类型良好的模块相同,很容易忘记你替换了模块。或者可能是一个同事这样做了,而你一开始根本不知道。值得不时重新审视这些。也许这个模块有官方的类型声明。或者也许你已经对模块有足够的了解,可以自己编写类型并将其贡献回社区。

第三方声明中另一个常见的 any 来源是类型存在错误时。也许声明没有遵循 Item 29 的建议,并且声明一个函数返回一个联合类型,而实际上它返回了更具体的东西。当你首次使用函数时,修复这个问题似乎不值得,所以你使用了 any 断言。但也许声明现在已经修复了。或者也许是时候自己修复它们了!

导致你使用 any 类型的考虑可能不再适用。也许现在你可以插入一个类型,而之前你使用了 any。也许不再需要一个不安全的类型断言。也许你曾经绕过的类型声明中的错误已经被修复。跟踪你的类型覆盖可以突出显示这些选择,并鼓励你不断重新审视它们。

要记住的事情

  • 即使设置了 noImplicitAny,any 类型可能仍会通过显式的 any 或第三方类型声明 (@types) 进入你的代码。

  • 考虑跟踪你的程序有多少是类型良好的。这将鼓励你重新考虑是否使用 any 并随着时间增加类型安全性。

^(1) 如果你正在使用 React,你应该使用内置的 useMemo 钩子,而不是自己编写。

^(2) 除了 never。

第六章:类型声明和 @types

任何语言中的依赖管理都可能令人困惑,TypeScript 也不例外。本章将帮助您建立一个关于 TypeScript 中依赖工作方式的思维模型,并向您展示如何解决可能出现的一些问题。它还将帮助您编写自己的类型声明文件以发布和与他人共享。通过编写优秀的类型声明,您不仅可以帮助自己的项目,还可以帮助整个 TypeScript 社区。

项目 45: 将 TypeScript 和 @types 放在 devDependencies 中

Node 包管理器 npm 在 JavaScript 世界中无处不在。它既提供了 JavaScript 库的仓库(npm 注册表),又提供了指定你依赖的它们的版本的方法(package.json)。

npm 在 package.json 中区分几种依赖类型:

dependencies

这些包是运行 JavaScript 所必需的。如果您在运行时导入 lodash,则应该放在 dependencies 中。当您在 npm 上发布您的代码并且另一个用户安装它时,它也会安装这些依赖项。(这些被称为传递依赖项。)

devDependencies

这些包用于开发和测试您的代码,但在运行时不需要。您的测试框架将是 devDependency 的一个示例。与 dependencies 不同,这些包不会与您的包一起传递安装。

peerDependencies

这些是运行时需要但不想负责跟踪的包。经典示例是插件。您的 jQuery 插件与多个版本的 jQuery 兼容,但您更希望用户选择一个版本,而不是由您选择。

其中,dependencies 和 devDependencies 是最常见的。在使用 TypeScript 时,请注意你正在添加哪种类型的依赖。因为 TypeScript 是一个开发工具,TypeScript 类型在运行时不存在(项目 3),与 TypeScript 相关的包通常应放在 devDependencies 中。

首先要考虑的依赖是 TypeScript 本身。虽然可以系统范围安装 TypeScript,但通常这不是一个好主意,原因有两个:

  • 不能保证您和您的同事总是安装相同的版本。

  • 它为你的项目设置增加了一步。

将 TypeScript 作为 devDependency。这样当你运行 npm install 时,你和你的同事将始终获得正确的版本。更新 TypeScript 版本与更新其他任何包的模式相同。

您的 IDE 和构建工具将愉快地发现以这种方式安装的 TypeScript 版本。在命令行上,您可以使用 npx 运行 npm 安装的 tsc 版本:

$ npx tsc

要考虑的下一个依赖类型是 类型依赖 或 @types。 如果库本身没有 TypeScript 类型声明,则可能仍然可以在 DefinitelyTyped 上找到 typings,这是一个由社区维护的 JavaScript 库类型定义集合。 DefinitelyTyped 上的类型定义发布在 npm 注册表下的 @types 范围内:@types/jquery 提供了 jQuery 的类型定义,@types/lodash 提供了 Lodash 的类型定义等。 这些 @types 包只包含 类型。 它们不包含实现。

您的 @types 依赖项也应该是 devDependencies,即使该包本身是直接依赖关系。 例如,要依赖于 React 及其类型声明,您可以运行:

$ npm install react
$ npm install --save-dev @types/react

这将导致一个类似以下内容的 package.json 文件:

{
  "devDependencies": {
    "@types/lodash": "¹⁶.8.19",
    "typescript": "³.5.3"
  },
  "dependencies": {
    "react": "¹⁶.8.6"
  }
}

这里的理念是您应该发布 JavaScript,而不是 TypeScript,并且当您运行它时,您的 JavaScript 不依赖于 @types。 @types 依赖项可能会出现一些问题,下一项将更深入地探讨这个主题。

要记住的事项

  • 避免在系统范围内安装 TypeScript。 将 TypeScript 作为项目的 devDependency,以确保团队中的每个人都使用一致的版本。

  • 将 @types 依赖项放入 devDependencies,而不是 dependencies。 如果您需要在运行时使用 @types,则可能需要重新调整您的流程。

条款 46: 理解涉及类型声明的三个版本

对于软件开发人员来说,依赖管理很少能带来愉快的感觉。 通常您只想使用一个库,而不用太过关心其传递依赖是否与您的兼容。

不好的消息是,TypeScript 并没有使这个过程变得更好。 实际上,它使依赖管理变得相当 复杂。 这是因为现在您不再需要关心单一版本,而是有了三个版本:

  • 包的版本

  • 其类型声明的版本 (@types)

  • TypeScript 的版本

如果其中任何一个版本与其他版本不同步,可能会遇到与依赖管理无关但错误不明显的问题。但正如俗话说的,“使事情尽可能简单,但不要过于简单。” 理解 TypeScript 包管理的全部复杂性将有助于您诊断和解决问题。 它还将帮助您在发布自己的类型声明时做出更明智的决策。

TypeScript 中依赖项的工作方式如下。 您将一个包作为直接依赖项安装,并将其类型作为 devDependency 安装(参见 条款 45):

$ npm install react
+ react@16.8.6

$ npm install --save-dev @types/react
+ @types/react@16.8.19

请注意主要和次要版本(16.8)相匹配,但修订版本(.6 和 .19)不匹配。这正是你想要看到的。@types 版本中的 16.8 意味着这些类型声明描述了 react 版本 16.8 的 API。假设 react 模块遵循良好的语义化版本控制,修订版本(16.8.1、16.8.2,……)不会改变其公共 API,也不需要更新类型声明。但类型声明本身可能存在错误或遗漏。@types 模块的修订版本对应于这些修复和添加。在这种情况下,类型声明更新比库本身要多(19 与 6)。

这可能会以几种方式出现。

首先,你可能会更新一个库,但忘记更新它的类型声明。在这种情况下,每当你尝试使用库的新功能时,你将收到类型错误。如果库有重大变更,尽管你的代码通过了类型检查器,你可能会得到运行时错误。

解决方案通常是更新类型声明,使版本重新同步。如果类型声明没有更新,你有几个选择。你可以在自己的项目中使用扩展来添加想要使用的新函数和方法。或者你可以向社区贡献更新的类型声明。

第二,你的类型声明可能会超过库的版本。如果你之前在没有类型声明的情况下使用了一个库(也许你使用了 declare module 来给它一个 any 类型),然后试图稍后安装它们。如果库及其类型声明有新的发布,你的版本可能不会同步。这种情况的症状与第一个问题类似,只是反过来。类型检查器将与最新的 API 对比你的代码,而你在运行时将使用较旧的 API。解决方案是要么升级库,要么降低类型声明版本,直到它们匹配。

第三,类型声明可能需要比项目中正在使用的 TypeScript 版本更新的新版本。TypeScript 类型系统的许多开发都是为了更准确地为像 Lodash、React 和 Ramda 这样的流行 JavaScript 库类型化。因此,这些库的类型声明希望使用最新和最好的特性来提供更好的类型安全性是有道理的。

如果出现这种情况,你会将其体验为@types 声明中的类型错误。解决方案是升级 TypeScript 版本,使用旧版本的类型声明,或者如果真的不能更新 TypeScript,则使用 declare module 来存根化类型。有些库可以通过 typesVersions 为不同版本的 TypeScript 提供不同的类型声明,但这种情况很少见:在撰写本文时,绝大多数在 DefinitelyTyped 上的包都没有这么做。

要安装特定版本 TypeScript 的@types,您可以使用:

npm install --save-dev @types/lodash@ts3.1

库和它们类型之间的版本匹配是尽力而为的,可能不总是正确的。但是,库越受欢迎,它的类型声明正确性的可能性就越大。

第四点,您可能会遇到重复的@types依赖。假设您依赖于@types/foo和@types/bar。如果@types/bar依赖于不兼容的@types/foo版本,那么 npm 将尝试解决此问题,将两个版本都安装,一个在嵌套文件夹中:

node_modules/
  @types/
    foo/
      index.d.ts @1.2.3
    bar/
      index.d.ts
      node_modules/
        @types/
          foo/
            index.d.ts @2.3.4

虽然对于在运行时使用的节点模块有时是可以接受的,但对于类型声明来说几乎肯定是不可接受的,因为它们存在于一个平面全局命名空间中。您会看到关于重复声明或无法合并声明的错误。您可以通过运行npm ls @types/foo来追踪为什么会有重复的类型声明。解决方案通常是更新您对@types/foo或@types/bar的依赖,使它们兼容。像这样的传递依赖@types通常是问题的根源。如果您要发布类型,请参阅第 51 条以避免这些问题。

一些包,特别是用 TypeScript 编写的包,选择捆绑它们自己的类型声明。这通常通过它们的 package.json 中的 "types" 字段指向一个 .d.ts 文件来表示:

{
  "name": "left-pad",
  "version": "1.3.0",
  "description": "String left pad",
  "main": "index.js",
  "types": "index.d.ts",
  // ...
}

这能解决我们所有的问题吗?如果答案是“是”,那我会提这个问题吗?

捆绑类型确实解决了版本不匹配的问题,特别是如果库本身是用 TypeScript 编写的,并且类型声明是由 tsc 生成的。但捆绑也有自己的问题。

第一点,如果捆绑类型中有一个无法通过增补修复的错误?或者类型在发布时工作正常,但后来发布了一个新的 TypeScript 版本,它标记了一个错误。使用@types,您可以依赖于库的实现但不依赖于其类型声明。但是对于捆绑类型,您将失去这个选项。一个坏的类型声明可能会让您困在旧版本的 TypeScript 上。与 DefinitelyTyped 相比,微软在开发 TypeScript 时会运行它来验证 DefinitelyTyped 上所有类型声明的正确性。问题会很快得到修复。

第二点,如果您的类型依赖于另一个库的类型声明怎么办?通常这会是一个devDependency(参见#dev-dependencies)。但如果您发布您的模块,另一个用户安装它时,他们将不会得到您的devDependencies。这会导致类型错误。另一方面,您可能也不想将其作为直接依赖,因为这样一来,您的 JavaScript 用户将无故安装@types模块。第 51 条讨论了这种情况的标准解决方法。但如果您在 DefinitelyTyped 上发布您的类型,这将不是问题:您在那里声明您的类型依赖,只有您的 TypeScript 用户会得到它。

第三,如果你需要修复旧版本库的类型声明问题,你能否回到并发布一个补丁更新?DefinitelyTyped 有机制同时维护同一库不同版本的类型声明,这在你自己的项目中可能会比较困难。

第四,你对于接受类型声明的补丁有多大的承诺?记住这一条目开始时的 react 和 @types/react 的三个版本。类型声明的补丁更新次数比库本身多三倍。DefinitelyTyped 是由社区维护的,能够处理这么大的量。特别是,如果一个库维护者在五天内没有查看补丁,将会由全局维护者来处理。你能否承诺为你的库提供类似的响应时间?

在 TypeScript 中管理依赖可能具有挑战性,但这确实带来了回报:良好编写的类型声明可以帮助你正确使用库,并极大地提高你的生产力。当你遇到依赖管理问题时,请记住这三个版本。

如果你要发布包,权衡在捆绑类型声明和在 DefinitelyTyped 上发布之间的利弊。官方建议是只有当库是用 TypeScript 编写时才捆绑类型声明。这在实践中效果很好,因为 tsc 可以使用 declaration 编译选项自动生成类型声明。对于 JavaScript 库来说,手工编写的类型声明更容易包含错误,并且它们需要更多的更新。如果你将你的类型声明发布在 DefinitelyTyped 上,社区将帮助你支持和维护它们。

需要记住的事情

  • 在 @types 依赖中涉及三个版本:库的版本、@types 的版本和 TypeScript 的版本。

  • 如果你更新了一个库,请确保你也更新了相应的 @types。

  • 了解捆绑类型和在 DefinitelyTyped 上发布之间的利弊。如果你的库是用 TypeScript 编写的,最好选择捆绑类型;如果不是,选择 DefinitelyTyped。

条目 47:导出所有出现在公共 API 中的类型

使用 TypeScript 足够长的时间,你最终会发现自己想要使用第三方模块中的 type 或 interface,却发现它们未被导出。幸运的是,TypeScript 提供了丰富的类型映射工具,作为库的用户,你几乎总能找到引用你想要的类型的方法。作为库的作者,这意味着你应该从一开始就导出你的类型。如果一个类型在函数声明中出现,那么它实际上是被导出的。因此,最好让事情显式化。

假设你想创建一些秘密的、未导出的类型:

interface SecretName {
  first: string;
  last: string;
}

interface SecretSanta {
  name: SecretName;
  gift: string;
}

export function getGift(name: SecretName, gift: string): SecretSanta {
  // ...
}

作为你的模块的用户,我无法直接导入 SecretName 或 SecretSanta,只能导入 getGift。但这并不是障碍:因为这些类型出现在导出函数签名中,我可以提取它们。一种方法是使用 Parameters 和 ReturnType 泛型类型:

type MySanta = ReturnType<typeof getGift>;  // SecretSanta
type MyName = Parameters<typeof getGift>[0];  // SecretName

如果你不导出这些类型是为了保持灵活性,那么这个计划已经失败!你已经通过将它们放在公共 API 中而承诺了它们。为了让用户方便,最好是导出它们。

要记住的事情

  • 导出任何形式出现在任何公共方法中的类型。你的用户无论如何都能提取它们,所以最好让他们更容易。

Item 48: 使用 TSDoc 编写 API 注释

这是一个生成问候语的 TypeScript 函数:

// Generate a greeting. Result is formatted for display.
function greet(name: string, title: string) {
  return `Hello ${title} ${name}`;
}

作者很好地留下了一条描述这个函数做什么的注释。但是对于打算供你的函数用户阅读的文档,最好使用 JSDoc 风格的注释:

/** Generate a greeting. Result is formatted for display. */
function greetJSDoc(name: string, title: string) {
  return `Hello ${title} ${name}`;
}

原因是编辑器几乎普遍遵循的约定是在调用函数时显示 JSDoc 风格的注释(参见 图 6-1)。

efts 06in01

图 6-1. JSDoc 风格的注释通常会在编辑器的工具提示中显示。

而内联注释则不会得到这样的待遇(参见 图 6-2)。

efts 06in02

图 6-2. 内联注释通常不会显示在工具提示中。

TypeScript 语言服务支持这种约定,你应该利用它。如果一个注释描述了一个公共 API,那么它应该是 JSDoc。在 TypeScript 的上下文中,这些注释有时被称为 TSDoc。你可以使用许多常见的约定,比如 @param 和 @returns:

/**
 * Generate a greeting.
 * @param name Name of the person to greet
 * @param salutation The person's title
 * @returns A greeting formatted for human consumption.
 */
function greetFullTSDoc(name: string, title: string) {
  return `Hello ${title} ${name}`;
}

这样编辑器就可以在你编写函数调用时显示每个参数的相关文档(如 图 6-3 所示)。

efts 06in03

图 6-3. 一个 @param 注释可以让你的编辑器在输入参数时显示当前参数的文档。

你也可以在类型定义中使用 TSDoc:

/** A measurement performed at a time and place. */
interface Measurement {
  /** Where was the measurement made? */
  position: Vector3D;
  /** When was the measurement made? In seconds since epoch. */
  time: number;
  /** Observed momentum */
  momentum: Vector3D;
}

当你检查 Measurement 对象中的各个字段时,你会得到上下文文档(参见 图 6-4)。

efts 06in04

图 6-4. 当你在编辑器中悬停在该字段上时,会显示字段的 TSDoc。

TSDoc 注释使用 Markdown 格式,因此如果你想使用粗体、斜体或项目列表,你可以使用(参见 图 6-5):

/**
 * This _interface_ has **three** properties:
 * 1\. x
 * 2\. y
 * 3\. z
 */
interface Vector3D {
  x: number;
  y: number;
  z: number;
}

efts 06in05

图 6-5. TSDoc 注释

尽量避免在你的文档中写文章,最好的注释是简短而直接的。

JSDoc 包含一些约定来指定类型信息(@param {string} name ...),但你应该避免这些,而是选择 TypeScript 类型(Item 30)。

要记住的事情

  • 使用 JSDoc-/TSDoc 格式的注释来记录导出的函数、类和类型。这有助于编辑器在最相关的时候为用户提供信息。

  • 使用 @param、@returns 和 Markdown 进行格式化。

  • 避免在文档中包含类型信息(参见 Item 30)。

条目 49:在回调中为 this 提供类型

JavaScript 的 this 关键字是语言中最令人困惑的部分之一。与使用 let 或 const 声明的变量不同,它们具有词法作用域,this 具有动态作用域:其值不取决于其 定义 方式,而取决于其 调用 方式。

this 最常用于类中,通常引用对象的当前实例:

class C {
  vals = [1, 2, 3];
  logSquares() {
    for (const val of this.vals) {
      console.log(val * val);
    }
  }
}

const c = new C();
c.logSquares();

这将记录:

1
4
9

现在看看如果你尝试将 logSquares 放在变量中并调用它会发生什么:

const c = new C();
const method = c.logSquares;
method();

此版本在运行时会抛出错误:

Uncaught TypeError: Cannot read property 'vals' of undefined

问题在于 c.logSquares() 实际上做了两件事:它调用了 C.prototype.logSquares 并且 绑定了该函数中 this 的值为 c。通过提取对 logSquares 的引用,你将它们分开了,并且 this 的值被设置为 undefined。

JavaScript 允许你完全控制 this 绑定。你可以使用 call 显式设置 this 并解决问题:

const c = new C();
const method = c.logSquares;
method.call(c);  // Logs the squares again

没有理由 this 必须绑定到 C 的一个实例。它可以绑定到任何东西。因此,库可以并且确实将 this 的值作为其 API 的一部分。即使 DOM 也利用了它。例如,在事件处理程序中:

document.querySelector('input')!.addEventListener('change', function(e) {
  console.log(this);  // Logs the input element on which the event fired.
});

this 绑定经常出现在像这样的回调上下文中。例如,如果你想在类中定义一个 onClick 处理程序,你可能会尝试这样做:

class ResetButton {
  render() {
    return makeButton({text: 'Reset', onClick: this.onClick});
  }
  onClick() {
    alert(`Reset ${this}`);
  }
}

当 Button 调用 onClick 时,它将弹出 “Reset undefined.”。糟糕!和往常一样,问题出在 this 绑定上。一个常见的解决方案是在构造函数中创建一个绑定版本的方法:

class ResetButton {
  constructor() {
    this.onClick = this.onClick.bind(this);
  }
  render() {
    return makeButton({text: 'Reset', onClick: this.onClick});
  }
  onClick() {
    alert(`Reset ${this}`);
  }
}

onClick() { ... } 定义在 ResetButton.prototype 上定义了一个属性。这个属性被所有 ResetButton 的实例共享。当你在构造函数中绑定 this.onClick = ... 时,它会在 ResetButton 的实例上创建一个名为 onClick 的属性,并且将 this 绑定到该实例。onClick 实例属性在查找顺序中位于 onClick 原型属性之前,因此 this.onClick 在 render() 方法中指向绑定的函数。

还有一个绑定的缩写形式,有时可能很方便:

class ResetButton {
  render() {
    return makeButton({text: 'Reset', onClick: this.onClick});
  }
  onClick = () => {
    alert(`Reset ${this}`);  // "this" always refers to the ResetButton instance.
  }
}

在这里,我们用箭头函数替换了 onClick。这将每次用适当值构造 ResetButton 时定义一个新的函数。看一下这段 JavaScript 代码生成的内容是很有启发性的:

class ResetButton {
  constructor() {
    var _this = this;
    this.onClick = function () {
      alert("Reset " + _this);
    };
  }
  render() {
    return makeButton({ text: 'Reset', onClick: this.onClick });
  }
}

那么 TypeScript 与此有何关系呢?因为 this 绑定是 JavaScript 的一部分,TypeScript 对其进行建模。这意味着,如果你正在编写(或键入)一个在回调中设置 this 值的库,那么你也应该对此进行建模。

你可以通过在回调中添加一个 this 参数来实现这一点:

function addKeyListener(
  el: HTMLElement,
  fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
  el.addEventListener('keydown', e => {
    fn.call(el, e);
  });
}

this 参数是特殊的:它不只是另一个位置参数。如果尝试使用两个参数调用它,可以看到这一点:

function addKeyListener(
  el: HTMLElement,
  fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
  el.addEventListener('keydown', e => {
    fn(el, e);
        // ~ Expected 1 arguments, but got 2
  });
}

更好的是,TypeScript 将强制你以正确的this上下文调用该函数:

function addKeyListener(
  el: HTMLElement,
  fn: (this: HTMLElement, e: KeyboardEvent) => void
) {
  el.addEventListener('keydown', e => {
    fn(e);
 // ~~~~~ The 'this' context of type 'void' is not assignable
 //       to method's 'this' of type 'HTMLElement'
  });
}

作为此函数的用户,你可以在回调中引用this,并获得完全的类型安全性:

declare let el: HTMLElement;
addKeyListener(el, function(e) {
  this.innerHTML;  // OK, "this" has type of HTMLElement
});

当然,如果你在这里使用箭头函数,你会覆盖this的值。TypeScript 将捕捉到这个问题:

class Foo {
  registerHandler(el: HTMLElement) {
    addKeyListener(el, e => {
      this.innerHTML;
        // ~~~~~~~~~ Property 'innerHTML' does not exist on type 'Foo'
    });
  }
}

不要忘记this!如果你在回调函数中设置了this的值,那么它就成为了你的 API 的一部分,你应该在类型声明中包含它。

需要记住的事情

  • 理解this绑定的工作原理。

  • 当它作为你的 API 的一部分时,为回调函数中的this提供类型。

条目 50:更喜欢条件类型而不是重载声明

你会如何为这个 JavaScript 函数编写类型声明?

function double(x) {
  return x + x;
}

double可以传入string或number。因此,你可能会使用一个联合类型:

function double(x: number|string): number|string;
function double(x: any) { return x + x; }

(这些示例都利用了 TypeScript 的函数重载概念。如果需要回顾,请参见条目 3。)

虽然这个声明是准确的,但有点不太精确:

const num = double(12);  // string | number
const str = double('x');  // string | number

当double传入一个number时,它返回一个number。当传入一个string时,它返回一个string。这个声明忽略了这种细微差别,导致类型处理起来很困难。

你可以尝试使用泛型来捕捉这种关系:

function double<T extends number|string>(x: T): T;
function double(x: any) { return x + x; }

const num = double(12);  // Type is 12
const str = double('x');  // Type is "x"

不幸的是,在我们追求精确性的过程中,我们有些过火了。这些类型现在有点过于精确了。当传入一个string类型时,这个double声明将导致一个string类型,这是正确的。但是当传入一个字符串字面量类型时,返回类型却是相同的字符串字面量类型。这是错误的:将'x'翻倍得到的是'xx',而不是'x'。

另一个选项是提供多个类型声明。虽然 TypeScript 只允许你编写一个函数的实现,但允许你编写任意数量的类型声明。你可以利用这一点来改进double的类型:

function double(x: number): number;
function double(x: string): string;
function double(x: any) { return x + x; }

const num = double(12);  // Type is number
const str = double('x');  // Type is string

这是进步!但是这个声明是否正确?不幸的是,仍然存在一个细微的 bug。这个类型声明将适用于值是string或number的情况,但不适用于可能是两者之一的值:

function f(x: number|string) {
  return double(x);
             // ~ Argument of type 'string | number' is not assignable
             //   to parameter of type 'string'
}

这次对double的调用是安全的,并且应该返回string|number。当你重载类型声明时,TypeScript 会逐个处理直到找到匹配项。你看到的错误是最后一个重载(string版本)失败,因为string|number不能赋值给string。

虽然你可以通过添加第三个string|number重载来解决这个问题,但最好的解决方案是使用条件类型。条件类型在类型空间中类似于 if 语句(条件语句)。它们非常适合像这种需要涵盖几种可能性的情况:

function double<T extends number | string>(
  x: T
): T extends string ? string : number;
function double(x: any) { return x + x; }

这与使用泛型首次尝试为double编写类型类似,但返回类型更为复杂。你可以像在 JavaScript 中读取三元(?:)操作符一样读取条件类型:

  • 如果 T 是 string 的子集(例如,string 或者字符串字面量或字符串字面量的联合),那么返回类型是 string。

  • 否则返回 number。

使用这个声明,我们所有的例子都可以工作:

const num = double(12);  // number
const str = double('x');  // string

// function f(x: string | number): string | number
function f(x: number|string) {
  return double(x);
}

number|string 的例子之所以有效,是因为条件类型可以在联合中分布。当 T 是 number|string 时,TypeScript 解析条件类型如下:

   (number|string) extends string ? string : number
-> (number extends string ? string : number) |
   (string extends string ? string : number)
-> number | string

使用重载进行类型声明虽然更容易编写,但使用条件类型版本更加正确,因为它泛化到各个单独情况的联合。对于重载,这种情况经常发生。而重载是独立处理的,类型检查器可以将条件类型作为单个表达式分析,并在联合中分发它们。如果你发现自己正在编写重载的类型声明,考虑是否可以使用条件类型更好地表达它。

要记住的事情

  • 更倾向于使用条件类型而不是重载类型声明。通过在联合中分布,条件类型允许你的声明支持联合类型而无需额外的重载。

条目 51:镜像类型以减少依赖关系

假设你编写了一个用于解析 CSV 文件的库。其 API 很简单:你传入 CSV 文件的内容,然后得到一个将列名映射到值的对象列表。作为方便,你允许内容既可以是一个 string,也可以是一个 NodeJS 的 Buffer:

function parseCSV(contents: string | Buffer): {[column: string]: string}[]  {
  if (typeof contents === 'object') {
    // It's a buffer
    return parseCSV(contents.toString('utf8'));
  }
  // ...
}

Buffer 的类型定义来自于 NodeJS 的类型声明,你必须安装它:

npm install --save-dev @types/node

当你发布你的 CSV 解析库时,你需要将类型声明与之一同发布。由于你的类型声明依赖于 NodeJS 的类型,因此将这些声明作为 devDependency 包含在内(条目 45)。如果这样做,你可能会从两组用户那里得到抱怨:

  • 对 JavaScript 开发者来说,他们想知道他们依赖的这些 @types 模块是什么。

  • 对 TypeScript 的 Web 开发者来说,他们想知道为什么他们要依赖于 NodeJS。

这些抱怨是合理的。Buffer 的行为并不重要,只对已经使用 NodeJS 的用户相关。而 @types/node 中的声明只对同时使用 TypeScript 和 NodeJS 的用户相关。

TypeScript 的结构化类型(条目 4)可以帮助你摆脱困境。不要使用来自 @types/node 的 Buffer 声明,你可以只编写自己需要的方法和属性的声明。在这种情况下,只需要一个接受编码的 toString 方法:

interface CsvBuffer {
  toString(encoding: string): string;
}
function parseCSV(contents: string | CsvBuffer): {[column: string]: string}[]  {
  // ...
}

这个接口比完整接口要短得多,但它确实捕捉了我们对 Buffer 的(简单)需求。在 NodeJS 项目中,使用真实的 Buffer 调用 parseCSV 仍然可以,因为类型是兼容的:

parseCSV(new Buffer("column1,column2\nval1,val2", "utf-8"));  // OK

如果你的库只依赖于另一个库的类型而不依赖于其实现,请考虑将你所需的声明镜像到你自己的代码中。这将为你的 TypeScript 用户提供类似的体验,并为其他用户提供改进的体验。

如果你依赖于一个库的实现,可能仍然可以应用相同的技巧来避免依赖于其类型。但随着依赖的增长和变得更加重要,这变得越来越困难。如果你复制了另一个库的大部分类型声明,可能需要通过显式地声明 @types 依赖来正式化这种关系。

此技术对于割裂单元测试与生产系统之间的依赖关系也很有帮助。请参阅 Item 4 中的 getAuthors 示例。

记住的事情

  • 使用结构类型来割裂非必要的依赖关系。

  • 不要强迫 JavaScript 用户依赖于 @types。不要强迫 Web 开发者依赖于 NodeJS。

项目 52:注意测试类型的陷阱

你不会发布没有写测试的代码(希望如此!),你也不应该发布没有为其写类型声明测试的代码。但是如何测试类型呢?如果你在编写类型声明,测试是一项必不可少但令人惊讶的艰巨工作。诱人的是使用 TypeScript 提供的工具在类型系统内部对类型进行断言。但这种方法存在几个陷阱。最终,更安全、更直接的方法是使用 dtslint 或类似的工具,从类型系统外部检查类型。

假设你已经为实用库提供的 map 函数编写了类型声明(流行的 Lodash 和 Underscore 库都提供了这样的函数):

declare function map<U, V>(array: U[], fn: (u: U) => V): V[];

如何检查此类型声明是否产生了预期的类型?(假设实现有单独的测试。)一个常见的技术是编写一个调用该函数的测试文件:

map(['2017', '2018', '2019'], v => Number(v));

这将进行一些粗糙的错误检查:如果你的 map 声明只列出了一个参数,这将捕获错误。但你觉得这里有些什么遗漏了吗?

用于运行时行为的此类测试的等效物可能看起来像这样:

test('square a number', () => {
  square(1);
  square(2);
});

当然,这会测试 square 函数不会抛出错误。但它没有检查返回值,因此无法真正测试其行为。square 的错误实现仍然会通过这个测试。

这种方法在测试类型声明文件中很常见,因为可以简单地复制现有库的单元测试。虽然这确实提供了一些价值,但实际上检查一些类型会更好!

一种方法是将结果分配给具有特定类型的变量:

const lengths: number[] = map(['john', 'paul'], name => name.length);

这正是 项目 19 鼓励您移除的多余类型声明。但在这里,它发挥了关键作用:它确保 map 声明至少对类型进行了一些合理的操作。确实,您可以在 DefinitelyTyped 中找到许多使用这种方法进行测试的类型声明。但是,正如我们将看到的那样,使用赋值进行测试存在一些根本性问题。

其中之一是,您必须创建一个可能未使用的命名变量。这增加了样板,但也意味着您必须禁用某些形式的 linting。

一个常见的解决方法是定义一个辅助函数:

function assertType<T>(x: T) {}

assertType<number[]>(map(['john', 'paul'], name => name.length));

这消除了未使用变量的问题,但仍然存在一些意外情况。

第二个问题是,我们检查的是两种类型的可赋性而不是相等性。通常情况下,这样做的效果如您所期望的那样。例如:

const n = 12;
assertType<number>(n);  // OK

如果您检查 n 符号,您会看到它的类型实际上是 12,一个数字文字类型。这是 number 的子类型,因此可赋性检查会通过,正如您所期望的那样。

到目前为止一切顺利。但是当您开始检查对象的类型时,情况就变得更加复杂了:

const beatles = ['john', 'paul', 'george', 'ringo'];
assertType<{name: string}[]>(
  map(beatles, name => ({
    name,
    inYellowSubmarine: name === 'ringo'
  })));  // OK

map 调用返回一个数组,其中包含 {name: string, inYellowSubmarine: boolean} 对象。这可以赋值给 {name: string}[],当然可以,但是我们是否应该被迫承认黄色潜艇?根据上下文,您可能确实希望检查类型的相等性。

如果您的函数返回另一个函数,您可能会对可赋性的判断感到惊讶:

const add = (a: number, b: number) => a + b;
assertType<(a: number, b: number) => number>(add);  // OK

const double = (x: number) => 2 * x;
assertType<(a: number, b: number) => number>(double);  // OK!?

您是否对第二个断言成功感到惊讶?原因是 TypeScript 中的函数可以赋值给参数较少的函数类型:

const g: (x: string) => any = () => 12;  // OK

这反映了调用 JavaScript 函数时使用更多参数是完全可以的事实。 TypeScript 选择模拟此行为而不是禁止它,主要是因为它在回调函数中普遍存在。例如,Lodash map 函数中的回调最多接受三个参数:

map(array, (name, index, array) => { /* ... */ });

尽管所有三者都可用,但非常常见的情况是仅使用一个或两个,就像我们在本条目中到目前为止所做的那样。事实上,很少同时使用所有三者。通过禁止这种赋值,TypeScript 将在大量的 JavaScript 代码中报告错误。

那么您能做什么呢?您可以拆分函数类型,并使用通用的 Parameters 和 ReturnType 类型测试其各个部分:

const double = (x: number) => 2 * x;
let p: Parameters<typeof double> = null!;
assertType<[number, number]>(p);
//                           ~ Argument of type '[number]' is not
//                             assignable to parameter of type [number, number]
let r: ReturnType<typeof double> = null!;
assertType<number>(r);  // OK

但如果“这”还不够复杂,那就有另一个问题:map 设置了其回调的 this 值。 TypeScript 可以模拟此行为(请参见 项目 49),因此您的类型声明应该这样做。并且您应该进行测试。我们怎么做呢?

到目前为止,我们对map的测试都有点黑盒风格:我们通过map运行了一个数组和函数,并测试了结果的类型,但我们没有测试中间步骤的细节。我们可以通过填写回调函数并直接验证其参数和this的类型来做到这一点:

const beatles = ['john', 'paul', 'george', 'ringo'];
assertType<number[]>(map(
  beatles,
  function(name, i, array) {
// ~~~~~~~ Argument of type '(name: any, i: any, array: any) => any' is
//         not assignable to parameter of type '(u: string) => any'
    assertType<string>(name);
    assertType<number>(i);
    assertType<string[]>(array);
    assertType<string[]>(this);
                      // ~~~~ 'this' implicitly has type 'any'
    return name.length;
  }
));

这揭示了我们对map声明的一些问题。请注意使用非箭头函数,以便我们可以测试this的类型。

这是一个通过检查的声明:

declare function map<U, V>(
  array: U[],
  fn: (this: U[], u: U, i: number, array: U[]) => V
): V[];

但还有一个最后的问题,它是一个重大问题。这是我们模块的一个完整类型声明文件,即使经过最严格的map测试也是一无是处:

declare module 'overbar';

这会将any类型分配给整个模块。您的所有测试都将通过,但您将没有任何类型安全性。更糟糕的是,该模块中每个函数的调用都会悄悄产生any类型,从而在您的代码中传播破坏类型安全性。即使使用了noImplicitAny,您仍然可以通过类型声明获得any类型。

除非使用一些高级技巧,否则很难从类型系统内部检测到any类型。这就是为什么测试类型声明的首选方法是使用在类型检查器外部操作的工具。

对于 DefinitelyTyped 仓库中的类型声明,这个工具是dtslint。它通过特殊格式的注释来操作。以下是您如何使用dtslint为map函数编写最后一个测试的方法:

const beatles = ['john', 'paul', 'george', 'ringo'];
map(beatles, function(
  name,  // $ExpectType string
  i,     // $ExpectType number
  array  // $ExpectType string[]
) {
  this   // $ExpectType string[]
  return name.length;
});  // $ExpectType number[]

与其检查可分配性,dtslint检查每个符号的类型并进行文本比较。这与您在编辑器中手动测试类型声明的方式匹配:dtslint本质上自动化了这个过程。这种方法确实有一些缺点:number|string和string|number在文本上是不同的,但类型相同。但是string和any也是如此,尽管它们可以相互分配,这确实是重点。

测试类型声明是一件棘手的事情。你应该测试它们。但要注意一些常见技术的缺陷,并考虑使用像dtslint这样的工具来避免它们。

要记住的事情

  • 在测试类型时,要注意等式和可分配性之间的差异,特别是对于函数类型。

  • 对于使用回调的函数,请测试回调参数的推断类型。如果它是您 API 的一部分,请不要忘记测试this的类型。

  • 在涉及类型的测试中要谨慎使用any。考虑使用像dtslint这样的工具进行更严格、更少出错的检查。

第七章:编写和运行您的代码

本章内容有点杂乱:涵盖了编写代码时(而不是类型)出现的一些问题,以及在运行代码时可能遇到的问题。

项目 53:更喜欢 ECMAScript 功能而不是 TypeScript 功能

随着时间的推移,TypeScript 和 JavaScript 之间的关系发生了变化。当微软于 2010 年开始开发 TypeScript 时,围绕 JavaScript 的普遍态度是它是一个有问题的语言,需要修复。常见的框架和源到源编译器通常会向 JavaScript 添加缺失的功能,如类、装饰器和模块系统。TypeScript 也不例外。早期版本包括自制的类、枚举和模块。

随着时间的推移,管理 JavaScript 的标准机构 TC39 向核心 JavaScript 语言添加了许多相同的功能。它们添加的功能与 TypeScript 现有版本不兼容。这使得 TypeScript 团队陷入尴尬的境地:是采纳标准中的新功能还是打破现有代码?

TypeScript 在很大程度上选择了后者,并最终阐明了其当前的统治原则:TC39 定义运行时,而 TypeScript 仅在类型空间中创新。

还有一些在这一决定之前存在的功能。重要的是要认识和理解这些,因为它们与语言的其余部分的关系不符。总体上,我建议避免它们,以尽可能保持 TypeScript 与 JavaScript 之间的关系清晰。

枚举

许多语言通过枚举或enums模型化可以取少量值的类型。TypeScript 将它们添加到了 JavaScript 中:

enum Flavor {
  VANILLA = 0,
  CHOCOLATE = 1,
  STRAWBERRY = 2,
}

let flavor = Flavor.CHOCOLATE;  // Type is Flavor

Flavor  // Autocomplete shows: VANILLA, CHOCOLATE, STRAWBERRY
Flavor[0]  // Value is "VANILLA"

枚举的论点是它们比裸数字提供更多的安全性和透明性。但 TypeScript 中的枚举有一些怪癖。实际上,有几种枚举的变体,它们的行为都有微妙的不同:

  • 数值枚举(如Flavor)。任何数字都可以赋值给它,因此不太安全(设计成这样是为了实现位标志结构的可能性)。

  • 字符串枚举。这提供了类型安全性,同时在运行时也提供了更透明的值。但它不是结构类型化的,不像 TypeScript 中的其他类型(稍后会详细说明)。

  • const enum。与常规枚举不同,常量枚举在运行时完全消失。如果在上一个示例中改为const enum Flavor,编译器会将Flavor.CHOCOLATE重写为0。这也打破了我们对编译器行为的预期,同时还存在string和number值枚举之间的分歧行为。

  • 设置了preserveConstEnums标志的const enum。这会像常规枚举一样生成运行时代码。

字符串值枚举是名义上类型化的,这一点特别令人惊讶,因为 TypeScript 中的每一种类型都使用结构化类型来进行赋值(参见项目 4):

enum Flavor {
  VANILLA = 'vanilla',
  CHOCOLATE = 'chocolate',
  STRAWBERRY = 'strawberry',
}

let flavor = Flavor.CHOCOLATE;  // Type is Flavor
    flavor = 'strawberry';
 // ~~~~~~ Type '"strawberry"' is not assignable to type 'Flavor'

这在您发布库时有一些影响。假设您有一个接受Flavor的函数:

function scoop(flavor: Flavor) { /* ... */ }

因为运行时的Flavor实际上只是一个字符串,因此对于您的 JavaScript 用户来说,使用一个是可以的:

scoop('vanilla');  // OK in JavaScript

但是您的 TypeScript 用户需要导入enum并使用它代替:

scoop('vanilla');
   // ~~~~~~~~~ '"vanilla"' is not assignable to parameter of type 'Flavor'

import {Flavor} from 'ice-cream';
scoop(Flavor.VANILLA);  // OK

对于 JavaScript 和 TypeScript 用户的这些不同体验是避免使用以字符串值为基础的枚举的原因。

TypeScript 提供了一个在其他语言中不太常见的枚举的替代方案:字面类型的联合。

type Flavor = 'vanilla' | 'chocolate' | 'strawberry';

let flavor: Flavor = 'chocolate';  // OK
    flavor = 'mint chip';
 // ~~~~~~ Type '"mint chip"' is not assignable to type 'Flavor'

这提供了与枚举一样多的安全性,并且直接转换为 JavaScript 的能力。在编辑器中,它也提供了类似的强大的自动完成功能:

function scoop(flavor: Flavor) {
  if (flavor === 'v
 // Autocomplete here suggests 'vanilla'
}

要了解更多此方法,请参见第 33 项。

参数属性

在初始化类时,将属性分配给构造函数参数是很常见的:

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

TypeScript 为此提供了更紧凑的语法:

class Person {
  constructor(public name: string) {}
}

这被称为“参数属性”,它等价于第一个示例中的代码。使用参数属性时需要注意一些问题:

  • 它们是少数在编译到 JavaScript 时会生成代码的结构之一(enum是另一个)。通常情况下,编译只涉及消除类型。

  • 因为参数仅在生成的代码中使用,因此源代码看起来好像有未使用的参数。

  • 使用参数和非参数属性的混合可能会隐藏您的类的设计。

例如:

class Person {
  first: string;
  last: string;
  constructor(public name: string) {
    [this.first, this.last] = name.split(' ');
  }
}

此类具有三个属性(first,last,name),但是仅在构造函数之前列出两个属性使得代码难以阅读。如果构造函数还接受其他参数,则情况会更糟。

如果您的类仅包含参数属性而没有方法,您可能考虑将其制作为一个interface并使用对象字面量。请记住,由于结构类型,两者可以互相赋值第 4 项:

class Person {
  constructor(public name: string) {}
}
const p: Person = {name: 'Jed Bartlet'};  // OK

参数属性的看法不一。虽然我通常避免使用它们,但其他人欣赏所节省的按键。请注意,它们不符合 TypeScript 其余部分的模式,并且实际上可能会使新开发者难以理解。尝试避免通过混合使用参数和非参数属性来隐藏类的设计。

命名空间和三斜线导入

在 ECMAScript 2015 之前,JavaScript 没有官方的模块系统。不同的环境以不同的方式添加了这一缺失功能:Node.js 使用require和module.exports,而 AMD 使用一个带有回调的define函数。

TypeScript 还通过自己的模块系统填补了这一空白。这是使用module关键字和“三斜线”导入完成的。在 ECMAScript 2015 添加了官方模块系统之后,TypeScript 添加了namespace作为module的同义词,以避免混淆:

namespace foo {
  function bar() {}
}
/// <reference path="other.ts"/>
foo.bar();

除了类型声明外,三斜线导入和module关键字只是历史上的一点奇闻。在你自己的代码中,你应该使用 ECMASCript 2015 风格的模块(import 和 export)。参见 Item 58。

装饰器

装饰器可以用来注释或修改类、方法和属性。例如,你可以定义一个logged注解,记录类中方法的所有调用:

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  @logged
  greet() {
    return "Hello, " + this.greeting;
  }
}

function logged(target: any, name: string, descriptor: PropertyDescriptor) {
  const fn = target[name];
  descriptor.value = function() {
    console.log(`Calling ${name}`);
    return fn.apply(this, arguments);
  };
}

console.log(new Greeter('Dave').greet());
// Logs:
// Calling greet
// Hello, Dave

这个功能最初是为了支持 Angular 框架而添加的,需要在tsconfig.json中设置experimentalDecorators属性。在我写作这篇文章时,它们的实现尚未被 TC39 标准化,因此,今天使用装饰器的任何代码都可能会中断或变得不标准。除非你使用 Angular 或其他需要注解的框架,并且在它们标准化之前,不要使用 TypeScript 的装饰器。

需要记住的事情

  • 大体上来说,你可以通过从代码中移除所有类型来将 TypeScript 转换为 JavaScript。

  • 枚举、参数属性、三斜线导入和装饰器是历史上的例外。

  • 为了尽可能清晰地保持 TypeScript 在你的代码库中的角色,我建议避免使用这些功能。

项目 54:了解如何迭代对象

这段代码运行良好,但 TypeScript 却在其中标记了一个错误。为什么呢?

const obj = {
  one: 'uno',
  two: 'dos',
  three: 'tres',
};
for (const k in obj) {
  const v = obj[k];
         // ~~~~~~ Element implicitly has an 'any' type
         //        because type ... has no index signature
}

检查obj和k这两个符号可以给出一个线索:

const obj = { /* ... */ };
// const obj: {
//     one: string;
//     two: string;
//     three: string;
// }
for (const k in obj) {  // const k: string
  // ...
}

k的类型是string,但是你试图索引到一个对象中,该对象的类型只有三个特定的键:'one'、'two'和'three'。除了这三个字符串,还有其他字符串,所以这是会失败的。

插入一个更窄的类型声明到k中可以修复这个问题:

let k: keyof typeof obj;  // Type is "one" | "two" | "three"
for (k in obj) {
  const v = obj[k];  // OK
}

所以真正的问题是:为什么第一个例子中k的类型被推断为string而不是"one" | "two" | "three"?

为了理解,让我们看一个稍微不同的例子,涉及接口和函数:

interface ABC {
  a: string;
  b: string;
  c: number;
}

function foo(abc: ABC) {
  for (const k in abc) {  // const k: string
    const v = abc[k];
           // ~~~~~~ Element implicitly has an 'any' type
           //        because type 'ABC' has no index signature
  }
}

这与之前的错误相同。你可以使用相同类型的声明来“修复”它(let k: keyof ABC)。但在这种情况下,TypeScript 是对的。为什么呢?

const x = {a: 'a', b: 'b', c: 2, d: new Date()};
foo(x);  // OK

函数foo可以被调用以赋值给ABC的任何值,而不仅仅是具有“a”、“b”和“c”属性的值。这个值完全可能有其他属性(参见 Item 4)。为了允许这种情况,TypeScript 给k赋予了它能确信的唯一类型,即string。

在这里使用keyof声明会有另一个缺点:

function foo(abc: ABC) {
  let k: keyof ABC;
  for (k in abc) {  // let k: "a" | "b" | "c"
    const v = abc[k];  // Type is string | number
  }
}

如果"a" | "b" | "c"对于k来说太窄,那么string | number对于v来说肯定也太窄。在前面的例子中,其中一个值是Date,但它可以是任何东西。这些类型在这里给出了一种错误的确定性感,可能会在运行时导致混乱。

那么如果你只想在不出现类型错误的情况下遍历对象的键和值呢?Object.entries允许你同时遍历这两者:

function foo(abc: ABC) {
  for (const [k, v] of Object.entries(abc)) {
    k  // Type is string
    v  // Type is any
  }
}

虽然这些类型可能很难处理,但至少它们是诚实的!

你还应该注意原型污染的可能性。即使在你定义的对象文字中,for-in 也可能产生额外的键:

> Object.prototype.z = 3; // Please don't do this!
> const obj = {x: 1, y: 2};
> for (const k in obj) { console.log(k); }
x
y
z

希望这种情况不会发生在非对抗环境中(你绝不应该向Object.prototype添加可枚举属性),但这是 for-in 即使对于对象文字也产生字符串键的另一个原因。

如果你想迭代对象的键和值,请使用keyof声明(let k: keyof T)或Object.entries。前者适用于常量或其他你知道对象不会有额外键并且需要精确类型的情况。后者更普遍适用,尽管键和值类型更难处理。

事项记忆

  • 当你确切知道键是什么时,使用let k: keyof T和 for-in 循环来迭代对象。请注意,你的函数接收的任何对象参数可能有额外的键。

  • 使用Object.entries来迭代任何对象的键和值。

条目 55:理解 DOM 层次结构

本书中大多数项目都不关心在哪里运行你的 TypeScript:在浏览器中、服务器上、手机上。但这一章不同。如果你不在浏览器中工作,请跳过!

当你在 Web 浏览器中运行 JavaScript 时,DOM 层次结构始终存在。当你使用document.getElementById获取一个元素或者使用document.createElement创建一个元素时,它始终是特定类型的元素,即使你并不完全熟悉其分类。你调用方法并使用你想要的属性,然后希望一切顺利。

使用 TypeScript,DOM 元素的层次结构变得更加可见。了解你的Node、Element和EventTarget有助于你调试类型错误,并决定何时使用类型断言。因为很多 API 都基于 DOM,即使你使用像 React 或 d3 这样的框架,这一点也是相关的。

假设你想在用户拖动鼠标穿过<div>时跟踪其鼠标移动。你写了一些看似无害的 JavaScript 代码:

function handleDrag(eDown: Event) {
  const targetEl = eDown.currentTarget;
  targetEl.classList.add('dragging');
  const dragStart = [eDown.clientX, eDown.clientY];
  const handleUp = (eUp: Event) => {
    targetEl.classList.remove('dragging');
    targetEl.removeEventListener('mouseup', handleUp);
    const dragEnd = [eUp.clientX, eUp.clientY];
    console.log('dx, dy = ', [0, 1].map(i => dragEnd[i] - dragStart[i]));
  }
  targetEl.addEventListener('mouseup', handleUp);
}
const div = document.getElementById('surface');
div.addEventListener('mousedown', handleDrag);

TypeScript 的类型检查器在这 14 行代码中标记了不少于 11 个错误:

function handleDrag(eDown: Event) {
  const targetEl = eDown.currentTarget;
  targetEl.classList.add('dragging');
// ~~~~~~~           Object is possibly 'null'.
//         ~~~~~~~~~ Property 'classList' does not exist on type 'EventTarget'
  const dragStart = [
     eDown.clientX, eDown.clientY];
        // ~~~~~~~                Property 'clientX' does not exist on 'Event'
        //                ~~~~~~~ Property 'clientY' does not exist on 'Event'
  const handleUp = (eUp: Event) => {
    targetEl.classList.remove('dragging');
//  ~~~~~~~~           Object is possibly 'null'.
//           ~~~~~~~~~ Property 'classList' does not exist on type 'EventTarget'
    targetEl.removeEventListener('mouseup', handleUp);
//  ~~~~~~~~ Object is possibly 'null'
    const dragEnd = [
       eUp.clientX, eUp.clientY];
        // ~~~~~~~                Property 'clientX' does not exist on 'Event'
        //              ~~~~~~~   Property 'clientY' does not exist on 'Event'
    console.log('dx, dy = ', [0, 1].map(i => dragEnd[i] - dragStart[i]));
  }
  targetEl.addEventListener('mouseup', handleUp);
// ~~~~~~~ Object is possibly 'null'
}

   const div = document.getElementById('surface');
   div.addEventListener('mousedown', handleDrag);
// ~~~ Object is possibly 'null'

发生了什么?这个EventTarget是什么?为什么一切都可能是null?

要理解EventTarget错误,需要深入了解 DOM 层次结构。这里是一些 HTML 代码:

<p id="quote">and <i>yet</i> it moves</p>

如果你打开浏览器的 JavaScript 控制台并获取到p元素的引用,你会发现它是一个HTMLParagraphElement:

const p = document.getElementsByTagName('p')[0];
p instanceof HTMLParagraphElement
// True

HTMLParagraphElement是HTMLElement的子类型,后者是Element的子类型,后者是Node的子类型,后者是EventTarget的子类型。以下是层次结构中一些类型的示例:

表 7-1. DOM 层次结构中的类型

类型 示例
EventTarget window, XMLHttpRequest
Node document, Text, Comment
Element 包括 HTMLElements, SVGElements
HTMLElement <i>, <b>
HTMLButtonElement <button>

EventTarget 是 DOM 类型中最通用的类型。你可以做的所有操作只有添加事件监听器、移除它们和分发事件。考虑到这一点,classList 的错误开始变得更加有意义:

function handleDrag(eDown: Event) {
  const targetEl = eDown.currentTarget;
  targetEl.classList.add('dragging');
// ~~~~~~~           Object is possibly 'null'
//         ~~~~~~~~~ Property 'classList' does not exist on type 'EventTarget'
  // ...
}

如其名称所示,Event 的 currentTarget 属性是一个 EventTarget。它甚至可能是 null。TypeScript 没有理由认为它具有 classList 属性。虽然 EventTarget 在实践中 可能 是 HTMLElement,但从类型系统的角度来看,它同样可能是 window 或 XMLHTTPRequest。

向上移动层次,我们来到 Node。一些不是 Element 的 Node 的例子是文本片段和注释。例如,在这个 HTML 中:

<p>
  And <i>yet</i> it moves
  <!-- quote from Galileo -->
</p>

最外层的元素是一个 HTMLParagraphElement。如你在这里所见,它具有 children 和 childNodes:

> p.children
HTMLCollection [i]
> p.childNodes
NodeList(5) [text, i, text, comment, text]

children 返回一个 HTMLCollection,一个类似数组的结构,仅包含子 Element(<i>yet</i>)。childNodes 返回一个 NodeList,一个类似数组的集合,包含不仅仅是 Element(<i>yet</i>),还有文本片段(“And,” “it moves”)和注释(“quote from Galileo”)。

Element 和 HTMLElement 有什么区别?包括整个 SVG 标签层次结构的非 HTML Element。这些是 SVGElement,另一种 Element 类型。<html> 或 <svg> 标签的类型是什么?它们分别是 HTMLHtmlElement 和 SVGSvgElement。

有时,这些特殊的类会有自己的属性,例如,HTMLImageElement 有一个 src 属性,而 HTMLInputElement 有一个 value 属性。如果你想从值中读取其中一个属性,它的类型必须足够具体,才能具有该属性。

TypeScript 对 DOM 的类型声明广泛使用字面类型,以尽可能获取最具体的类型。例如:

document.getElementsByTagName('p')[0];  // HTMLParagraphElement
document.createElement('button');  // HTMLButtonElement
document.querySelector('div');  // HTMLDivElement

但这并不总是可能的,特别是使用 document.getElementById 时:

document.getElementById('my-div');  // HTMLElement

尽管一般不建议使用类型断言(《Item 9》),但这是一种你比 TypeScript 更了解的情况,因此它们是合适的。只要你知道 #my-div 是一个 div,这没有问题:

document.getElementById('my-div') as HTMLDivElement;

启用了 strictNullChecks 后,你需要考虑 document.getElementById 返回 null 的情况。根据实际情况,你可以添加一个 if 语句或一个断言 (!):

const div = document.getElementById('my-div')!;

这些类型并不特定于 TypeScript。相反,它们是从 DOM 的正式规范生成的。这是《Item 35》建议的一个例子,尽可能地从规范生成类型。

DOM 层次结构就是这样。那么 clientX 和 clientY 的错误呢?

function handleDrag(eDown: Event) {
  // ...
  const dragStart = [
     eDown.clientX, eDown.clientY];
        // ~~~~~~~                Property 'clientX' does not exist on 'Event'
        //                ~~~~~~~ Property 'clientY' does not exist on 'Event'
  // ...
}

除了 Node 和 Element 的层次结构之外,还有 Event 的层次结构。Mozilla 文档目前列出了不少于 52 种 Event 类型!

普通的 Event 是最通用的事件类型。更具体的类型包括:

UIEvent

任何用户界面事件

MouseEvent

鼠标点击等由鼠标触发的事件

TouchEvent

移动设备上的触摸事件

WheelEvent

由滚动滚轮触发的事件

KeyboardEvent

按键按下

在handleDrag中的问题是事件声明为Event,而clientX和clientY仅存在于更具体的MouseEvent类型中。

那么你应该如何修复本条款开头的示例呢?TypeScript 对 DOM 的声明广泛使用上下文(条款 26)。内联 mousedown 处理程序为 TypeScript 提供了更多信息,并消除了大部分错误。你还可以声明参数类型为MouseEvent而不是Event。以下是使用这两种技术修复错误的版本:

function addDragHandler(el: HTMLElement) {
  el.addEventListener('mousedown', eDown => {
    const dragStart = [eDown.clientX, eDown.clientY];
    const handleUp = (eUp: MouseEvent) => {
      el.classList.remove('dragging');
      el.removeEventListener('mouseup', handleUp);
      const dragEnd = [eUp.clientX, eUp.clientY];
      console.log('dx, dy = ', [0, 1].map(i => dragEnd[i] - dragStart[i]));
    }
    el.addEventListener('mouseup', handleUp);
  });
}

const div = document.getElementById('surface');
if (div) {
  addDragHandler(div);
}

结尾的if语句处理了可能没有#surface元素的情况。如果你知道这个元素存在,可以使用断言代替(div!)。addDragHandler需要一个非空的HTMLElement,所以这是将null值推到边界的示例(条款 31)。

要记住的事情

  • DOM 具有一种类型层次结构,在编写 JavaScript 时通常可以忽略。但在 TypeScript 中,这些类型变得更加重要。理解它们将帮助你为浏览器编写 TypeScript。

  • 了解Node、Element、HTMLElement和EventTarget之间的区别,以及Event和MouseEvent之间的区别。

  • 在你的代码中要么使用足够具体的类型来表示 DOM 元素和事件,要么给 TypeScript 提供推断的上下文。

条款 56:不要依赖私有属性来隐藏信息

JavaScript 在历史上缺乏一种使类的属性私有化的方法。通常的解决方法是采用一种约定,即用下划线作为不属于公共 API 部分的字段的前缀:

class Foo {
  _private = 'secret123';
}

但这只会阻止用户访问私有数据。它很容易绕过:

const f = new Foo();
f._private;  // 'secret123'

TypeScript 添加了public、protected和private字段修饰符,似乎提供了一些强制执行:

class Diary {
  private secret = 'cheated on my English test';
}

const diary = new Diary();
diary.secret
   // ~~~~~~ Property 'secret' is private and only
   //        accessible within class 'Diary'

但private是类型系统的一个特性,就像类型系统的所有特性一样,在运行时消失了(见条款 3)。当 TypeScript 将其编译为 JavaScript(目标为 ES2017)时,这段代码片段看起来是这样的:

class Diary {
  constructor() {
    this.secret = 'cheated on my English test';
  }
}
const diary = new Diary();
diary.secret;

private指示器已经消失了,你的秘密暴露了!就像_private约定一样,TypeScript 的访问修饰符只是阻止你访问私有数据。通过类型断言,你甚至可以从 TypeScript 内部访问私有属性:

class Diary {
  private secret = 'cheated on my English test';
}

const diary = new Diary();
(diary as any).secret  // OK

换句话说,不要依赖private来隐藏信息!

那么如果你想要更可靠的东西该怎么办呢?传统的答案是利用 JavaScript 中最可靠的隐藏信息的方式之一:闭包。你可以在构造函数中创建一个:

declare function hash(text: string): number;

class PasswordChecker {
  checkPassword: (password: string) => boolean;
  constructor(passwordHash: number) {
    this.checkPassword = (password: string) => {
      return hash(password) === passwordHash;
    }
  }
}

const checker = new PasswordChecker(hash('s3cret'));
checker.checkPassword('s3cret');  // Returns true

JavaScript 没有办法从PasswordChecker构造函数外部访问passwordHash变量。然而,这确实有一些缺点:具体来说,因为passwordHash在构造函数外部看不到,每个使用它的方法也必须在那里定义。这导致为每个类实例创建每个方法的副本,这将导致更高的内存使用。它还阻止同一类的其他实例访问私有数据。闭包可能不方便,但它们肯定会保持你的数据私密!

更新的选择是使用私有字段,这是一个正在稳定的提案语言功能,正在此书印刷时进行。在此提案中,为了使字段在类型检查和运行时都私有化,需要使用#作为前缀:

class PasswordChecker {
  #passwordHash: number;

  constructor(passwordHash: number) {
    this.#passwordHash = passwordHash;
  }

  checkPassword(password: string) {
    return hash(password) === this.#passwordHash;
  }
}

const checker = new PasswordChecker(hash('s3cret'));
checker.checkPassword('secret');  // Returns false
checker.checkPassword('s3cret');  // Returns true

#passwordHash属性不能从类外部访问。与闭包技术相比,它确实可以从类方法和同一类的其他实例中访问。对于不本地支持私有字段的 ECMAScript 目标,将使用WeakMap来进行后备实现。结果是你的数据仍然是私有的。此提案处于第 3 阶段,并且在此书印刷时正在向 TypeScript 添加支持。如果你想使用它,请查看 TypeScript 发行说明,以查看其是否普遍可用。

最后,如果你担心安全性,而不仅仅是封装,那么还有其他需要注意的问题,比如对内置原型和函数的修改。

要记住的事情

  • private访问修饰符仅通过类型系统强制执行。在运行时没有影响,并且可以通过断言绕过。不要假设它会保持数据隐藏。

  • 要实现更可靠的信息隐藏,可以使用闭包。

第 57 条:使用源映射调试 TypeScript

当你运行 TypeScript 代码时,实际上是在运行 TypeScript 编译器生成的 JavaScript。对于任何源到源编译器都是如此,无论是缩小器、编译器还是预处理器。希望这基本上是透明的,你可以假装执行 TypeScript 源代码而无需查看 JavaScript。

直到你需要调试代码时才会发现这种方法很有效。调试器通常在你执行的代码上工作,并不知道它经历了哪些翻译过程。由于 JavaScript 是如此流行的目标语言,浏览器供应商合作解决了这个问题。结果就是源映射。它们将生成文件中的位置和符号映射回原始源中相应的位置和符号。大多数浏览器和许多 IDE 都支持它们。如果你没有使用它们来调试你的 TypeScript,那你就错过了!

假设你创建了一个小脚本,用于在 HTML 页面上添加一个按钮,每次点击它时都会增加:

function addCounter(el: HTMLElement) {
  let clickCount = 0;
  const button = document.createElement('button');
  button.textContent = 'Click me';
  button.addEventListener('click', () => {
    clickCount++;
    button.textContent = `Click me (${clickCount})`;
  });
  el.appendChild(button);
}

addCounter(document.body);

如果您在浏览器中加载此文件并打开调试器,您将看到生成的 JavaScript。这与原始源代码非常接近,因此调试并不太困难,如图 7-1 所示。

efts 07in01

图 7-1. 使用 Chrome 开发者工具调试生成的 JavaScript 代码。对于这个简单的示例,生成的 JavaScript 与 TypeScript 源代码非常相似。

让我们通过从 numbersapi.com 获取每个数字的有趣事实,使页面变得更有趣。

function addCounter(el: HTMLElement) {
  let clickCount = 0;
  const triviaEl = document.createElement('p');
  const button = document.createElement('button');
  button.textContent = 'Click me';
  button.addEventListener('click', async () => {
    clickCount++;
    const response = await fetch(`http://numbersapi.com/${clickCount}`);
    const trivia = await response.text();
    triviaEl.textContent = trivia;
    button.textContent = `Click me (${clickCount})`;
  });
  el.appendChild(triviaEl);
  el.appendChild(button);
}

如果您现在打开浏览器的调试器,您会看到生成的源代码变得复杂得多(见图 7-2)。

efts 07in02

图 7-2. 在这种情况下,TypeScript 编译器生成的 JavaScript 与原始的 TypeScript 源代码并不相似。这会使得调试更加困难。

为了支持旧版浏览器中的async和await,TypeScript 已将事件处理程序重写为状态机。这具有相同的行为,但代码不再与原始源代码如此相似。

这就是源映射可以帮助的地方。要告诉 TypeScript 生成源映射,请在您的tsconfig.json中设置sourceMap选项:

{
  "compilerOptions": {
    "sourceMap": true
  }
}

现在当您运行tsc时,它会为每个.ts文件生成两个输出文件:一个.js文件和一个.js.map文件。后者就是源映射。

有了这个文件,您在浏览器的调试器中会看到一个新的index.ts文件。您可以在其中设置断点并检查变量,就像您希望的那样(见图 7-3)。

efts 07in03

图 7-3. 当存在源映射时,您可以在调试器中使用原始的 TypeScript 源代码,而不是生成的 JavaScript。

请注意,在左侧文件列表中index.ts以斜体显示。这表示它不是网页包含的“真实”文件。相反,它是通过源映射包含的。根据您的设置,index.js.map将包含对index.ts的引用(在这种情况下,浏览器通过网络加载它)或者它的内联副本(在这种情况下,不需要请求)。

源映射有几点需要注意:

  • 如果您正在使用 TypeScript 与捆绑器或缩小器,它可能会生成自己的源映射。为了获得最佳的调试体验,您希望它能够完整映射回原始的 TypeScript 源代码,而不是生成的 JavaScript。如果您的捆绑器内置支持 TypeScript,那么这应该很顺利。如果没有,您可能需要查找一些标志来使其读取源映射输入。

  • 要注意你是否在生产环境中提供源映射。浏览器只有在调试器打开时才会加载源映射,因此对最终用户没有性能影响。但是,如果源映射包含原始源代码的内联副本,那么可能会有一些内容是你不打算公开的。世界真的需要看到你的讽刺评论或内部 bug 追踪器的 URL 吗?

你也可以使用源映射调试 NodeJS 程序。通常通过你的编辑器或通过从浏览器的调试器连接到你的 node 进程来完成。请查阅 Node 文档以获取详细信息。

类型检查器可以在运行代码之前捕获许多错误,但它不能替代一个好的调试器。使用源映射可以获得出色的 TypeScript 调试体验。

要记住的事情

  • 不要调试生成的 JavaScript。使用源映射在运行时调试你的 TypeScript 代码。

  • 确保你的源映射一直映射到你运行的代码。

  • 根据你的设置,你的源映射可能包含原始代码的内联副本。除非你知道自己在做什么,否则不要发布它们!

第八章:TypeScript 迁移

您听说过 TypeScript 很棒。您也从痛苦的经验中知道,维护您 15 年历史,有 10 万行代码的 JavaScript 库并不是一件容易的事。如果它能成为 TypeScript 库就好了!

本章提供了一些建议,介绍了如何将您的 JavaScript 项目迁移到 TypeScript,而不会丧失理智并放弃努力。

只有最小的代码库可以一次性迁移。对于较大的项目来说,逐步迁移才是关键。 Item 60 讨论了如何做到这一点。长时间迁移时,跟踪进度并确保没有倒退至关重要。这将产生一种变革的动力和不可避免性。 Item 61 讨论了如何做到这一点。

将大型项目迁移到 TypeScript 可能并不容易,但它确实提供了巨大的潜在优势。一项 2017 年的研究发现,GitHub 上 JavaScript 项目中修复的 15%的错误可以通过 TypeScript 预防。^(1)更令人印象深刻的是,AirBnb 六个月的事故报告调查发现,有 38%的事故本可以通过 TypeScript 避免。^(2)如果你在组织中提倡 TypeScript,这些统计数据会有所帮助!对此,运行一些实验并找到早期采用者也会有所帮助。 Item 59 讨论了在开始迁移之前如何进行 TypeScript 实验。

由于本章大部分内容都是关于 JavaScript 的,因此许多代码示例要么是纯 JavaScript(不需要通过类型检查器),要么使用更宽松的设置进行检查(例如,关闭noImplicitAny)。

Item 58: 写现代 JavaScript

除了检查代码是否具有类型安全性外,TypeScript 还可以将您的 TypeScript 代码编译为任何版本的 JavaScript 代码,一直回溯到 1999 年的 ES3 版本。由于 TypeScript 是 JavaScript 的最新版本的超集,这意味着您可以将tsc用作“转换器”:一种将新的 JavaScript 转换为更旧,更广泛支持的 JavaScript 的工具。

换个角度看,这意味着当您决定将现有的 JavaScript 代码库转换为 TypeScript 时,采用所有最新的 JavaScript 特性没有坏处。事实上,有很多好处:因为 TypeScript 设计用于与现代 JavaScript 一起使用,所以现代化您的 JS 是迈向采用 TypeScript 的重要第一步。

而且,由于 TypeScript 是 JavaScript 的超集,学习编写更现代和习惯用法的 JavaScript 意味着您也在学习编写更好的 TypeScript。

本项介绍了现代 JavaScript 的一些特性,这里定义的是自 ECMAScript 2015(又名 ES6)以及之后引入的所有内容。这些材料在其他书籍和在线资源中有更详尽的介绍。如果这里提到的任何主题对你不熟悉,你应该花时间了解更多。当你学习像 async/await 这样的新语言特性时,TypeScript 可以极大地帮助你:它几乎肯定比你更好地理解这些特性,并可以指导你正确使用它们。

这些都值得理解,但是对于采用 TypeScript 来说,最重要的是 ECMAScript 模块和 ES2015 类。

使用 ECMAScript 模块

在 ECMAScript 2015 版本之前,没有标准的方法将代码分割为单独的模块。有多种解决方案,从多个 <script> 标签、手动串联、Makefile 到 node.js 风格的 require 语句或 AMD 风格的 define 回调。TypeScript 甚至有自己的模块系统(第 53 项)。

今天有一个标准:ECMAScript 模块,也称为 import 和 export。如果你的 JavaScript 代码库仍然是单文件,如果你使用串联或其他模块系统之一,那么现在是切换到 ES 模块的时候了。这可能需要设置类似 webpack 或 ts-node 这样的工具。TypeScript 最适合使用 ES 模块,并且采用它们将促进你的过渡,至少因为它将允许你逐个迁移模块(参见 第 61 项)。

具体细节将取决于你的设置,但如果你像这样使用 CommonJS:

// CommonJS
// a.js
const b = require('./b');
console.log(b.name);

// b.js
const name = 'Module B';
module.exports = {name};

那么 ES 模块的等价物看起来会像这样:

// ECMAScript module
// a.ts
import * as b from './b';
console.log(b.name);

// b.ts
export const name = 'Module B';

使用类替代原型

JavaScript 拥有灵活的基于原型的对象模型。但总的来说,JS 开发者们更倾向于使用更严格的基于类的模型,这在 ES2015 引入 class 关键字后正式成为了语言的一部分。

如果你的代码以直接的方式使用原型,应该切换到使用类。也就是说,不要再使用:

function Person(first, last) {
  this.first = first;
  this.last = last;
}

Person.prototype.getName = function() {
  return this.first + ' ' + this.last;
}

const marie = new Person('Marie', 'Curie');
const personName = marie.getName();

写:

class Person {
  first: string;
  last: string;

  constructor(first: string, last: string) {
    this.first = first;
    this.last = last;
  }

  getName() {
    return this.first + ' ' + this.last;
  }
}

const marie = new Person('Marie', 'Curie');
const personName = marie.getName();

TypeScript 对于原型版本的 Person 存在一些困难,但对于带有最小注释的基于类的版本理解能力更好。如果你对语法不熟悉,TypeScript 将帮助你正确使用它。

对于使用旧式类的代码,TypeScript 语言服务提供了一个“将函数转换为 ES2015 类”的快速修复方案,可以加快此过程(图 8-1)。

efts 08in01

图 8-1. TypeScript 语言服务提供了一种快速修复方案,将旧式类转换为 ES2015 类。

使用 let/const 替代 var

JavaScript 的 var 有一些著名的古怪作用域规则。如果你想更深入了解它们,请阅读《Effective JavaScript》。但最好避免使用 var,不必担心!相反,请使用 let 和 const。它们真正地在块级作用域中工作,比 var 更直观。

同样,TypeScript 在这里会帮助您。如果将 var 改为 let 导致错误,则几乎可以肯定您正在做不应该做的事情。

嵌套的函数语句也具有类似于 var 的作用域规则:

function foo() {
  bar();
  function bar() {
    console.log('hello');
  }
}

当您调用 foo() 时,它会记录 hello,因为 bar 的定义被提升到 foo 的顶部。这令人惊讶!最好使用函数表达式 (const bar = () => { ... })。

使用 for-of 或数组方法代替 for(;😉

在经典 JavaScript 中,您使用 C 风格的 for 循环来遍历数组:

for (var i = 0; i < array.length; i++) {
  const el = array[i];
  // ...
}

在现代 JavaScript 中,您可以使用 for-of 循环:

for (const el of array) {
  // ...
}

这样更不容易出现拼写错误,并且不会引入索引变量。如果需要索引变量,可以使用 forEach:

array.forEach((el, i) => {
  // ...
});

避免使用 for-in 构造循环遍历数组,因为它会有很多意外情况(见 Item 16)。

更喜欢箭头函数而不是函数表达式

this 关键字是 JavaScript 中最令人困惑的一个方面,因为它具有与其他变量不同的作用域规则:

class Foo {
  method() {
    console.log(this);
    [1, 2].forEach(function(i) {
      console.log(this);
    });
  }
}
const f = new Foo();
f.method();
// Prints Foo, undefined, undefined in strict mode
// Prints Foo, window, window (!) in non-strict mode

通常情况下,您希望 this 引用所在类的相关实例。箭头函数通过保持其闭包作用域中的 this 值来帮助您实现这一点:

class Foo {
  method() {
    console.log(this);
    [1, 2].forEach(i => {
      console.log(this);
    });
  }
}
const f = new Foo();
f.method();
// Always prints Foo, Foo, Foo

除了语义更简单外,箭头函数更为简洁。应尽可能使用它们。关于 this 绑定的更多信息,请参见 Item 49。使用 noImplicitThis(或 strict)编译选项,TypeScript 将帮助您正确地处理 this 绑定。

使用紧凑对象字面量和解构赋值

而不是写成:

const x = 1, y = 2, z = 3;
const pt = {
  x: x,
  y: y,
  z: z
};

您可以简单地写:

const x = 1, y = 2, z = 3;
const pt = { x, y, z };

除了更简洁外,这还鼓励变量和属性的一致命名,这是您的人类读者也会欣赏的 (Item 36)。

要从箭头函数中返回对象字面量,请将其括在括号中:

['A', 'B', 'C'].map((char, idx) => ({char, idx}));
// [ { char: 'A', idx: 0 },  { char: 'B', idx: 1 }, { char: 'C', idx: 2 } ]

还有属性值为函数的简写:

const obj = {
  onClickLong: function(e) {
    // ...
  },
  onClickCompact(e) {
    // ...
  }
};

紧凑对象字面量的反义是对象解构。而不是写成:

const props = obj.props;
const a = props.a;
const b = props.b;

您可以写:

const {props} = obj;
const {a, b} = props;

或者甚至:

const {props: {a, b}} = obj;

在这个最后的例子中,只有 a 和 b 变成了变量,而不是 props。

在解构时可以指定默认值。而不是写成:

let {a} = obj.props;
if (a === undefined) a = 'default';

写成这样:

const {a = 'default'} = obj.props;

您还可以解构数组。这在使用元组类型时特别有用:

const point = [1, 2, 3];
const [x, y, z] = point;
const [, a, b] = point;  // Ignore the first one

解构也可以用在函数参数中:

const points = [
  [1, 2, 3],
  [4, 5, 6],
];
points.forEach(([x, y, z]) => console.log(x + y + z));
// Logs 6, 15

与简洁的对象字面量语法一样,解构是简洁的,并鼓励一致的变量命名。使用它!

使用默认函数参数

在 JavaScript 中,所有函数参数都是可选的:

function log2(a, b) {
  console.log(a, b);
}
log2();

这将输出:

undefined undefined

这经常用于实现参数的默认值:

function parseNum(str, base) {
  base = base || 10;
  return parseInt(str, base);
}

在现代 JavaScript 中,您可以直接在参数列表中指定默认值:

function parseNum(str, base=10) {
  return parseInt(str, base);
}

除了更加简洁外,这还表明 base 是一个可选参数。默认参数在你迁移到 TypeScript 时还有另一个好处:它们帮助类型检查器推断参数的类型,从而消除了类型注解的需求。参见 项目 19。

使用 async/await 替代原始的 Promises 或回调

项目 25 解释了为什么 async 和 await 更可取,但要点在于它们能简化你的代码,防止错误,并帮助类型在异步代码中流动。

而不是这两者:

function getJSON(url: string) {
  return fetch(url).then(response => response.json());
}
function getJSONCallback(url: string, cb: (result: unknown) => void) {
  // ...
}

编写这样的代码:

async function getJSON(url: string) {
  const response = await fetch(url);
  return response.json();
}

不要在 TypeScript 中使用 use strict

ES5 引入了“严格模式”以使一些可疑模式更加显式化为错误。你可以通过在代码中加入 'use strict' 来启用它:

'use strict';
function foo() {
  x = 10;  // Throws in strict mode, defines a global in non-strict.
}

如果你的 JavaScript 代码库从未使用过严格模式,那么试试看吧。它找到的错误很可能也会被 TypeScript 编译器发现。

但是随着你转向 TypeScript,保留源代码中的 'use strict' 并没有太多价值。总的来说,TypeScript 提供的健全性检查远比严格模式提供的要严格得多。

在 JavaScript 中,由 tsc 发出的代码中加上 'use strict' 是有一定价值的。如果你设置了 alwaysStrict 或 strict 编译器选项,TypeScript 将以严格模式解析你的代码,并在生成的 JavaScript 输出中添加 'use strict'。

简而言之,在 TypeScript 中不要写 'use strict'。使用 alwaysStrict 代替即可。

这些只是 TypeScript 允许你使用的许多新的 JavaScript 特性中的一部分。TC39,负责 JS 标准的机构,非常活跃,每年都会添加新特性。目前,TypeScript 团队致力于实现达到标准化流程阶段 3(共 4 阶段)的任何特性,因此你甚至不必等待程序稳定下来。查看 TC39 GitHub 仓库^(3) 获取最新信息。截至本文撰写时,管道和装饰器提案特别有可能影响 TypeScript。

需记住的事项

  • TypeScript 允许你在任何运行时环境中编写现代 JavaScript。利用这一点,使用它所支持的语言特性。除了改善你的代码库,这还将帮助 TypeScript 理解你的代码。

  • 使用 TypeScript 来学习类、解构和 async/await 等语言特性。

  • 不必在 TypeScript 中费心 'use strict':TypeScript 更严格。

  • 查看 TC39 GitHub 仓库和 TypeScript 发布说明,了解所有最新的语言特性。

项目 59:使用 @ts-check 和 JSDoc 来尝试 TypeScript

在你开始从 JavaScript 转换源文件到 TypeScript 的过程之前(条目 60),你可能希望尝试类型检查,以了解可能遇到的问题。TypeScript 的 @ts-check 指令允许你做到这一点。它指示类型检查器分析单个文件并报告它发现的任何问题。你可以把它看作是一种非常宽松的类型检查版本:甚至比 TypeScript 的 noImplicitAny 关闭的情况更宽松(条目 2)。

这是它的工作原理:

// @ts-check
const person = {first: 'Grace', last: 'Hopper'};
2 * person.first
 // ~~~~~~~~~~~~ The right-hand side of an arithmetic operation must be of type
 //              'any', 'number', 'bigint', or an enum type

TypeScript 推断 person.first 的类型为 string,所以 2 * person.first 是一个类型错误,无需类型注解。

虽然它可能会暴露这种明显的类型错误,或者调用了太多参数的函数,但实际上,// @ts-check 倾向于揭示几种特定类型的错误:

未声明的全局变量

如果这些是你定义的符号,那么用 let 或 const 声明它们。如果它们是在别处(比如在 HTML 文件的 <script> 标签中)定义的“环境”符号,则可以创建一个类型声明文件来描述它们。

例如,如果你有这样的 JavaScript 代码:

// @ts-check
console.log(user.firstName);
         // ~~~~ Cannot find name 'user'

然后你可以创建一个名为 types.d.ts 的文件:

interface UserData {
  firstName: string;
  lastName: string;
}
declare let user: UserData;

单独创建这个文件可能会修复问题。如果不行,你可能需要使用 “三斜杠” 引用显式导入它:

// @ts-check
/// <reference path="./types.d.ts" />
console.log(user.firstName);  // OK

这个 types.d.ts 文件是一个有价值的工件,将成为你项目类型声明的基础。

未知的库

如果你使用第三方库,TypeScript 需要知道它。例如,你可能会使用 jQuery 来设置 HTML 元素的大小。使用 @ts-check,TypeScript 将标记一个错误:

// @ts-check
   $('#graph').style({'width': '100px', 'height': '100px'});
// ~ Cannot find name '$'

解决方案是安装 jQuery 的类型声明:

$ npm install --save-dev @types/jquery

现在错误具体到了 jQuery:

// @ts-check
$('#graph').style({'width': '100px', 'height': '100px'});
         // ~~~~~ Property 'style' does not exist on type 'JQuery<HTMLElement>'

实际上,应该是 .css,而不是 .style。

@ts-check 允许你利用 TypeScript 对流行 JavaScript 库的声明,而无需自行迁移到 TypeScript。这是使用它的最佳理由之一。

DOM 问题

假设你正在编写在 web 浏览器中运行的代码,TypeScript 可能会标记你在处理 DOM 元素时遇到的问题。例如:

// @ts-check
const ageEl = document.getElementById('age');
ageEl.value = '12';
   // ~~~~~ Property 'value' does not exist on type 'HTMLElement'

问题在于只有 HTMLInputElement 具有 value 属性,但是 document.getElementById 返回更通用的 HTMLElement(参见 条目 55)。如果你知道 #age 元素确实是一个 input 元素,那么这是使用类型断言的适当时机(条目 9)。但这仍然是一个 JS 文件,所以你不能写 as HTMLInputElement。相反,你可以使用 JSDoc 进行类型断言:

// @ts-check
const ageEl = /** @type {HTMLInputElement} */(document.getElementById('age'));
ageEl.value = '12';  // OK

如果你在编辑器中悬停在 ageEl 上,你会看到 TypeScript 现在将其视为 HTMLInputElement。在输入 JSDoc @type 注释时要小心:括号后面的注释是必需的。

这导致了另一种类型的错误,在 @ts-check 中出现了不准确的 JSDoc,下面将进行解释。

不准确的 JSDoc

如果你的项目已经有类似 JSDoc 风格的注释,启用 @ts-check 后 TypeScript 将开始检查它们。如果之前使用过像 Closure Compiler 这样的系统来强制执行类型安全性,那么这不应该造成重大问题。但如果你的注释更像是“理想的 JSDoc”,可能会有些意外。

// @ts-check
/**
 * Gets the size (in pixels) of an element.
 * @param {Node} el The element
 * @return {{w: number, h: number}} The size
 */
function getSize(el) {
  const bounds = el.getBoundingClientRect();
                 // ~~~~~~~~~~~~~~~~~~~~~ Property 'getBoundingClientRect'
                 //                       does not exist on type 'Node'
  return {width: bounds.width, height: bounds.height};
       // ~~~~~~~~~~~~~~~~~~~ Type '{ width: any; height: any; }' is not
       //                     assignable to type '{ w: number; h: number; }'
}

第一个问题是对 DOM 的误解:getBoundingClientRect() 被定义在 Element 上,而不是 Node 上。因此,@param 标签应该更新。第二个问题是在 @return 标签和实现中指定的属性之间不匹配。很可能项目的其余部分使用 width 和 height 属性,因此 @return 标签应该更新。

你可以使用 JSDoc 逐步为项目添加类型注解。TypeScript 语言服务将会建议根据用法从推断类型注释的快速修复代码,就像这里和 图 8-2 中展示的一样:

function double(val) {
  return 2 * val;
}

efts 08in02

图 8-2. TypeScript 语言服务提供了一个从用法推断参数类型的快速修复。

这将得到一个正确的 JSDoc 注释:

// @ts-check
/**
 * @param {number} val
 */
function double(val) {
  return 2 * val;
}

使用 @ts-check 可以帮助类型在你的代码中流动。但它并不总是效果很好。例如:

function loadData(data) {
  data.files.forEach(async file => {
    // ...
  });
}

如果你使用快速修复来注释 data,你最终会得到:

/**
 * @param {{
 *  files: { forEach: (arg0: (file: any) => Promise<void>) => void; };
 * }} data
 */
function loadData(data) {
  // ...
}

这是结构化类型化走火入魔(项目 4)。虽然函数在任何具有该签名的对象上技术上都可以工作,但最有可能是参数应该是 {files: string[]}。

在 JavaScript 项目中,你可以通过 JSDoc 注解和 @ts-check 获得大部分 TypeScript 的体验。这很吸引人,因为它不需要你改变工具链。但最好不要走得太远。注释的样板有真正的成本:在一大堆 JSDoc 中容易让逻辑变得混乱。TypeScript 最适合 .ts 文件,而不是 .js 文件。最终目标是将你的项目转换为 TypeScript,而不是带有 JSDoc 注解的 JavaScript。但 @ts-check 可以是一种有用的方式,特别是对于已经有大量 JSDoc 注解的项目,可以用来试验类型并发现一些初始错误。

需记住的事项

  • 在 JavaScript 文件顶部添加 "// @ts-check" 可启用类型检查。

  • 识别常见错误。了解如何声明全局变量并为第三方库添加类型声明。

  • 使用 JSDoc 注释进行类型断言和更好的类型推断。

  • 不要花太多时间用 JSDoc 完美地输入你的代码。记住,目标是转换为 .ts!

项目 60:使用 allowJs 混合 TypeScript 和 JavaScript

对于小型项目,你可能能够一举从 JavaScript 转换到 TypeScript。但对于较大的项目,这种“停止世界”的方法行不通。你需要逐步过渡的能力。这意味着你需要一种让 TypeScript 和 JavaScript 共存的方式。

这的关键在于allowJs编译选项。使用allowJs,TypeScript 文件和 JavaScript 文件可以相互导入。对于 JavaScript 文件,这种模式非常宽松。除非你使用@ts-check(Item 59),否则你只会看到语法错误。这是“TypeScript 是 JavaScript 的超集”在最琐碎的意义上。

虽然不太可能捕捉到错误,但allowJs确实为你提供了一个机会,在开始进行代码更改之前将 TypeScript 引入到你的构建链中。这是一个好主意,因为你会希望在将模块转换为 TypeScript 时能够运行你的测试(Item 61)。

如果你的打包工具包含了 TypeScript 集成或有可用的插件,那通常是最简单的前进路径。例如,使用browserify,你运行npm install --sav-dev tsify并将其添加为插件:

$ browserify index.ts -p [ tsify --noImplicitAny ] > bundle.js

大多数单元测试工具也有类似的选项。例如,使用jest工具,你安装ts-jest并通过指定jest.config.js将 TypeScript 源代码传递给它:

module.exports = {
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
};

如果你的构建链是自定义的,那么你的任务将会更加复杂。但总会有一个很好的备选方案:当你指定outDir选项时,TypeScript 会在一个与源代码树平行的目录中生成纯 JavaScript 源代码。通常情况下,你现有的构建链可以在这上面运行。你可能需要微调 TypeScript 的 JavaScript 输出,使其与原始 JavaScript 源代码尽可能匹配(例如,通过指定target和module选项)。

将 TypeScript 添加到你的构建和测试流程中可能不是最愉快的任务,但这是一个至关重要的任务,让你能够有信心开始迁移你的代码。

要记住的事情

  • 使用allowJs编译选项来支持混合 JavaScript 和 TypeScript,以便在项目转换过程中过渡。

  • 在开始大规模迁移之前,确保你的测试和构建链能够与 TypeScript 一起工作。

项目 61:按模块转换,沿着依赖图向上移动

你已经采用了现代 JavaScript,将项目转换为使用 ECMAScript 模块和类(Item 58)。你已经将 TypeScript 集成到你的构建链中,并且所有的测试都通过了(Item 60)。现在是有趣的部分:将你的 JavaScript 转换为 TypeScript。但从哪里开始呢?

当你给一个模块添加类型时,很可能会在所有依赖它的模块中出现新的类型错误。理想情况下,你希望每个模块只转换一次就完成。这意味着你应该按照依赖图向上转换模块:从叶子节点(不依赖其他模块的模块)开始,向根节点移动。

第一个要迁移的模块通常是第三方依赖项,因为按定义,您依赖于它们,但它们不依赖于您。通常这意味着安装 @types 模块。例如,如果您使用 lodash 实用程序库,您将运行 npm install --save-dev @types/lodash。这些类型将帮助类型在您的代码中流动,并显示您对库的使用中出现的问题。

如果您的代码调用外部 API,可能还希望尽早为这些 API 添加类型声明。尽管这些调用可能发生在代码的任何位置,但这仍符合向依赖图上游移动的精神,因为您依赖于 API,但它们并不依赖于您。许多类型源自 API 调用,这些类型通常很难从上下文中推断出来。如果您可以找到 API 的规范,可以从中生成类型(参见 Item 35)。

在迁移您自己的模块时,可视化依赖图非常有帮助。图 8-3 展示了一个中型 JavaScript 项目的示例图,使用了出色的 madge 工具生成。

efts 0801

图 8-3. 中型 JavaScript 项目的依赖图。箭头表示依赖关系。较深色的框表示模块涉及循环依赖。

此依赖图底部是 utils.js 和 tickers.js 之间的循环依赖。许多模块依赖于这两个模块,但它们只相互依赖。这种模式非常常见:大多数项目将在依赖图的底部拥有某种类型的实用程序模块。

在迁移代码时,专注于添加类型而不是重构。如果这是一个旧项目,您可能会注意到一些奇怪的问题并想要修复它们。请抵制这种冲动!当前的目标是将项目转换为 TypeScript,而不是改进其设计。相反,请在发现问题时记录代码异味并制定未来重构的清单。

当您转换为 TypeScript 时,可能会遇到一些常见错误。其中一些在 Item 59 中有所涉及,但新的错误包括:

未声明的类成员

JavaScript 中的类不需要声明其成员,但 TypeScript 中的类需要。当您将类的 .js 文件重命名为 .ts 时,很可能会显示对您引用的每个属性的错误:

class Greeting {
  constructor(name) {
    this.greeting = 'Hello';
      // ~~~~~~~~ Property 'greeting' does not exist on type 'Greeting'
    this.name = name;
      // ~~~~ Property 'name' does not exist on type 'Greeting'
  }
  greet() {
    return this.greeting + ' ' + this.name;
             // ~~~~~~~~              ~~~~ Property ... does not exist
  }
}

对于此问题,有一个有用的快速修复(参见 图 8-4),您应该利用它。

efts 08in03

图 8-4. 快速修复以添加缺失成员的声明对于将类转换为 TypeScript 特别有帮助。

这将根据使用情况添加缺失成员的声明:

class Greeting {
  greeting: string;
  name: any;
  constructor(name) {
    this.greeting = 'Hello';
    this.name = name;
  }
  greet() {
    return this.greeting + ' ' + this.name;
  }
}

TypeScript 能够正确获取 greeting 的类型,但不能正确获取 name 的类型。应用此快速修复后,您应该查看属性列表并修复 any 类型。

如果这是您第一次看到类的完整属性列表,您可能会感到震惊。当我将dygraph.js中的主类转换(图 8-3 中的根模块)时,我发现它竟然有不少于 45 个成员变量!迁移到 TypeScript 会显露出之前隐含的糟糕设计。如果必须看到它,糟糕的设计就更难以自圆其说了。但同样,现在抵制重构的冲动。注意这种奇怪之处,并考虑如何在其他日子解决它。

具有变化类型的值

TypeScript 将抱怨这样的代码:

const state = {};
state.name = 'New York';
   // ~~~~ Property 'name' does not exist on type '{}'
state.capital = 'Albany';
   // ~~~~~~~ Property 'capital' does not exist on type '{}'

更详细地讨论了这个主题,见项目 23,所以如果您遇到此错误,可能需要重新学习一下该项目。如果修复很简单,您可以一次性构建对象:

const state = {
  name: 'New York',
  capital: 'Albany',
};  // OK

如果不是,那么现在是使用类型断言的适当时机:

interface State {
  name: string;
  capital: string;
}
const state = {} as State;
state.name = 'New York';  // OK
state.capital = 'Albany';  // OK

最终您应该修复这个问题(参见项目 9),但这是一个权宜之计,将帮助您保持迁移进程。

如果您一直在使用 JSDoc 和@ts-check(项目 59),请注意,转换为 TypeScript 实际上可能会丢失类型安全性。例如,TypeScript 在这段 JavaScript 中标记了一个错误:

// @ts-check
/**
 * @param {number} num
 */
function double(num) {
  return 2 * num;
}

double('trouble');
    // ~~~~~~~~~ Argument of type '"trouble"' is not assignable to
    //           parameter of type 'number'

当您转换为 TypeScript 时,@ts-check 和 JSDoc 将不再强制执行。这意味着num的类型隐式为any,因此不会出现错误:

/**
 * @param {number} num
 */
function double(num) {
  return 2 * num;
}

double('trouble');  // OK

幸运的是,有一个快速修复方法可以将 JSDoc 类型转移到 TypeScript 类型中。如果您有任何 JSDoc,请使用图 8-5 中所示的内容。

efts 08in04

图 8-5. 快速修复:将 JSDoc 注释复制到 TypeScript 类型注释中

一旦您将类型注释复制到 TypeScript 中,请确保从 JSDoc 中删除它们,以避免冗余(参见项目 30):

function double(num: number) {
  return 2 * num;
}

double('trouble');
    // ~~~~~~~~~ Argument of type '"trouble"' is not assignable to
    //           parameter of type 'number'

当您打开noImplicitAny时,也会捕获到这个问题,但现在您可能也可以添加类型。

最后迁移您的测试。它们应该位于您的依赖图的顶部(因为您的代码不依赖它们),在迁移过程中,知道您的测试仍然通过,尽管您完全没有更改它们,这是非常有帮助的。

要记住的事项

  • 通过为第三方模块和外部 API 调用添加@types开始迁移。

  • 从依赖图的底部开始迁移您的模块。第一个模块通常会是某种实用代码。考虑可视化依赖图以帮助您跟踪进度。

  • 在揭示奇怪的设计时,抵制重构代码的冲动。保留一个未来重构的想法列表,但专注于 TypeScript 转换。

  • 在转换过程中注意常见的错误。如有必要,复制 JSDoc 注释以避免丢失类型安全性。

项目 62:在启用noImplicitAny之前,不要认为迁移完成。

将整个项目转换为 .ts 是一个重要的成就。但你的工作还没有完成。你的下一个目标是启用 noImplicitAny 选项(条款 2)。没有 noImplicitAny 的 TypeScript 代码最好被看作是过渡性的,因为它可能掩盖了你在类型声明中真正的错误。

例如,也许你使用了“添加所有缺失成员”的快速修复来添加类的属性声明(条款 61)。你会留下一个any类型,并希望修复它:

class Chart {
  indices: any;

  // ...
}

indices 听起来应该是一个数字数组,所以你插入了那个类型:

class Chart {
  indices: number[];

  // ...
}

没有出现新错误,所以你继续前进。不幸的是,你犯了一个错误:number[] 是错误的类型。这里是来自课堂其他地方的一些代码:

getRanges() {
  for (const r of this.indices) {
    const low = r[0];  // Type is any
    const high = r[1];  // Type is any
    // ...
  }
}

显然,number[][]或[number, number][]将是更精确的类型。你是否对索引到number允许的事实感到惊讶?这表明 TypeScript 在没有 noImplicitAny 的情况下可以有多松散。

当你启用 noImplicitAny 时,以下内容将变成错误:

getRanges() {
  for (const r of this.indices) {
    const low = r[0];
             // ~~~~ Element implicitly has an 'any' type because
             //      type 'Number' has no index signature
    const high = r[1];
              // ~~~~ Element implicitly has an 'any' type because
              //      type 'Number' has no index signature
    // ...
  }
}

启用 noImplicitAny 的一个好策略是在你的本地客户端设置它并开始修复错误。从类型检查器中获取的错误数量可以让你了解你的进展。你可以提交类型修正而不提交 tsconfig.json 的更改,直到将错误数量减少到零为止。

还有许多其他可以调整的开关来增加类型检查的严格性,最终设置为 "strict": true。但 noImplicitAny 是最重要的设置,即使你不采用像 strictNullChecks 这样的其他设置,你的项目也会获得 TypeScript 的大部分好处。在采用更严格的设置之前,给你的团队一个适应 TypeScript 的机会。

记住这些事情:

  • 在采用 noImplicitAny 之前不要认为你的 TypeScript 迁移完成。宽松的类型检查可能掩盖类型声明中的真正错误。

  • 逐步修复类型错误,然后再强制使用 noImplicitAny。在采用更严格的检查之前,给你的团队一个适应 TypeScript 的机会。

^(1) Z. Gao, C. Bird, 和 E. T. Barr,《是类型还是不类型:量化 JavaScript 中可检测到的错误》,ICSE 2017,http://earlbarr.com/publications/typestudy.pdf。

^(2) Brie Bunge,《大规模采用 TypeScript》,JSConf Hawaii 2019,https://youtu.be/P-J9Eg7hJwE。

^(3) https://github.com/tc39/proposals

posted @ 2025-11-19 09:22  绝不原创的飞龙  阅读(30)  评论(0)    收藏  举报