TypeScript-学习指南-全-
TypeScript 学习指南(全)
原文:
zh.annas-archive.org/md5/a979cdb64c2fd8ea2726a869c9e6f43e译者:飞龙
前言
我对 TypeScript 的旅程并不是一帆风顺或快速的。我在学校主要写 Java,然后是 C++,像许多在静态类型语言上成长的新开发人员一样,我看不起 JavaScript,认为它只是人们随意扔在网站上的懒散脚本语言。
我在这门语言上的第一个重要项目是一个纯 HTML5/CSS/JavaScript 实现的愚蠢的 超级马里奥兄弟 游戏的重新制作,就像许多第一个项目一样,一开始完全混乱。在项目开始时,我本能地不喜欢 JavaScript 的奇怪灵活性和缺乏防护。直到项目接近尾声,我才真正开始尊重 JavaScript 的特性和怪癖:语言的灵活性,能够混合和匹配小函数,以及在用户加载页面后几秒钟内即刻生效。
在我完成第一个项目时,我已经深深爱上了 JavaScript。
静态分析(分析你的代码而无需运行它的工具),比如 TypeScript,起初让我感到不适。JavaScript 如此轻松流畅,我想,为什么要用严格的结构和类型束缚自己?我们是不是在回到我曾经离开的 Java 和 C++ 的世界?
回顾我的旧项目时,我花了整整十分钟才勉强读懂我那些古怪混乱的 JavaScript 代码,理解没有静态分析时事情会变得多么混乱。整理那些代码的过程向我展示了所有可能从结构化中受益的地方。从那时起,我便着迷于尽可能向我的项目中添加静态分析。
距我第一次尝试 TypeScript 已经快十年了,我仍然像以前一样享受它。这门语言仍在不断发展,引入新特性,如今比以往任何时候都更有用,为 JavaScript 提供了安全性和结构。
我希望通过阅读 学习 TypeScript,你能像我一样学会欣赏 TypeScript:它不仅仅是找出错误和拼写错误的手段,当然也不是 JavaScript 代码模式的重大变化,而是带有类型的 JavaScript:一个声明我们的 JavaScript 应该如何工作的美丽系统,并帮助我们坚持下去。
适合读者
如果你理解如何编写 JavaScript 代码,可以在终端运行基本命令,并有兴趣学习 TypeScript,那么这本书适合你。
也许你听说过 TypeScript 能帮助你用更少的错误写更多的 JavaScript(属实!),或者为其他人阅读文档良好地记录你的代码(同样属实!)。也许你看到 TypeScript 在许多职位招聘中出现,或者在你即将开始的新角色中。
无论你的理由是什么,只要你已掌握 JavaScript 的基础知识——变量、函数、闭包/作用域和类——这本书将带你从零开始掌握 TypeScript 的基础知识和最重要的特性。通过本书,你将理解:
-
TypeScript 在“纯”JavaScript 之上为何有用的历史和背景
-
类型系统如何对代码建模
-
类型检查器如何分析代码
-
如何使用仅用于开发的类型注解来指导类型系统
-
TypeScript 如何与 IDE(集成开发环境)协作,提供代码探索和重构工具
你将能够:
-
阐述 TypeScript 的好处和其类型系统的一般特性。
-
在代码中添加有用的类型注解。
-
使用 TypeScript 的内置推断和新语法来表示中等复杂类型。
-
使用 TypeScript 协助本地开发进行代码重构。
我为什么写这本书
TypeScript 是一个在工业界和开源社区都极为流行的语言:
-
GitHub 的 2021 年和 2020 年 Octoverse 报告显示,它是该平台第四受欢迎的语言,比 2019 年和 2018 年的第七位以及 2017 年的第十位有所上升。
-
StackOverflow 2021 年的开发者调查显示,它是世界上第三受欢迎的语言(72.73% 的用户)。
-
2020 年 JS 调查显示,TypeScript 作为构建工具和 JavaScript 变体,始终保持高度满意度和使用率。
对于前端开发人员来说,TypeScript 在所有主要的 UI 库和框架中都得到了良好支持,包括强烈推荐使用 TypeScript 的 Angular,以及 Gatsby、Next.js、React、Svelte 和 Vue。对于后端开发人员来说,TypeScript 生成的 JavaScript 可以原生运行在 Node.js 中;Deno,Node.js 的创造者创建的类似运行时环境,强调直接支持 TypeScript 文件。
然而,尽管有如此多受欢迎的项目支持,当我第一次学习这门语言时,我对在线缺乏良好的入门内容感到相当失望。许多在线文档源并没有很好地解释“类型系统”是什么以及如何使用它。它们通常假设读者在 JavaScript 和强类型语言方面有大量的先验知识,或者只是提供了粗略的代码示例。
在几年前,没有看到 O'Reilly 出版的一本有趣动物封面介绍 TypeScript 的书让我感到失望。虽然现在包括 O'Reilly 在内的其他出版商出版了许多关于 TypeScript 的书籍,但在这本书之前,我找不到一本专注于这门语言基础的书籍,解释它为何以其特定方式工作以及其核心特性如何协同工作。这本书从语言基础开始解释,逐步添加功能。我很高兴能够为那些对 TypeScript 原理不熟悉的读者清晰、全面地介绍 TypeScript 语言基础。
导读本书
学习 TypeScript 有两个目的:
-
您可以通过一次阅读了解 TypeScript 的整体情况。
-
稍后,您可以将其作为实际入门 TypeScript 语言的参考书。
这本书分为三个主要部分,从概念到实际运用逐步展开:
-
第一部分,“概念”:JavaScript 的发展历程,TypeScript 在其中的贡献以及 TypeScript 创建的类型系统的基础。
-
第二部分,“特性”:展开类型系统如何与您在编写 TypeScript 代码时使用的 JavaScript 的主要部分互动。
-
第三部分,“用法”:现在您已经了解了构成 TypeScript 语言的特性,如何在实际情况中使用它们来提高您的代码阅读和编辑体验。
在末尾添加了一个第四部分,“额外积分”章节,涵盖了较少使用但偶尔仍然有用的 TypeScript 特性。您不需要深入了解它们来自认为自己是 TypeScript 开发者。但它们都是有用的概念,可能会在您实际项目中使用 TypeScript 时遇到。一旦您完成了对前三部分的理解,我强烈建议您学习额外积分部分。
每章以俳句开头,以双关语结尾,以进入其内容的精神。整个 Web 开发社区以及其中的 TypeScript 社区以其欢乐和对新手的欢迎而闻名。我尽量使本书对像我这样不喜欢冗长、枯燥文风的学习者来说愉快阅读。
示例和项目
不同于许多其他介绍 TypeScript 的资源,本书有意侧重于通过独立示例展示新信息,而不深入介绍中等或大型项目。我更喜欢这种教学方法,因为它首先突出 TypeScript 语言。TypeScript 在许多框架和平台上都很有用——其中许多框架和平台经常进行 API 更新——我不想在本书中保留任何特定于框架或平台的内容。
话虽如此,在学习编程语言时,立即在介绍后练习概念非常有用。我强烈建议每章结束后休息一下,复习该章节的内容。每章都建议访问其在https://learningtypescript.com上的部分,并完成列出的示例和项目。
本书使用的约定
本书使用以下排版约定:
斜体
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
固定宽度
用于程序清单,以及段落内引用程序元素,例如变量或函数名称、数据类型、语句和关键字。
提示
此元素表示提示或建议。
注意
此元素表示一般注意事项。
警告
此元素指示警告或注意事项。
使用代码示例
附加材料(代码示例、练习等)可在https://learningtypescript.com下载。
如果您对使用代码示例有技术问题或问题,请发送电子邮件至bookquestions@oreilly.com。
本书旨在帮助您完成工作任务。通常情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书中的示例需要许可。引用本书回答问题并引用示例代码不需要许可。将本书大量示例代码整合到您产品的文档中需要许可。
我们感谢,但通常不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“学习 TypeScript by Josh Goldberg (O’Reilly)。2022 年版权 Josh Goldberg, 978-1-098-11033-8。”
如果您认为您对代码示例的使用超出了合理使用或上述许可,请随时通过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/learning-typescript。
通过电子邮件bookquestions@oreilly.com发表评论或提出有关本书的技术问题。
有关我们的图书和课程的新闻和信息,请访问https://oreilly.com。
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media。
在 Twitter 上关注我们:https://twitter.com/oreillymedia。
在 YouTube 上关注我们:https://www.youtube.com/oreillymedia。
致谢
这本书是团队的努力成果,我要衷心感谢所有让这成为可能的人。首先是我的超人编辑总监 Rita Fernando,在整个创作过程中展现的不可思议的耐心和卓越指导。额外向 O'Reilly 团队的其他成员致敬:Kristen Brown,Suzanne Huston,Clare Jensen,Carol Keller,Elizabeth Kelly,Cheryl Lenser,Elizabeth Oliver 和 Amanda Quinn。你们都太棒了!
特别感谢技术审阅者,他们提供了始终如一的高水平教学见解和 TypeScript 专业知识:Mike Boyle,Ryan Cavanaugh,Sara Gallagher,Michael Hoffman,Adam Reineke 和 Dan Vanderkam。没有你们,这本书将不会如此。希望我成功捕捉到你们所有伟大建议的意图!
特别感谢给予书籍详尽评论以帮助我提升技术准确性和写作质量的各位同行和赞誉者:Robert Blake,Andrew Branch,James Henry,Adam Kaczmarek,Loren Sands-Ramshaw,Nik Stern 和 Lenz Weber-Tronic。每一个建议都非常重要!
最后,我要感谢多年来家人们的爱和支持。感谢我的父母,Frances 和 Mark,以及我的兄弟 Danny — 感谢你们让我有时间玩乐高、读书和玩游戏。感谢我的配偶 Mariah Goldberg,在我长时间编辑和写作的时候给予的耐心,还有我们的猫 Luci,Tiny 和 Jerry,因为它们卓越的毛发和陪伴。
第一部分:概念
第一章:从 JavaScript 到 TypeScript
当前 JavaScript
支持浏览器数十年
网页的美丽
在谈论 TypeScript 之前,我们需要先了解它来自哪里:JavaScript!
JavaScript 的历史
JavaScript 由 Brendan Eich 在 1995 年在 Netscape 设计完成,旨在为网站提供一种易于接近和使用的语言。开发者们从那时起就在调侃其怪癖和被认为的缺点。我将在下一节中介绍其中的一些。
自 1995 年以来,JavaScript 已经发展得非常迅猛!它的主导委员会 TC39 自 2015 年以来每年发布 ECMAScript 的新版本——JavaScript 所基于的语言规范,这些新特性使其与其他现代语言保持一致。令人印象深刻的是,即使有定期发布的新语言版本,JavaScript 在各种环境中,包括浏览器、嵌入式应用和服务器运行时,几十年来仍然保持向后兼容性。
今天,JavaScript 是一门极具灵活性和众多优点的语言。人们应该赞赏 JavaScript 虽然有其特 quirks,但也帮助推动了 Web 应用和互联网的令人难以置信的增长。
给我展示一个完美的编程语言,我会告诉你一个没有用户的语言。
Anders Hejlsberg,TSConf 2019
原生 JavaScript 的缺点
开发者经常使用没有任何重要语言扩展或框架的 JavaScript 作为“原生”:这是指它作为熟悉的原始版本。我很快会介绍 TypeScript 是如何恰到好处地解决这些主要问题的,但了解它们为何会令人痛苦也是有益的。所有这些缺点在项目变得越来越大和更长寿时会更加显著。
自由的代价
许多开发者对 JavaScript 的最大抱怨之一,不幸的是这也是它的关键特性之一:JavaScript 几乎没有限制你如何结构化你的代码。这种自由让人在 JavaScript 中启动一个项目非常有趣!
然而,随着文件越来越多,自由的破坏性也变得越来越明显。从某些虚构的绘画应用程序中提取出来的下面这段代码片段就是一个典型例子,没有上下文支持:
function paintPainting(painter, painting) {
return painter
.prepare()
.paint(painting, painter.ownMaterials)
.finish();
}
在没有任何上下文的情况下阅读该代码,你只能对如何调用 paintPainting 函数有模糊的想法。也许如果你在周围的代码库中工作过,你可能会记得 painter 应该是某个 getPainter 函数返回的内容。你甚至可能会幸运地猜到 painting 是一个字符串。
即使这些假设是正确的,稍后对代码的更改可能会使它们失效。也许 painting 从字符串更改为其他数据类型,或者可能会有一个或多个画家的方法被重命名。
其他语言可能会拒绝运行代码,如果它们的编译器确定它可能会崩溃。动态类型的语言——例如 JavaScript,运行代码时不会事先检查是否可能会崩溃。
代码的自由性让 JavaScript 用起来很有趣,但当您希望在运行代码时获得安全性时,这成为一个真正的痛点。
松散的文档
JavaScript 语言规范中没有任何内容可以正式描述代码中函数参数、函数返回值、变量或其他结构的含义。许多开发人员采用了一个称为 JSDoc 的标准,使用块注释来描述函数和变量。JSDoc 标准描述了如何编写直接放置在函数和变量等结构上方的文档注释,并以标准方式格式化。这里有一个例子,同样摘自上下文:
/**
* Performs a painter painting a particular painting.
*
* @param {Painting} painter
* @param {string} painting
* @returns {boolean} Whether the painter painted the painting.
*/
function paintPainting(painter, painting) { /* ... */ }
JSDoc 存在一些关键问题,这些问题经常使得在大型代码库中使用它变得不愉快:
-
没有什么可以阻止 JSDoc 描述与代码不符。
-
即使您的 JSDoc 描述以前是正确的,在代码重构过程中,可能很难找到所有与您的更改相关的现在无效的 JSDoc 注释。
-
描述复杂对象笨拙且冗长,需要多个独立的注释来定义类型及其关系。
维护十几个文件中的 JSDoc 注释不会花费太多时间,但在数百甚至数千个不断更新的文件中进行维护可能是一项真正的苦差事。
更弱的开发工具
因为 JavaScript 没有提供内置方法来识别类型,并且代码很容易偏离 JSDoc 注释,因此难以自动化地进行大规模的更改或获得关于代码库的见解。JavaScript 开发人员经常会对像 C# 和 Java 这样的类型语言中的特性感到惊讶,这些特性允许开发人员执行类成员重命名或跳转到声明参数类型的位置。
注意
您可能会抗议说现代 IDE(如 VS Code)提供了一些开发工具,例如自动重构到 JavaScript。没错,但是:它们在大多数 JavaScript 代码中使用 TypeScript 或等效语言,在性能和可靠性上不如在良好定义的 TypeScript 代码中。
TypeScript!
TypeScript 在 2010 年代初期在 Microsoft 内部创建,然后于 2012 年发布并开源。其开发负责人是 Anders Hejlsberg,他也负责开发流行的 C# 和 Turbo Pascal 语言。TypeScript 通常被描述为“JavaScript 的超集”或“带类型的 JavaScript”。但是 TypeScript 到底是什么呢?
TypeScript 是四个东西:
编程语言
一种语言,包括所有现有的 JavaScript 语法,以及用于定义和使用类型的新的 TypeScript 特定语法
类型检查器
一个接受 JavaScript 和/或 TypeScript 编写的一组文件的程序,开发对所有创建的结构(变量、函数等)的理解,并告知您是否认为任何设置不正确
编译器
运行类型检查器的程序,报告任何问题,然后输出等效的 JavaScript 代码
语言服务
一个使用类型检查器告知诸如 VS Code 等编辑器如何为开发人员提供有用工具的程序
在 TypeScript Playground 入门
到现在为止,您已经阅读了大量关于 TypeScript 的内容。让我们开始编写它吧!
TypeScript 的主要网站在 https://www.typescriptlang.org/play 包含一个“Playground”编辑器。您可以在主编辑器中输入代码,并查看与在本地使用完整集成开发环境(IDE)时相同的许多编辑器建议。
本书中大多数片段都故意足够小且自包含,以便您可以在 Playground 中输入它们并进行乐趣调整。
TypeScript 实战
看看这段代码片段:
const firstName = "Georgia";
const nameLength = firstName.length();
// ~~~~~~
// This expression is not callable.
该代码使用普通的 JavaScript 语法编写——我还没有介绍 TypeScript 特定的语法。如果您对此代码运行 TypeScript 类型检查器,它会利用其知识,即字符串的 length 属性是一个数字而不是函数,从而向您显示在注释中所示的投诉。
如果您将该代码粘贴到 playground 或编辑器中,语言服务会在 length 下面放置一个小红波浪线,表示 TypeScript 对您的代码不满意。悬停在这段波浪线下面会显示投诉的文本(图 1-1)。

图 1-1. TypeScript 报告字符串长度不可调用的错误
当您键入时,在编辑器中即时发现这些简单错误要比等到特定行代码运行并抛出错误要愉快得多。如果您尝试在 JavaScript 中运行该代码,它会崩溃!
通过限制获得自由
TypeScript 允许我们指定参数和变量可以提供的值的类型。有些开发人员最初会觉得在代码中明确写出特定区域如何工作是一种限制。
但是!我认为以这种方式“受限制”实际上是件好事!通过将我们的代码限制为只能按照您指定的方式使用,TypeScript 可以让您确信代码中的更改不会破坏其他使用它的代码区域。
例如,如果您更改了函数的必需参数数量,TypeScript 将在您忘记更新调用该函数的地方时提醒您。
在下面的例子中,sayMyName 从接收两个参数更改为接收一个参数,但调用它时传递了两个字符串,并因此触发了 TypeScript 的投诉:
// Previously: sayMyName(firstName, lastNameName) { ...
function sayMyName(fullName) {
console.log(`You acting kind of shady, ain't callin' me ${fullName}`);
}
sayMyName("Beyoncé", "Knowles");
// ~~~~~~~~~
// Expected 1 argument, but got 2.
该代码在 JavaScript 中不会崩溃运行,但其输出与预期不同(不包括 "Knowles"):
You acting kind of shady, ain't callin' me Beyoncé
调用函数时使用错误数量的参数正是 TypeScript 限制的短视 JavaScript 自由的一种体现。
精确的文档
让我们看一下之前的paintPainting函数的 TypeScript 版本。尽管我还没有详细介绍用于记录类型的 TypeScript 语法,但以下代码片段仍然暗示了 TypeScript 可以精确记录代码的能力:
interface Painter {
finish(): boolean;
ownMaterials: Material[];
paint(painting: string, materials: Material[]): boolean;
}
function paintPainting(painter: Painter, painting: string): boolean { /* ... */ }
第一次阅读此代码的 TypeScript 开发人员可以理解painter至少有三个属性,其中两个是方法。通过嵌入描述对象“形状”的语法,TypeScript 提供了一个出色的、强制性的系统,用于描述对象的外观。
更强大的开发人员工具
TypeScript 的类型允许诸如 VS Code 之类的编辑器更深入地了解您的代码。它们可以利用这些见解在您输入时提供智能建议。这些建议对开发非常有用。
如果您以前使用 VS Code 编写 JavaScript,您可能已经注意到,当您使用内置对象类型(如字符串)编写代码时,它会建议“自动完成”。例如,如果您开始输入已知为字符串的某个成员,TypeScript 可以建议所有字符串的成员(图 1-2)。

图 1-2。TypeScript 为字符串提供 JavaScript 中的自动完成建议
当您为了理解代码添加 TypeScript 的类型检查器时,即使是您编写的代码,它也可以为您提供这些有用的建议。在paintPainting函数中键入painter.时,TypeScript 会知道painter参数的类型是Painter,而Painter类型具有以下成员(图 1-3)。

图 1-3。TypeScript 为字符串提供 JavaScript 中的自动完成建议
太棒了!我将在第十二章,“使用 IDE 功能”中介绍大量其他有用的编辑器功能。
编译语法
TypeScript 的编译器允许我们输入 TypeScript 语法,进行类型检查,并获得等效的 JavaScript 输出。作为一种便利,编译器还可以将现代 JavaScript 语法编译成其较旧的 ECMAScript 等效形式。
如果您将此 TypeScript 代码粘贴到 Playground 中:
const artist = "Augusta Savage";
console.log({ artist });
Playground 会在屏幕右侧显示,编译器输出的等效 JavaScript 代码(图 1-4)。

图 1-4。TypeScript Playground 将 TypeScript 代码编译成等效的 JavaScript
TypeScript Playground 是展示源 TypeScript 如何变成输出 JavaScript 的绝佳工具。
注意
许多 JavaScript 项目使用专门的转译器,如 Babel (https://babeljs.io),而不是 TypeScript 自己的转译器来将源代码转译为可运行的 JavaScript。你可以在 https://learningtypescript.com/starters 上找到常见项目启动器的列表。
在本地开始
只要你的计算机安装了 Node.js,就可以在计算机上运行 TypeScript。要全局安装最新版本的 TypeScript,请运行以下命令:
npm i -g typescript
现在,你可以通过 tsc(TypeScript Compiler)命令在命令行上运行 TypeScript。尝试使用 --version 标志确保它已正确设置:
tsc --version
它应该输出类似 Version X.Y.Z 的东西——安装 TypeScript 时的当前版本:
$ tsc --version
Version 4.7.2
本地运行
现在 TypeScript 已安装好,让我们在本地设置一个文件夹来运行 TypeScript 代码。在你的计算机上的任意位置创建一个文件夹,并运行以下命令来创建一个新的 tsconfig.json 配置文件:
tsc --init
tsconfig.json 文件声明了 TypeScript 在分析你的代码时使用的设置。在这本书中,文件中的大多数选项对你来说并不重要(编程中存在许多罕见的边界案例,语言需要考虑到这些情况!)。我会在 第十三章,“配置选项” 中进行讲解。现在的重要功能是你现在可以运行 tsc 命令来告诉 TypeScript 编译该文件夹中的所有文件,并且 TypeScript 将参考 tsconfig.json 文件中的任何配置选项。
尝试添加一个名为 index.ts 的文件,其中包含以下内容:
console.blub("Nothing is worth more than laughter.");
然后,运行 tsc 并提供 index.ts 文件的名称:
tsc index.ts
你应该会得到一个类似以下的错误:
index.ts:1:9 - error TS2339: Property 'blub' does not exist on type 'Console'.
1 console.blub("Nothing is worth more than laughter.");
~~~~
Found 1 error.
的确,在 console 上不存在 blub。我当时在想什么呢?
在你修复代码以符合 TypeScript 要求之前,请注意,tsc 为你创建了一个名为 index.js 的文件,其中包含了 console.blub。
注意
这是一个重要的概念:尽管我们的代码中存在 类型错误,但 语法 仍然完全有效。TypeScript 编译器仍将从输入文件生成 JavaScript,而不管是否存在任何类型错误。
修正 index.ts 中的代码以调用 console.log 并再次运行 tsc。在你的终端中应该没有任何投诉,并且 index.js 文件现在应包含更新后的输出代码:
console.log("Nothing is worth more than laughter.");
提示
我强烈建议在阅读过程中与书中的代码片段进行互动,可以在 playground 中或者带 TypeScript 支持的编辑器中运行它们。这意味着它会为你运行 TypeScript 语言服务。书中还提供了一些小型的自包含练习和更大的项目,帮助你练习所学的内容,你可以在 https://learningtypescript.com 上找到这些资源。
编辑器功能
创建 tsconfig.json 文件的另一个好处是,当编辑器打开到特定文件夹时,它们现在将识别该文件夹为 TypeScript 项目。例如,如果你在一个文件夹中打开 VS Code,则用于分析你的 TypeScript 代码的设置将尊重该文件夹 tsconfig.json 中的内容。
作为一个练习,回顾本章中的代码片段,并在编辑器中输入它们。当你输入时,你应该看到下拉列表建议为名称(特别是 console 上的 log)完成。
非常激动人心:你正在使用 TypeScript 语言服务帮助自己编写代码!你正在成为一名 TypeScript 开发者的路上!
提示
VS Code 提供了出色的 TypeScript 支持,它本身就是用 TypeScript 构建的。你不一定非要用它来写 TypeScript——几乎所有现代编辑器都具有出色的 TypeScript 支持,无论是内置的还是通过插件提供的——但我建议至少在阅读本书时尝试一下 TypeScript。如果你使用不同的编辑器,我也建议启用其 TypeScript 支持。我将更深入地介绍编辑器功能,见第十二章,“使用 IDE 功能”。
TypeScript 的非所是
现在你已经看到 TypeScript 有多么美妙了,我必须警告你一些局限性。每种工具都在某些领域表现出色,在其他方面则有局限性。
治愈糟糕代码的良方
TypeScript 帮助你组织你的 JavaScript,但除了强制类型安全外,它不会对这种结构有任何看法。
好!
TypeScript 是一种每个人都应该能够使用的语言,而不是针对特定目标受众的主观框架。你可以使用 JavaScript 中习惯的任何架构模式编写代码,TypeScript 将支持它们。
如果有人告诉你 TypeScript 强制使用类,或者使得编写良好代码变得困难,或者对其他任何代码风格抱怨,给他们一个严厉的眼神,并告诉他们去买一本《学习 TypeScript》。TypeScript 不强制代码风格的看法,比如是否使用类或函数,也不与任何特定的应用框架(如 Angular、React 等)相关联。
JavaScript 的扩展(主要是)
TypeScript 的设计目标明确指出它应该:
-
与当前和未来的 ECMAScript 提议保持一致
-
保留所有 JavaScript 代码的运行时行为
TypeScript 并不试图改变 JavaScript 的工作方式。它的创造者们非常努力地避免添加新的代码功能,这些功能可能会增加或与 JavaScript 冲突。这样的任务是 TC39 的领域,即负责 ECMAScript 的技术委员会。
TypeScript 中有一些较早的功能,多年前添加以反映 JavaScript 代码中的常见用例。这些功能大多数情况下要么相对不常见,要么已经不再流行,并且仅在 第十四章,“语法扩展” 中简要介绍。我建议在大多数情况下远离它们。
注意
截至 2022 年,TC39 正在研究向 JavaScript 添加类型注解的语法。最新的提议将它们作为不影响运行时代码的一种形式的注释,仅用于开发时系统,如 TypeScript。将来多年内,JavaScript 可能会添加类型注释或其等效物,因此本书中不会进一步提到。
比 JavaScript 慢
在互联网上有时您可能会听到一些有主观看法的开发者抱怨 TypeScript 在运行时比 JavaScript 慢。这种说法通常是不准确和误导性的。TypeScript 对代码的唯一更改是,如果要求它将您的代码编译成早期版本的 JavaScript 以支持旧的运行时环境,如 Internet Explorer 11。许多生产框架根本不使用 TypeScript 的编译器,而是使用单独的工具进行转译(编译的部分,将源代码从一种编程语言转换为另一种)并且仅用 TypeScript 进行类型检查。
TypeScript 确实会增加编译代码的时间。大多数环境(如浏览器和 Node.js)在运行 TypeScript 代码之前,必须将其编译成 JavaScript。大多数构建管道通常设置为性能损失可以忽略不计,而较慢的 TypeScript 功能(如分析代码以检测可能的错误)是独立于生成可运行应用程序代码文件的。
注意
即使看起来允许直接运行 TypeScript 代码的项目,如 ts-node 和 Deno,在运行之前也会将 TypeScript 代码内部转换为 JavaScript。
结束进化
Web 的进化还远未结束,因此 TypeScript 也未成熟。TypeScript 语言不断接收 bug 修复和功能增强,以满足 Web 社区不断变化的需求。本书中学到的 TypeScript 的基本原则大致保持不变,但错误消息、更复杂的功能和编辑器集成将随时间改进。
事实上,虽然本书的这个版本是基于 TypeScript 4.7.2 发布的,但当您开始阅读时,我们可以肯定已经发布了更新版本。本书中的一些 TypeScript 错误消息甚至可能已经过时!
摘要
在本章中,您将了解 JavaScript 的一些主要弱点的背景,TypeScript 的作用以及如何开始使用 TypeScript:
-
JavaScript 的简短历史
-
JavaScript 的缺点:昂贵的自由、松散的文档和较弱的开发工具
-
TypeScript 是什么:一种编程语言,类型检查器,编译器和语言服务
-
TypeScript 的优势:通过限制获得自由,精确的文档和更强大的开发者工具
-
开始在 TypeScript Playground 和本地计算机上编写 TypeScript 代码
-
TypeScript 并非什么:修复糟糕代码的药方,JavaScript 的扩展(大多数情况下),比 JavaScript 更慢,或者已经完全进化完毕。
提示
当你完成阅读本章之后,请在https://learningtypescript.com/from-javascript-to-typescript上练习你学到的内容。
如果你在运行 TypeScript 编译器时发现错误会发生什么?
你最好去
catch它们!
第二章:类型系统
JavaScript 的能力
来自于灵活性
要小心!
我在第一章,“从 JavaScript 到 TypeScript”中简要谈到了 TypeScript 中存在的“类型检查器”,它查看您的代码,理解它的预期工作方式,并告诉您可能出错的地方。但是类型检查器真正是如何工作的呢?
类型包含什么?
“类型”描述了 JavaScript 值的形状可能是什么。通过“形状”,我的意思是一个值上存在哪些属性和方法,以及内置typeof操作符如何描述它。
例如,当你创建一个初始值为"Aretha"的变量时:
let singer = "Aretha";
TypeScript 可以推断,或者说可以找出,singer变量是string类型。
TypeScript 中最基本的类型对应于 JavaScript 中七种基本类型的一种:
-
null -
undefined -
boolean//true或false -
string//"","Hi!","abc123", … -
number//0,2.1,-4, … -
bigint//0n,2n,-4n, … -
symbol//Symbol(),Symbol("hi"), …
对于这些值中的每一个,TypeScript 理解值的类型是七种基本原始类型之一:
-
null; // null -
undefined; // undefined -
true; // boolean -
"Louise"; // string -
1337; // number -
1337n; // bigint -
Symbol("Franklin"); // symbol
如果你忘记了原始类型的名称,你可以在TypeScript Playground或 IDE 中将一个具有原始值的let变量输入,并将鼠标悬停在变量名上。结果弹出窗将包括原始类型的名称,例如这个屏幕截图显示悬停在一个字符串变量上(见图 2-1)。

图 2-1. TypeScript 显示字符串变量类型的悬停信息
TypeScript 也足够聪明,能够推断出起始值是计算的变量的类型。在这个例子中,TypeScript 知道三元表达式总是产生一个字符串,因此bestSong变量是一个string:
// Inferred type: string
let bestSong = Math.random() > 0.5
? "Chain of Fools"
: "Respect";
回到TypeScript Playground或你的 IDE,尝试将鼠标悬停在bestSong变量上。你应该看到一些信息框或消息,告诉你 TypeScript 已经推断出bestSong变量是string类型(见图 2-2)。

图 2-2. TypeScript 从三元表达式中报告一个let变量作为其string文字类型
注意
回顾 JavaScript 中对象和原始类型的区别:如Boolean和Number类包装它们的原始类型等效物。TypeScript 的最佳实践通常是分别引用小写名称,如boolean和number。
类型系统
类型系统 是编程语言理解程序中构造可能具有的类型的一组规则。
在其核心,TypeScript 的类型系统工作方式是:
-
阅读代码并理解所有存在的类型和值
-
对于每个值,查看其初始声明指示的可能包含的类型
-
对于每个值,查看后续代码中它被使用的所有方式
-
向用户抱怨如果一个值的使用方式与其类型不匹配
让我们详细地走一遍这个类型推断过程。
看下面的片段,其中 TypeScript 正在发出关于错误调用成员属性的类型错误:
let firstName = "Whitney";
firstName.length();
// ~~~~~~
// This expression is not callable.
// Type 'Number' has no call signatures
TypeScript 通过以下顺序对这个投诉进行了处理:
-
阅读代码并理解那里有一个名为
firstName的变量 -
得出
firstName是string类型,因为它的初始值是string,即"Whitney" -
看到代码正试图访问
firstName的.length成员并尝试像调用函数一样调用它 -
抱怨字符串的
.length成员是一个数字,而不是一个函数(它不能像函数一样调用)
理解 TypeScript 的类型系统是理解 TypeScript 代码的重要技能。本章节和本书的其余部分的代码片段将展示 TypeScript 能够从代码中推断出的越来越复杂的类型。
错误类型
在编写 TypeScript 时,你最常遇到的两种“错误”是:
语法
阻止 TypeScript 被转换为 JavaScript
类型
类型检查器检测到的某种不匹配
这两者之间的差异非常重要。
语法错误
语法错误是 TypeScript 检测到无法理解为代码的不正确语法。这些错误会阻止 TypeScript 能够从你的文件正确生成输出的 JavaScript。根据你用于将 TypeScript 代码转换为 JavaScript 的工具和设置,你可能仍会得到某种形式的 JavaScript 输出(在默认的 tsc 设置中,会有)。但如果有的话,它可能不会看起来像你预期的那样。
这个输入 TypeScript 对于意外的 let 有一个语法错误:
let let wat;
// ~~~
// Error: ',' expected.
其编译后的 JavaScript 输出,根据 TypeScript 编译器版本的不同,可能如下所示:
let let, wat;
提示
虽然 TypeScript 会尽力输出 JavaScript 代码,而不管语法错误如何,但输出的代码可能不是你想要的。最好在尝试运行输出的 JavaScript 之前修复语法错误。
类型错误
类型错误发生在语法正确但 TypeScript 类型检查器检测到程序类型存在错误的情况下。这些错误不会阻止 TypeScript 语法被转换为 JavaScript。然而,它们通常表明如果允许代码运行,某些内容可能会崩溃或表现出意外行为。
你在第一章,“从 JavaScript 到 TypeScript”中看到了console.blub示例,代码在语法上是有效的,但 TypeScript 可以检测到运行时可能会崩溃:
console.blub("Nothing is worth more than laughter.");
// ~~~~
// Error: Property 'blub' does not exist on type 'Console'.
尽管 TypeScript 可能会输出 JavaScript 代码,尽管存在类型错误,但类型错误通常表明输出的 JavaScript 可能不会按照您期望的方式运行。最好在运行 JavaScript 之前阅读并考虑修复任何报告的问题。
注意
一些项目配置为在开发过程中阻止运行代码,直到所有 TypeScript 类型错误(不仅仅是语法错误)都被修复。许多开发人员,包括我自己,通常认为这很烦人且不必要。大多数项目都有一种方式可以不被阻止,比如tsconfig.json文件和第十三章,“配置选项”中涵盖的配置选项。
可分配性
TypeScript 读取变量的初始值以确定这些变量允许的类型。如果稍后看到对该变量的新值的赋值,它将检查该新值的类型是否与变量的类型相同。
TypeScript 允许稍后将不同类型的值分配给变量。例如,如果一个变量最初是string值,稍后将其分配为另一个string值是可以的:
let firstName = "Carole";
firstName = "Joan";
如果 TypeScript 看到不同类型的赋值,它会给出类型错误。我们不能,比如,最初声明一个带有string值的变量,然后稍后放入一个boolean值:
let lastName = "King";
lastName = true;
// Error: Type 'boolean' is not assignable to type 'string'.
TypeScript 检查值是否允许提供给函数调用或变量的过程称为可分配性:即该值是否可分配给其传递的预期类型。随着我们比较更复杂的对象,这将是后续章节中的一个重要术语。
理解可分配性错误
在编写 TypeScript 代码时,格式错误“类型…不可分配给类型…”将是您经常看到的错误类型之一。
错误消息中提到的第一个类型是代码试图分配给接收者的值。提到的第二个类型是被分配第一个类型的接收者。例如,在前面的片段中我们写lastName = true时,我们试图分配true值(类型为boolean)给接收变量lastName(类型为string)。
随着您在本书中的进展,您将看到越来越复杂的可分配性问题。请务必仔细阅读,以了解实际类型和预期类型之间报告的差异。这样做将使在 TypeScript 给您带来语法错误时更容易处理。
类型注解
有时变量没有初始值供 TypeScript 读取。TypeScript 不会尝试从后续使用中推断变量的初始类型。它默认将变量隐式视为any类型:表示它可以是世界上的任何东西。
变量无法推断初始类型时会经历所谓的演变 any:而不是强制任何特定类型,TypeScript 将在每次分配新值时演变其对变量类型的理解。
在这里,将演变的any变量rocker首先赋予一个字符串,这意味着它具有toUpperCase等字符串方法,但随后演变为number:
let rocker; // Type: any
rocker = "Joan Jett"; // Type: string
rocker.toUpperCase(); // Ok
rocker = 19.58; // Type: number
rocker.toPrecision(1); // Ok
rocker.toUpperCase();
// ~~~~~~~~~~~
// Error: 'toUpperCase' does not exist on type 'number'.
TypeScript 能够捕获到我们在演变为number类型的变量上调用toUpperCase()方法。但是,在早期它无法告诉我们演变变量从string到number是否是有意的。
允许变量成为演变的any类型,以及通常使用any类型部分地败坏了 TypeScript 类型检查的目的!当 TypeScript 知道你的值意图成为何种类型时,它才能发挥最佳效果。大部分 TypeScript 类型检查无法应用于any类型的值,因为它们没有已知类型进行检查。第十三章,“配置选项”将讨论如何配置 TypeScript 的隐式any投诉。
TypeScript 提供了一种语法来声明变量的类型,而无需为其分配初始值,称为类型注解。类型注解放置在变量名之后,包括冒号后跟类型名称。
此类型注解指示rocker变量应为string类型:
let rocker: string;
rocker = "Joan Jett";
这些类型注解仅适用于 TypeScript——它们不影响运行时代码,也不是有效的 JavaScript 语法。如果你运行tsc将 TypeScript 源代码编译成 JavaScript,它们将被删除。例如,前面的例子将被编译成大致以下 JavaScript 代码:
// output .js file
let rocker;
rocker = "Joan Jett";
将类型不可分配给变量注释类型的值将导致类型错误。
此代码片段将一个数字赋给先前声明为string类型的rocker变量,导致类型错误:
let rocker: string;
rocker = 19.58;
// Error: Type 'number' is not assignable to type 'string'.
你将在接下来的几章中看到,类型注解允许你增强 TypeScript 对你代码的理解,使其在开发过程中为你提供更好的功能。TypeScript 包含各种新的语法片段,比如这些仅存在于类型系统中的类型注解。
注意
任何仅存在于类型系统中的东西都不会复制到生成的 JavaScript 中。TypeScript 类型不影响生成的 JavaScript。
不必要的类型注解
类型注解允许我们向 TypeScript 提供它无法自行获取的信息。你也可以在变量上使用它们,即使这些变量有立即可推断的类型,但你不会告诉 TypeScript 任何它不已经知道的信息。
下面的: string类型注解是多余的,因为 TypeScript 已经能够推断firstName应为string类型:
let firstName: string = "Tina";
// ~~~~~~~~ Does not change the type system...
如果你为一个有初始值的变量添加类型注解,TypeScript 将检查它是否与变量值的类型匹配。
以下的firstName声明为string类型,但其初始化器为number的42,TypeScript 视其为不兼容:
let firstName: string = 42;
// ~~~~~~~~~
// Error: Type 'number' is not assignable to type 'string'.
许多开发者,包括我自己,通常不愿意在类型注解不会改变任何内容的变量上添加类型注解。手动编写类型注解可能很麻烦,特别是在它们改变时,以及在本书后面我将向您展示的复杂类型时。
有时候在变量上包含显式类型注解可能很有用,用以清晰地文档化代码和/或使 TypeScript 免受变量类型意外更改的保护。我们将在后面的章节中看到,显式类型注解有时可以明确告诉 TypeScript 其通常不会推断的信息。
类型形状
TypeScript 不仅检查分配给变量的值是否与其原始类型匹配。TypeScript 还知道对象上应该存在哪些成员属性。如果尝试访问变量的属性,TypeScript 将确保该属性已知存在于该变量的类型上。
假设我们声明了一个类型为string的rapper变量。稍后当我们使用该rapper变量时,TypeScript 允许在字符串上已知的操作:
let rapper = "Queen Latifah";
rapper.length; // ok
TypeScript 不知道能在字符串上工作的操作将不被允许:
rapper.push('!');
// ~~~~
// Property 'push' does not exist on type 'string'.
类型也可以是更复杂的形状,最显著的是对象。在以下片段中,TypeScript 知道birthNames对象没有middleName键并发出警告:
let cher = {
firstName: "Cherilyn",
lastName: "Sarkisian",
};
cher.middleName;
// ~~~~~~~~~~
// Property 'middleName' does not exist on type
// '{ firstName: string; lastName: string; }'.
TypeScript 对对象形状的理解使其能够报告关于对象使用的问题,而不仅仅是可赋值性。第四章,“对象” 将描述 TypeScript 关于对象和对象类型强大功能的更多内容。
模块
直到相对较近的历史时期,JavaScript 编程语言并未包含文件间如何共享代码的规范。ECMAScript 2015 添加了“ECMAScript 模块”或 ESM,以标准化文件间的import和export语法。
供参考,此模块文件从兄弟文件./values导入了一个value并导出了一个doubled变量。
import { value } from "./values";
export const doubled = value * 2;
为了符合 ECMAScript 规范,在本书中我将使用以下命名法:
模块
具有顶级export或import的文件
脚本
任何不是模块的文件
TypeScript 能够处理这些现代模块文件以及较旧的文件。在模块文件中声明的任何内容只在该文件中有效,除非该文件显式地用export语句导出它。一个模块中声明的变量如果与另一个文件中声明的变量同名,则不会被视为命名冲突(除非一个文件导入另一个文件的变量)。
以下的 a.ts 和 b.ts 文件都是导出了一个名为 shared 的相似变量的模块,没有问题。c.ts 导致类型错误,因为它在导入的 shared 和自身的值之间存在命名冲突:
// a.ts
export const shared = "Cher";
// b.ts
export const shared = "Cher";
// c.ts
import { shared } from "./a";
// ~~~~~~
// Error: Import declaration conflicts with local declaration of 'shared'.
export const shared = "Cher";
// ~~~~~~
// Error: Individual declarations in merged declaration
// 'shared' must be all exported or all local.
如果一个文件是脚本,TypeScript 将其视为全局作用域,这意味着所有脚本都可以访问其内容。这意味着在脚本文件中声明的变量不能与其他脚本文件中声明的变量同名。
以下的 a.ts 和 b.ts 文件被视为脚本,因为它们没有模块风格的 export 或 import 声明。这意味着它们的同名变量会彼此冲突,就好像它们是在同一个文件中声明的一样:
// a.ts
const shared = "Cher";
// ~~~~~~
// Cannot redeclare block-scoped variable 'shared'.
// b.ts
const shared = "Cher";
// ~~~~~~
// Cannot redeclare block-scoped variable 'shared'.
如果在 TypeScript 文件中看到这些“Cannot redeclare…”错误,可能是因为您还未在文件中添加 export 或 import 声明。根据 ECMAScript 规范,如果需要一个文件成为模块但没有 export 或 import 声明,可以在文件的某处添加 export {}; 强制将其视为模块:
// a.ts and b.ts
const shared = "Cher"; // Ok
export {};
警告
TypeScript 不会识别使用旧的模块系统(如 CommonJS)编写的 TypeScript 文件中的导入和导出类型。TypeScript 通常会将从 CommonJS 风格的 require 函数返回的值视为 any 类型。
总结
在本章中,您了解了 TypeScript 类型系统的核心工作原理:
-
“类型”是什么以及 TypeScript 所识别的原始类型
-
“类型系统”是什么以及 TypeScript 的类型系统如何理解代码
-
类型错误与语法错误的比较
-
推断变量类型和变量可赋性
-
类型注解用于显式声明变量类型,避免演变为
any类型 -
对类型形状进行对象成员检查
-
ECMAScript 模块文件的声明作用域与脚本文件相比
提示
现在您已经完成了本章的阅读,请在https://learningtypescript.com/the-type-system上练习您所学到的内容。
数字和字符串为什么会分离?
他们不是彼此的类型。
第三章:联合类型和字面量
没有什么是恒定的
值可能随时间变化
(好吧,除了常数)
第二章,“类型系统” 讨论了“类型系统”的概念以及如何读取值以理解变量的类型。现在我想介绍 TypeScript 与之一起工作以进行推断的两个关键概念:
联合
扩展一个值的允许类型为两种或更多种可能的类型
缩小
缩小一个值的允许类型为 不是 一个或多个可能的类型
联合类型和类型缩小的结合是强大的概念,使得 TypeScript 能够做出其他许多主流语言无法做出的有根据的推断。
联合类型
对于这个mathematician变量:
let mathematician = Math.random() > 0.5
? undefined
: "Mark Goldberg";
mathematician是什么类型?
它不仅仅是undefined,也不仅仅是string,尽管它们都是可能的类型。mathematician可以是要么 undefined 要么 string。这种“要么或者”类型称为联合类型。联合类型是一个很棒的概念,它允许我们处理那些我们不确切知道值是哪种类型,但知道它是两个或更多选项之一的代码情况。
TypeScript 使用 |(管道)操作符来表示联合类型的可能值或 组成部分。之前的 mathematician 类型被视为 string | undefined。悬停在 mathematician 变量上会显示其类型为 string | undefined(图 3-1)。

图 3-1。TypeScript 报告 mathematician 变量为 string | undefined 类型
声明联合类型
联合类型是一个例子,当可能需要为一个变量提供显式类型注解时,尽管它有一个初始值。在这个例子中,thinker 起初是 null,但可能包含一个 string。给它一个显式的 string | null 类型注解意味着 TypeScript 允许它被赋予 string 类型的值:
let thinker: string | null = null;
if (Math.random() > 0.5) {
thinker = "Susanne Langer"; // Ok
}
联合类型声明可以放置在你可能声明类型注解的任何地方。
注意
联合类型声明的顺序不重要。你可以写 boolean | number 或 number | boolean,TypeScript 将完全相同对待它们。
联合属性
当一个值已知是联合类型时,TypeScript 只允许你访问所有可能类型中存在的成员属性。如果尝试访问一个不在所有可能类型上存在的类型,它会给你一个类型检查错误。
在下面的代码片段中,physicist 的类型是 number | string。虽然.toString() 在两种类型中都存在并且允许使用,但.toUpperCase() 和 .toFixed() 不允许使用,因为.toUpperCase() 在number类型上缺失,而.toFixed() 在string类型上缺失:
let physicist = Math.random() > 0.5
? "Marie Curie"
: 84;
physicist.toString(); // Ok
physicist.toUpperCase();
// ~~~~~~~~~~~
// Error: Property 'toUpperCase' does not exist on type 'string | number'.
// Property 'toUpperCase' does not exist on type 'number'.
physicist.toFixed();
// ~~~~~~~
// Error: Property 'toFixed' does not exist on type 'string | number'.
// Property 'toFixed' does not exist on type 'string'.
限制访问不在所有联合类型上存在的属性是一种安全措施。 如果对象不确定一定是包含某个属性的类型,TypeScript 将认为尝试使用该属性是不安全的。 属性可能不存在!
要使用仅存在于潜在类型子集上的联合类型值的属性,您的代码需要告诉 TypeScript 该代码位置的值是其中更具体类型之一:这个过程称为 缩小。
缩小
缩小是指 TypeScript 从您的代码推断出一个值的类型比定义、声明或先前推断的类型更具体的情况。 一种可以用于缩小类型的逻辑检查称为 类型守卫。
让我们来讨论 TypeScript 可以使用的两种常见类型守卫,从你的代码中推断出类型缩小的方法。
赋值缩小
如果直接将值分配给变量,则 TypeScript 将缩小变量的类型为该值的类型。
在这里,admiral 变量最初声明为 number | string,但在分配值 "Grace Hopper" 后,TypeScript 知道它必须是 string:
let admiral: number | string;
admiral = "Grace Hopper";
admiral.toUpperCase(); // Ok: string
admiral.toFixed();
// ~~~~~~~
// Error: Property 'toFixed' does not exist on type 'string'.
当变量被赋予明确的联合类型注释和初始值时,赋值缩小就会发挥作用。 TypeScript 将理解,虽然变量后来可能接收联合类型值中的任何一个,但它起始时仅作为其初始值的类型。
在下面的代码片段中,inventor 声明为 number | string 类型,但 TypeScript 知道它立即从其初始值缩小为 string:
let inventor: number | string = "Hedy Lamarr";
inventor.toUpperCase(); // Ok: string
inventor.toFixed();
// ~~~~~~~
// Error: Property 'toFixed' does not exist on type 'string'.
条件检查
缩小变量值的常见方式是编写一个 if 语句,检查变量是否等于已知值。 TypeScript 足够聪明,理解在该 if 语句的主体内,变量必须与已知值相同类型:
// Type of scientist: number | string
let scientist = Math.random() > 0.5
? "Rosalind Franklin"
: 51;
if (scientist === "Rosalind Franklin") {
// Type of scientist: string
scientist.toUpperCase(); // Ok
}
// Type of scientist: number | string
scientist.toUpperCase();
// ~~~~~~~~~~~
// Error: Property 'toUpperCase' does not exist on type 'string | number'.
// Property 'toUpperCase' does not exist on type 'number'.
使用条件逻辑进行缩小显示出 TypeScript 的类型检查逻辑反映了良好的 JavaScript 编码模式。 如果变量可能是多种类型之一,通常会希望检查其类型是否符合需要的类型。 TypeScript 强制我们在代码中保持安全性。 谢谢,TypeScript!
typeof 检查
除了直接值检查外,TypeScript 还识别 typeof 操作符来缩小变量类型。
类似于 scientist 示例,检查 typeof researcher 是否为 "string" 表示 TypeScript 必须将 researcher 的类型确定为 string:
let researcher = Math.random() > 0.5
? "Rosalind Franklin"
: 51;
if (typeof researcher === "string") {
researcher.toUpperCase(); // Ok: string
}
逻辑否定 ! 和 else 语句同样有效:
if (!(typeof researcher === "string")) {
researcher.toFixed(); // Ok: number
} else {
researcher.toUpperCase(); // Ok: string
}
这些代码片段可以使用三元语句进行重写,也支持类型缩小:
typeof researcher === "string"
? researcher.toUpperCase() // Ok: string
: researcher.toFixed(); // Ok: number
无论如何编写它们,typeof 检查都是缩小类型的实用且经常使用的方式。
TypeScript 的类型检查器识别出我们将在后续章节中看到的几种更多的缩小形式。
字面类型
现在我已经展示了联合类型和用于处理可能是两种或更多种潜在类型的值的缩小,我想通过引入 字面类型 来走相反的方向:原始类型的更具体版本。
拿这个 philosopher 变量:
const philosopher = "Hypatia";
philosopher 是什么类型?
乍一看,你可能会说 string —— 你是正确的。 philosopher 确实是一个 string。
但是! philosopher 不只是任何旧的 string。它特别是值 "Hypatia"。因此, philosopher 变量的类型在技术上更具体 "Hypatia"。
这就是 字面类型 的概念:这是一个已知为原始特定值的值类型,而不是所有这些原始值中的任何一个。原始类型 string 表示可能存在的所有可能字符串集合;字面类型 "Hypatia" 仅代表这一个字符串。
如果您将一个变量声明为 const 并直接赋予它一个字面值,TypeScript 将推断该变量为该字面值作为类型。这就是为什么当您将鼠标悬停在具有初始字面值的 const 变量上时,在诸如 VS Code 的 IDE 中,它将显示该变量的类型为该字面值 (图 3-2) 而不是更一般的原始类型 (图 3-3)。

图 3-2. TypeScript 报告一个 const 变量为其字面类型

图 3-3. TypeScript 报告一个 let 变量通常为其原始类型
您可以将每个 原始 类型视为每个可能匹配 字面 值的 联合。换句话说,原始类型是该类型的所有可能字面值的集合。
除了 boolean、null 和 undefined 类型外,所有其他原始类型如 number 和 string 都有无限数量的字面类型。在典型的 TypeScript 代码中,您会发现以下常见类型:
-
boolean:只是true | false -
null和undefined:两者都只有一个字面值,它们自己 -
number:0 | 1 | 2 | ... | 0.1 | 0.2 | ... -
string:"" | "a" | "b" | "c" | ... | "aa" | "ab" | "ac" | ...
联合类型注释可以混合和匹配文字和原语。例如,寿命的表示可以由任何number 或 一对已知的极端情况之一表示:
let lifespan: number | "ongoing" | "uncertain";
lifespan = 89; // Ok
lifespan = "ongoing"; // Ok
lifespan = true;
// Error: Type 'true' is not assignable to
// type 'number | "ongoing" | "uncertain"'
字面赋值
您已经看到了不同的原始类型如 number 和 string 是不可相互分配的。同样,同一原始类型内的不同字面类型 —— 例如 0 和 1 —— 也不能相互分配。
在这个例子中,specificallyAda被声明为字面量类型"Ada",因此可以赋予其值"Ada",但"Byron"和string类型不能赋给它:
let specificallyAda: "Ada";
specificallyAda = "Ada"; // Ok
specificallyAda = "Byron";
// Error: Type '"Byron"' is not assignable to type '"Ada"'.
let someString = ""; // Type: string
specificallyAda = someString;
// Error: Type 'string' is not assignable to type '"Ada"'.
然而,文本类型允许被分配给它们对应的基本类型。任何特定的字面量字符串仍然是string。
在这个代码示例中,类型为":)"的值":)"被赋给之前推断为string类型的someString变量:
someString = ":)";
谁会想到一个简单的变量赋值会如此理论化?
严格空值检查
在处理潜在未定义值时,通过收窄联合与字面量的能力,TypeScript 中类型系统所称的严格空值检查尤为明显。TypeScript 是一波利用严格空值检查来修复可怕“十亿美元错误”的现代编程语言之一。
十亿美元错误
我称它为我的十亿美元错误。这是在 1965 年发明的空引用……这导致了无数的错误、漏洞和系统崩溃,在过去 40 年中可能造成了数十亿美元的痛苦和损失。
Tony Hoare, 2009
“十亿美元错误”是一个行业术语,用于描述许多类型系统允许在需要不同类型的位置使用 null 值的情况。在没有严格空值检查的语言中,像这个示例一样将null赋给string是允许的:
const firstName: string = null;
如果你之前在像 C++或 Java 这样的类型语言中工作过,这些语言因为“十亿美元的错误”而著名,那么某些不允许这种情况发生的语言可能会让你感到惊讶。如果你以前从未在具有严格空值检查的语言中工作过,那么一些允许这种“十亿美元的错误”发生的语言可能会让你感到惊讶!
TypeScript 编译器包含多种选项,允许更改其运行方式。第十三章,“配置选项”将深入讨论 TypeScript 编译器选项。其中最有用的选择性选项之一,strictNullChecks,切换是否启用严格空值检查。粗略地说,禁用strictNullChecks会向代码中的每种类型添加| null | undefined,从而允许任何变量接收null或undefined。
将strictNullChecks选项设置为false时,以下代码被认为是完全类型安全的。然而,这是错误的;nameMaybe在访问.toLowerCase时可能是undefined:
let nameMaybe = Math.random() > 0.5
? "Tony Hoare"
: undefined;
nameMaybe.toLowerCase();
// Potential runtime error: Cannot read property 'toLowerCase' of undefined.
启用严格空值检查后,TypeScript 能够在代码片段中看到潜在的崩溃:
let nameMaybe = Math.random() > 0.5
? "Tony Hoare"
: undefined;
nameMaybe.toLowerCase();
// Error: Object is possibly 'undefined'.
没有启用严格空值检查,很难知道你的代码是否安全,是否会因为意外的null或undefined值而导致错误。
TypeScript 的最佳实践通常是启用严格空值检查。这样做有助于防止崩溃,并消除“十亿美元错误”。
真值收窄
从 JavaScript 中可以回忆起真值或真值性,即在Boolean上下文(如&&操作符或if语句)中,一个值是否被认为是true。JavaScript 中的所有值都是真值,除了被定义为假值的:false、0、-0、0n、""、null、undefined 和 NaN。^(1)
TypeScript 还可以通过真值检查来缩小变量的类型,如果它的一些潜在值可能是真值。在下面的片段中,geneticist 的类型是 string | undefined,因为undefined始终为假,TypeScript 可以推断在if语句体内它必须是string类型:
let geneticist = Math.random() > 0.5
? "Barbara McClintock"
: undefined;
if (geneticist) {
geneticist.toUpperCase(); // Ok: string
}
geneticist.toUpperCase();
// Error: Object is possibly 'undefined'.
执行真值检查的逻辑运算符同样适用,即&& 和 ?.:
geneticist && geneticist.toUpperCase(); // Ok: string | undefined
geneticist?.toUpperCase(); // Ok: string | undefined
不幸的是,真值检查并不适用于反向情况。如果我们知道string | undefined值是假值,这并不能告诉我们它是空字符串还是undefined。
在这里,biologist 的类型是 false | string,虽然它可以在if语句体中缩小为string,但else语句体知道,如果是"",它仍然可以是一个字符串:
let biologist = Math.random() > 0.5 && "Rachel Carson";
if (biologist) {
biologist; // Type: string
} else {
biologist; // Type: false | string
}
没有初始值的变量
在 JavaScript 中,声明而没有初始值的变量默认为undefined。这在类型系统中构成一个特例:如果你声明一个变量为不包含undefined的类型,然后试图在赋值前使用它会怎么样?
TypeScript 足够智能,能理解在赋值前变量是undefined。如果在赋值前尝试使用该变量,例如访问其属性,它将报告一个专门的错误消息:
let mathematician: string;
mathematician?.length;
// Error: Variable 'mathematician' is used before being assigned.
mathematician = "Mark Goldberg";
mathematician.length; // Ok
注意,如果变量的类型包含undefined,这种报告就不适用。向变量的类型添加| undefined表示 TypeScript 在使用前不需要定义它,因为undefined是其值的有效类型。
如果mathematician的类型是string | undefined,则前面的代码片段不会发出任何错误:
let mathematician: string | undefined;
mathematician?.length; // Ok
mathematician = "Mark Goldberg";
mathematician.length; // Ok
类型别名
在代码中,你通常会看到只有两个或三个组成部分的联合类型。然而,有时你可能会发现需要重复输入的长联合类型也是有用的。
这些变量中的每一个可能是四种可能的类型之一:
let rawDataFirst: boolean | number | string | null | undefined;
let rawDataSecond: boolean | number | string | null | undefined;
let rawDataThird: boolean | number | string | null | undefined;
TypeScript 包含了用于为重复使用的类型分配更简单名称的类型别名。类型别名以type关键字开始,接着是一个新名称、=,然后是任何类型。按照惯例,类型别名采用 PascalCase 命名:
type MyName = ...;
类型别名在类型系统中起到复制粘贴的作用。当 TypeScript 遇到类型别名时,它会将其视为你直接输入了别名所引用的实际类型。可以用长联合类型的类型别名重写前面变量的类型注解:
type RawData = boolean | number | string | null | undefined;
let rawDataFirst: RawData;
let rawDataSecond: RawData;
let rawDataThird: RawData;
这样阅读起来轻松多了!
类型别名是 TypeScript 中的一个方便功能,无论是对于复杂的类型还是数组、函数和对象类型。
类型别名不是 JavaScript
类型别名,就像类型注解一样,不会编译到输出的 JavaScript 中。它们纯粹存在于 TypeScript 的类型系统中。
前面的代码片段大致会编译成这样的 JavaScript:
let rawDataFirst;
let rawDataSecond;
let rawDataThird;
因为类型别名纯粹存在于类型系统中,您不能在运行时代码中引用它们。如果您试图访问不存在的内容,TypeScript 会通过类型错误提醒您:
type SomeType = string | undefined;
console.log(SomeType);
// ~~~~~~~~
// Error: 'SomeType' only refers to a type, but is being used as a value here.
类型别名纯粹作为开发时的构造存在。
结合类型别名
类型别名可以引用其他类型别名。有时候,让一个类型别名引用另一个类型别名是很有用的,比如一个类型别名是另一个类型别名中的联合类型(是一个超集)。
这个IdMaybe类型是Id中的类型以及undefined和null的联合:
type Id = number | string;
// Equivalent to: number | string | undefined | null
type IdMaybe = Id | undefined | null;
类型别名不需要按照使用顺序声明。你可以在文件中较早地声明一个类型别名引用稍后在文件中声明的别名。
前面的代码片段可以重写,使得IdMaybe出现在Id之前:
type IdMaybe = Id | undefined | null; // Ok
type Id = number | string;
总结
在本章中,您已经学习了 TypeScript 中的联合类型和字面类型,以及它的类型系统如何从我们代码的结构中推断出更具体(更窄)的类型:
-
联合类型如何表示可能是两种或更多类型之一的值
-
使用类型注解明确指示联合类型
-
类型缩小如何减少值的可能类型
-
const变量与字面类型的变量之间的差异 -
“十亿美元错误”以及 TypeScript 如何处理严格的空值检查
-
使用显式的
| undefined来表示可能不存在的值 -
对于未分配变量,隐式的
| undefined -
使用类型别名以避免重复输入长类型联合
提示
现在您已经完成了本章的阅读,请在https://learningtypescript.com/unions-and-literals上练习您所学到的内容。
为什么
const变量如此严格?它们太过于字面了。
^(1) 在旧版本浏览器兼容性的古怪遗留中,已弃用的document.all对象也被定义为假值。对于本书和作为开发者的您的幸福感,请不要担心document.all。
第四章:对象
对象字面量
一组键和值
每个都有自己的类型
第三章,“联合类型和字面量”详细介绍了联合类型和字面量类型:处理诸如 boolean 这样的原始类型及其值的字面量,例如 true。这些原始类型只是 JavaScript 代码中常用的复杂对象形状的冰山一角。如果 TypeScript 不能表示这些对象,它将显得相当无用。本章将介绍如何描述复杂的对象形状以及 TypeScript 如何检查它们的可赋值性。
对象类型
当您使用 {...} 语法创建对象字面量时,TypeScript 将视其为一个新的对象类型或类型形状,基于其属性。该对象类型将具有与对象值相同的属性名称和原始类型。可以通过 value.member 或等效的 value['member'] 语法访问值的属性。
TypeScript 理解以下 poet 变量的类型是一个具有两个属性 born(类型为 number)和 name(类型为 string)的对象。允许访问这些成员,但尝试访问任何其他成员名称将导致该名称不存在的类型错误:
const poet = {
born: 1935,
name: "Mary Oliver",
};
poet['born']; // Type: number
poet.name; // Type: string
poet.end;
// ~~~
// Error: Property 'end' does not exist on
// type '{ name: string; start: number; }'.
对象类型是 TypeScript 理解 JavaScript 代码的核心概念。除了 null 和 undefined 外的每个值都具有其后台类型形状中的一组成员,因此 TypeScript 必须理解每个值的对象类型以进行类型检查。
声明对象类型
直接从现有对象推断类型很好,但最终您会想要显式声明对象的类型。您需要一种方法来单独描述对象形状,而不是满足它的对象。
可以使用类似对象字面量但字段为类型的语法来描述对象类型。这是 TypeScript 在类型可赋值性错误消息中展示的相同语法。
此 poetLater 变量与之前的 name: string 和 born: number 相同类型:
let poetLater: {
born: number;
name: string;
};
// Ok
poetLater = {
born: 1935,
name: "Mary Oliver",
};
poetLater = "Sappho";
// Error: Type 'string' is not assignable to
// type '{ born: number; name: string; }'
别名对象类型
不断地像 { born: number; name: string; } 这样写出对象类型会很快变得乏味。更常见的做法是使用类型别名为每个类型形状分配一个名称。
前面的代码片段可以用 type Poet 重写,这样做的额外好处是使 TypeScript 的可赋值性错误消息更加直接和可读。
type Poet = {
born: number;
name: string;
};
let poetLater: Poet;
// Ok
poetLater = {
born: 1935,
name: "Sara Teasdale",
};
poetLater = "Emily Dickinson";
// Error: Type 'string' is not assignable to 'Poet'.
注意
大多数 TypeScript 项目更喜欢使用 interface 关键字来描述对象类型,这是一个我不会在 第七章,“接口” 之前介绍的特性。别名对象类型和接口几乎是相同的:本章的所有内容同样适用于接口。
我现在提到这些对象类型,因为理解 TypeScript 如何解释对象文字是学习 TypeScript 类型系统的重要部分。一旦我们转向本书下一节的功能,这些概念将继续非常重要。
结构化类型
TypeScript 的类型系统是结构化类型的:意味着任何满足某个类型的值都允许用作该类型的值。换句话说,当您声明参数或变量为特定对象类型时,您告诉 TypeScript,无论使用哪个对象(们),它们都必须具有这些属性。
以下WithFirstName和WithLastName别名对象类型仅声明了一个名为string的成员。hasBoth变量恰好具有这两个成员,即使它没有明确声明为这样,因此可以提供给声明为两个别名对象类型之一的变量:
type WithFirstName = {
firstName: string;
};
type WithLastName = {
lastName: string;
};
const hasBoth = {
firstName: "Lucille",
lastName: "Clifton",
};
// Ok: `hasBoth` contains a `firstName` property of type `string`
let withFirstName: WithFirstName = hasBoth;
// Ok: `hasBoth` contains a `lastName` property of type `string`
let withLastName: WithLastName = hasBoth;
结构化类型并不等同于鸭子类型,后者源自短语“如果它看起来像鸭子,叫起来像鸭子,那么它可能是鸭子”。
-
结构化类型是静态系统检查类型的一种情况,在 TypeScript 的情况下是类型检查器。
-
鸭子类型是指在运行时使用对象类型之前没有任何检查。
总结:JavaScript是鸭子类型,而TypeScript是结构化类型。
使用检查
当向带有对象类型注释的位置提供值时,TypeScript 会检查该值是否可以分配给该对象类型。首先,该值必须具有对象类型的必需属性。如果对象中缺少对象类型所需的任何成员,则 TypeScript 将发出类型错误。
以下FirstAndLastNames别名对象类型要求first和last属性都存在。包含这两者的对象允许在声明为FirstAndLastNames类型的变量中使用,但没有它们的对象则不允许:
type FirstAndLastNames = {
first: string;
last: string;
};
// Ok
const hasBoth: FirstAndLastNames = {
first: "Sarojini",
last: "Naidu",
};
const hasOnlyOne: FirstAndLastNames = {
first: "Sappho"
};
// Property 'last' is missing in type '{ first: string; }'
// but required in type 'FirstAndLastNames'.
两者之间不允许类型不匹配。对象类型同时指定必需属性的名称和这些属性预期的类型。如果对象的属性不匹配,TypeScript 将报告类型错误。
以下TimeRange类型期望start成员为Date类型。hasStartString对象引起类型错误,因为其start类型为string:
type TimeRange = {
start: Date;
};
const hasStartString: TimeRange = {
start: "1879-02-13",
// Error: Type 'string' is not assignable to type 'Date'.
};
过量属性检查
TypeScript 会在变量声明为对象类型并且其初始值的字段数超过其描述的类型时报告类型错误。因此,声明变量为对象类型是让类型检查器确保该类型仅具有预期字段的一种方式。
以下poetMatch变量具有恰好与由Poet别名的对象类型描述的字段,而extraProperty因具有额外属性而导致类型错误:
type Poet = {
born: number;
name: string;
}
// Ok: all fields match what's expected in Poet
const poetMatch: Poet = {
born: 1928,
name: "Maya Angelou"
};
const extraProperty: Poet = {
activity: "walking",
born: 1935,
name: "Mary Oliver",
};
// Error: Type '{ activity: string; born: number; name: string; }'
// is not assignable to type 'Poet'.
// Object literal may only specify known properties,
// and 'activity' does not exist in type 'Poet'.
请注意,多余属性检查仅在被声明为对象类型的位置创建对象文字时触发。提供现有对象文字会绕过多余属性检查。
此 extraPropertyButOk 变量不会触发前面示例中的 Poet 类型的类型错误,因为其初始值恰好与 Poet 结构匹配:
const existingObject = {
activity: "walking",
born: 1935,
name: "Mary Oliver",
};
const extraPropertyButOk: Poet = existingObject; // Ok
多余属性检查将在任何创建新对象的位置触发,该位置预期该对象与对象类型匹配——正如您将在后面的章节中看到的那样,包括数组成员、类字段和函数参数。禁止多余属性是 TypeScript 帮助确保代码清洁且符合您期望的另一种方式。未在其对象类型中声明的多余属性通常是拼写错误的属性名称或未使用的代码。
嵌套对象类型
由于 JavaScript 对象可以嵌套为其他对象的成员,因此 TypeScript 的对象类型必须能够在类型系统中表示嵌套对象类型。做到这一点的语法与之前相同,但使用 { ... } 对象类型而不是基本名称。
Poem 类型被声明为一个对象,其 author 属性具有 firstName: string 和 lastName: string。poemMatch 变量可以分配给 Poem,因为它匹配该结构,而 poemMismatch 不行,因为其 author 属性包含 name 而不是 firstName 和 lastName:
type Poem = {
author: {
firstName: string;
lastName: string;
};
name: string;
};
// Ok
const poemMatch: Poem = {
author: {
firstName: "Sylvia",
lastName: "Plath",
},
name: "Lady Lazarus",
};
const poemMismatch: Poem = {
author: {
name: "Sylvia Plath",
},
// Error: Type '{ name: string; }' is not assignable
// to type '{ firstName: string; lastName: string; }'.
// Object literal may only specify known properties, and 'name'
// does not exist in type '{ firstName: string; lastName: string; }'.
name: "Tulips",
};
另一种编写type Poem的方法是将author属性的形状提取为其自己的别名对象类型Author。将嵌套类型提取为它们自己的类型别名也有助于 TypeScript 提供更具信息性的类型错误消息。在这种情况下,它可以说 'Author' 而不是 '{ firstName: string; lastName: string; }':
type Author = {
firstName: string;
lastName: string;
};
type Poem = {
author: Author;
name: string;
};
const poemMismatch: Poem = {
author: {
name: "Sylvia Plath",
},
// Error: Type '{ name: string; }' is not assignable to type 'Author'.
// Object literal may only specify known properties,
// and 'name' does not exist in type 'Author'.
name: "Tulips",
};
提示
通常最好像这样将嵌套对象类型移到它们自己的类型名称中,这样做不仅可以使代码更可读,还可以使 TypeScript 错误消息更易于理解。
您将在后面的章节中看到,对象类型成员可以是其他类型,如数组和函数。
可选属性
对象类型属性并不都必须在对象中是必需的。您可以在类型属性的类型注释中的:之前包含?来指示它是一个可选属性。
此 Book 类型仅需要一个 pages 属性,并可选允许一个 author。符合此类型的对象可以提供 author 或将其省略,只要它们提供 pages:
type Book = {
author?: string;
pages: number;
};
// Ok
const ok: Book = {
author: "Rita Dove",
pages: 80,
};
const missing: Book = {
author: "Rita Dove",
};
// Error: Property 'pages' is missing in type
// '{ author: string; }' but required in type 'Book'.
请记住,可选属性和类型联合中包含 undefined 的属性之间存在差异。用 ? 声明的可选属性允许不存在。用 | undefined 声明为必需的属性必须存在,即使值为 undefined。
在以下 Writers 类型中,editor 属性可以在声明变量时被省略,因为在其声明中有 ?。author 属性没有 ?,因此它必须存在,即使其值只是 undefined:
type Writers = {
author: string | undefined;
editor?: string;
};
// Ok: author is provided as undefined
const hasRequired: Writers = {
author: undefined,
};
const missingRequired: Writers = {};
// ~~~~~~~~~~~~~~~
// Error: Property 'author' is missing in type
// '{}' but required in type 'Writers'.
第七章,“接口” 将进一步讨论其他类型属性,而第十三章,“配置选项”将描述 TypeScript 关于可选属性的严格设置。
对象类型的联合
在 TypeScript 代码中,希望能够描述一种类型,它可以是一个或多个具有稍有不同属性的不同对象类型是合理的。此外,你的代码可能希望能够基于属性值来在这些对象类型之间进行类型缩小。
推断的对象类型联合
如果一个变量被赋予一个可能是多种对象类型之一的初始值,TypeScript 将推断其类型为对象类型的联合。该联合类型将为每种可能的对象形状包含一个成员。类型上的每种可能属性在这些成员中都将存在,尽管它们在任何没有初始值的类型上将是 ? 可选类型。
这个 poem 值始终具有类型为 string 的 name 属性,并且可能具有 pages 和 rhymes 属性,也可能没有:
const poem = Math.random() > 0.5
? { name: "The Double Image", pages: 7 }
: { name: "Her Kind", rhymes: true };
// Type:
// {
// name: string;
// pages: number;
// rhymes?: undefined;
// }
// |
// {
// name: string;
// pages?: undefined;
// rhymes: boolean;
// }
poem.name; // string
poem.pages; // number | undefined
poem.rhymes; // booleans | undefined
显式对象类型联合
或者,你可以通过显式地使用自己的对象类型联合更加明确地描述你的对象类型。这样做需要编写更多的代码,但带来的好处是更多地控制你的对象类型。尤其值得注意的是,如果一个值的类型是对象类型的联合,TypeScript 的类型系统将只允许访问所有这些联合类型上存在的属性。
先前 poem 变量的这个版本显式地被类型化为一个联合类型,它总是具有 always 属性以及 pages 或 rhymes 中的一个。允许访问 names 是因为它总是存在的,但不能保证 pages 和 rhymes 的存在:
type PoemWithPages = {
name: string;
pages: number;
};
type PoemWithRhymes = {
name: string;
rhymes: boolean;
};
type Poem = PoemWithPages | PoemWithRhymes;
const poem: Poem = Math.random() > 0.5
? { name: "The Double Image", pages: 7 }
: { name: "Her Kind", rhymes: true };
poem.name; // Ok
poem.pages;
// ~~~~~
// Property 'pages' does not exist on type 'Poem'.
// Property 'pages' does not exist on type 'PoemWithRhymes'.
poem.rhymes;
// ~~~~~~
// Property 'rhymes' does not exist on type 'Poem'.
// Property 'rhymes' does not exist on type 'PoemWithPages'.
限制对对象可能不存在的成员的访问对于代码安全性来说是一件好事。如果一个值可能是多种类型之一,那么并非所有这些类型上都存在的属性在对象上并不保证存在。
正如文字和/或原始类型的联合必须被类型缩小以访问并非存在于所有类型成员上的属性一样,你需要缩小那些对象类型的联合。
缩小对象类型
如果类型检查器看到代码的某个区域只能在联合类型值包含某个属性时运行,它将缩小该值的类型,以仅包含包含该属性的成员。换句话说,如果在代码中检查它们的形状,TypeScript 的类型缩小将适用于对象。
继续使用显式类型化的 poem 示例,检查 poem 中的 "pages" in poem 是否作为 TypeScript 的类型守卫表明它是一个 PoemWithPages。如果 poem 不是 PoemWithPages,那么它必须是 PoemWithRhymes:
if ("pages" in poem) {
poem.pages; // Ok: poem is narrowed to PoemWithPages
} else {
poem.rhymes; // Ok: poem is narrowed to PoemWithRhymes
}
注意,TypeScript 不允许类似 if (poem.pages) 的真值存在检查。试图访问一个可能不存在的对象属性被视为类型错误,即使看起来像是类型保护的一种方式:
if (poem.pages) { /* ... */ }
// ~~~~~
// Property 'pages' does not exist on type 'PoemWithPages | PoemWithRhymes'.
// Property 'pages' does not exist on type 'PoemWithRhymes'.
辨别联合
JavaScript 和 TypeScript 中另一种流行的联合类型对象形式是在对象上有一个属性指示对象的形状。这种类型形状称为 辨别联合,其值指示对象类型的属性称为 辨别符。TypeScript 能够对辨别属性进行类型缩小,从而进行类型收窄。
例如,这个 Poem 类型描述了一个可以是 PoemWithPages 类型或 PoemWithRhymes 类型的对象,而 type 属性指示其中一个。如果 poem.type 是 "pages",那么 TypeScript 能够推断出 poem 的类型必须是 PoemWithPages。如果没有这种类型收窄,值上既不保证任何一个属性的存在:
type PoemWithPages = {
name: string;
pages: number;
type: 'pages';
};
type PoemWithRhymes = {
name: string;
rhymes: boolean;
type: 'rhymes';
};
type Poem = PoemWithPages | PoemWithRhymes;
const poem: Poem = Math.random() > 0.5
? { name: "The Double Image", pages: 7, type: "pages" }
: { name: "Her Kind", rhymes: true, type: "rhymes" };
if (poem.type === "pages") {
console.log(`It's got pages: ${poem.pages}`); // Ok
} else {
console.log(`It rhymes: ${poem.rhymes}`);
}
poem.type; // Type: 'pages' | 'rhymes'
poem.pages;
// ~~~~~
// Error: Property 'pages' does not exist on type 'Poem'.
// Property 'pages' does not exist on type 'PoemWithRhymes'.
辨别联合是我在 TypeScript 中最喜欢的功能,因为它们优雅地结合了 JavaScript 中常见的优雅模式与 TypeScript 的类型收窄。第十章,“泛型” 及其相关项目将展示更多关于如何使用辨别联合进行通用数据操作。
交集类型
TypeScript 的 | 联合类型代表一个值可以是两种或多种不同类型中的一种。正如 JavaScript 的运行时 | 运算符是其 & 运算符的对应物,TypeScript 允许表示一个同时为多种类型的类型:一个 & 交集类型。交集类型通常与别名对象类型一起使用,以创建一个结合多个现有对象类型的新类型。
下面的 Artwork 和 Writing 类型用于形成一个合并的 WrittenArt 类型,具有 genre、name 和 pages 属性:
type Artwork = {
genre: string;
name: string;
};
type Writing = {
pages: number;
name: string;
};
type WrittenArt = Artwork & Writing;
// Equivalent to:
// {
// genre: string;
// name: string;
// pages: number;
// }
交集类型可以与联合类型结合使用,这在描述一个类型的辨别联合时有时是很有用的。
这种 ShortPoem 类型始终具有 author 属性,并且还是一个基于 type 属性的辨别联合:
type ShortPoem = { author: string } & (
| { kigo: string; type: "haiku"; }
| { meter: number; type: "villanelle"; }
);
// Ok
const morningGlory: ShortPoem = {
author: "Fukuda Chiyo-ni",
kigo: "Morning Glory",
type: "haiku",
};
const oneArt: ShortPoem = {
author: "Elizabeth Bishop",
type: "villanelle",
};
// Error: Type '{ author: string; type: "villanelle"; }'
// is not assignable to type 'ShortPoem'.
// Type '{ author: string; type: "villanelle"; }' is not assignable to
// type '{ author: string; } & { meter: number; type: "villanelle"; }'.
// Property 'meter' is missing in type '{ author: string; type: "villanelle"; }'
// but required in type '{ meter: number; type: "villanelle"; }'.
交集类型的危险
交集类型是一个有用的概念,但是在使用它们时很容易让自己或 TypeScript 编译器感到困惑。我建议在使用它们时尽量保持代码简单。
长的可赋值性错误
当你创建复杂的交集类型(例如与联合类型结合)时,TypeScript 给出的可赋值性错误消息会变得更难理解。这将是 TypeScript 类型系统(以及一般类型化编程语言)的一个常见主题:你的代码越复杂,理解类型检查器消息的难度就越大。
在前面代码片段的 ShortPoem 情况下,将类型拆分成一系列别名对象类型将会更易读,允许 TypeScript 打印这些名称:
type ShortPoemBase = { author: string };
type Haiku = ShortPoemBase & { kigo: string; type: "haiku" };
type Villanelle = ShortPoemBase & { meter: number; type: "villanelle" };
type ShortPoem = Haiku | Villanelle;
const oneArt: ShortPoem = {
author: "Elizabeth Bishop",
type: "villanelle",
};
// Type '{ author: string; type: "villanelle"; }'
// is not assignable to type 'ShortPoem'.
// Type '{ author: string; type: "villanelle"; }'
// is not assignable to type 'Villanelle'.
// Property 'meter' is missing in type
// '{ author: string; type: "villanelle"; }'
// but required in type '{ meter: number; type: "villanelle"; }'.
永不
交叉类型也很容易被误用,并创建一个不可能存在的类型。原始类型不能作为交叉类型中的组成部分,因为一个值不可能同时是多个原始类型。尝试将两个原始类型用&连接在一起将导致never类型,由关键字never表示:
type NotPossible = number & string;
// Type: never
never关键字和类型是编程语言中所谓的底部类型或空类型。底部类型是一种没有可能值且无法到达的类型。不能为类型为底部类型的位置提供任何类型:
let notNumber: NotPossible = 0;
// ~~~~~~~~~
// Error: Type 'number' is not assignable to type 'never'.
let notString: never = "";
// ~~~~~~~~~
// Error: Type 'string' is not assignable to type 'never'.
大多数 TypeScript 项目很少——甚至从不——使用never类型。它偶尔会出现在代码中表示不可能的状态。不过,大部分时间,这可能是误用交叉类型的错误。我将在第十五章,“类型操作”中更详细地介绍它。
总结
在本章中,您扩展了对 TypeScript 类型系统的理解,以便能够处理对象:
-
TypeScript 如何从对象类型字面量中解释类型
-
描述对象字面类型,包括嵌套和可选属性
-
使用对象字面类型的联合进行声明、推断和类型缩小
-
区分联合和鉴别联合
-
将对象类型与交叉类型结合在一起
提示
现在您已经完成了本章的阅读,请在https://learningtypescript.com/objects上练习所学内容。
律师如何声明他们的 TypeScript 类型?
“我反对!”
第二部分:特性
第五章:函数
函数参数
从一端进,从另一端出
作为返回类型
在 第二章,“类型系统” 中,你了解了如何使用类型注解注释变量的值。现在,你将看到如何对函数参数和返回类型进行相同操作——以及为什么这样做很有用。
函数参数
看下面的 sing 函数,它接受一个 song 参数并将其记录下来:
function sing(song) {
console.log(`Singing: ${song}!`);
}
开发 sing 函数的开发者意图为 song 参数提供什么值类型?
这是一个 string 吗?还是一个带有覆盖的 toString() 方法的对象?这段代码有 bug 吗?谁知道呢?!
如果没有显式声明类型信息,我们可能永远不会知道——TypeScript 将其视为 any 类型,这意味着参数的类型可以是任何东西。
与变量类似,TypeScript 允许你使用类型注解声明函数参数的类型。现在我们可以使用 : string 告诉 TypeScript song 参数是 string 类型:
function sing(song: string) {
console.log(`Singing: ${song}!`);
}
更好:现在我们知道 song 是什么类型了!
注意,你不需要为函数参数添加正确的类型注解使代码成为有效的 TypeScript 语法。TypeScript 可能会报错,但生成的 JavaScript 仍然会运行。前面的代码片段缺少对 song 参数的类型声明仍会从 TypeScript 转换为 JavaScript。第十三章,“配置选项” 将讨论如何配置 TypeScript 对隐式为 any 类型的参数(如 song)的投诉方式。
必需参数
与 JavaScript 不同,它允许调用带有任意数量参数的函数,TypeScript 假定在函数上声明的所有参数都是必需的。如果使用错误数量的参数调用函数,TypeScript 将以类型错误的形式抗议。TypeScript 的参数计数将在函数调用时出现参数数目不足或过多的情况。
这个 singTwo 函数需要两个参数,因此传递一个参数和传递三个参数都是不允许的:
function singTwo(first: string, second: string) {
console.log(`${first} / ${second}`);
}
// Logs: "Ball and Chain / undefined"
singTwo("Ball and Chain");
// ~~~~~~~~~~~~~~~~
// Error: Expected 2 arguments, but got 1.
// Logs: "I Will Survive / Higher Love"
singTwo("I Will Survive", "Higher Love"); // Ok
// Logs: "Go Your Own Way / The Chain"
singTwo("Go Your Own Way", "The Chain", "Dreams");
// ~~~~~~~~
// Error: Expected 2 arguments, but got 3.
强制要求函数提供必需的参数有助于通过确保所有预期的参数值存在于函数内部来强制执行类型安全性。未能确保这些值存在可能会导致代码中的意外行为,例如前面的 singTwo 函数记录 undefined 或忽略参数。
注意
参数 指的是函数声明期望接收的参数。参数值 是在函数调用中提供给参数的值。在前面的例子中,first 和 second 是参数,而像 "Dreams" 这样的字符串是参数值。
可选参数
请记住,在 JavaScript 中,如果未提供函数参数,则函数内部的参数值默认为 undefined。有时不需要提供函数参数,函数的预期用途是该 undefined 值。我们不希望 TypeScript 报告未提供这些可选参数的参数错误。 TypeScript 允许通过在类型标注中的 : 前面添加 ? 将参数标注为可选,这与可选对象类型属性类似。
可选参数不需要在函数调用时提供。因此,它们的类型总是添加 | undefined 作为联合类型。
在下面的 announceSong 函数中,singer 参数被标记为可选。它的类型是 string | undefined,并且不需要由函数调用者提供。如果提供了 singer,它可以是 string 类型或 undefined:
function announceSong(song: string, singer?: string) {
console.log(`Song: ${song}`);
if (singer) {
console.log(`Singer: ${singer}`);
}
}
announceSong("Greensleeves"); // Ok
announceSong("Greensleeves", undefined); // Ok
announceSong("Chandelier", "Sia"); // Ok
这些可选参数始终隐式可以是 undefined。在前面的代码中,singer 最初被视为 string | undefined 类型,然后由 if 语句缩小为仅 string 类型。
可选参数与包括 | undefined 的联合类型的参数不同。没有用 ? 标记为可选的参数必须始终提供,即使值明确为 undefined。
在 announceSongBy 函数中,singer 参数必须显式提供。它可以是 string 类型或 undefined:
function announceSongBy(song: string, singer: string | undefined) { /* ... */ }
announceSongBy("Greensleeves");
// Error: Expected 2 arguments, but got 1.
announceSongBy("Greensleeves", undefined); // Ok
announceSongBy("Chandelier", "Sia"); // Ok
函数的任何可选参数必须是最后的参数。将可选参数放在必需参数之前会触发 TypeScript 语法错误:
function announceSinger(singer?: string, song: string) {}
// ~~~~
// Error: A required parameter cannot follow an optional parameter.
默认参数
JavaScript 中的可选参数可以在其声明中用 = 和一个值给出默认值。对于这些可选参数,因为默认情况下提供了一个值,它们的 TypeScript 类型在函数内部不会隐式添加 | undefined 联合。 TypeScript 仍然允许以缺少或 undefined 参数调用这些参数的函数。
TypeScript 的类型推断对默认函数参数值的工作方式与对初始变量值的工作方式类似。如果参数有默认值并且没有类型标注,TypeScript 将根据默认值推断参数的类型。
在下面的 rateSong 函数中,rating 被推断为 number 类型,但在调用函数的代码中是可选的 number | undefined:
function rateSong(song: string, rating = 0) {
console.log(`${song} gets ${rating}/5 stars!`);
}
rateSong("Photograph"); // Ok
rateSong("Set Fire to the Rain", 5); // Ok
rateSong("Set Fire to the Rain", undefined); // Ok
rateSong("At Last!", "100");
// ~~~~~
// Error: Argument of type '"100"' is not assignable
// to parameter of type 'number | undefined'.
剩余参数
JavaScript 中的某些函数可以用任意数量的参数调用。可以在函数声明的最后一个参数上放置 ... 展开操作符,以指示从该参数开始传递给函数的所有“剩余”参数应该存储在一个单独的数组中。
TypeScript 允许声明这些剩余参数的类型方式与常规参数类似,只是在末尾添加了 [] 语法,以指示它是一个参数数组。
在这里,singAllTheSongs可以接受零个或多个类型为string的参数作为它的songs剩余参数:
function singAllTheSongs(singer: string, ...songs: string[]) {
for (const song of songs) {
console.log(`${song}, by ${singer}`);
}
}
singAllTheSongs("Alicia Keys"); // Ok
singAllTheSongs("Lady Gaga", "Bad Romance", "Just Dance", "Poker Face"); // Ok
singAllTheSongs("Ella Fitzgerald", 2000);
// ~~~~
// Error: Argument of type 'number' is not
// assignable to parameter of type 'string'.
我将在第六章,“数组”中介绍在 TypeScript 中处理数组的方法。
返回类型
TypeScript 是有洞察力的:如果它理解函数可能返回的所有可能值,它将知道函数的返回类型。在这个例子中,TypeScript 理解singSongs返回一个number:
// Type: (songs: string[]) => number
function singSongs(songs: string[]) {
for (const song of songs) {
console.log(`${song}`);
}
return songs.length;
}
如果一个函数包含多个不同值的return语句,TypeScript 将推断返回类型为所有可能返回类型的联合。
这个getSongAt函数将被推断返回string | undefined,因为它的两个可能返回值分别是string和undefined:
// Type: (songs: string[], index: number) => string | undefined
function getSongAt(songs: string[], index: number) {
return index < songs.length
? songs[index]
: undefined;
}
显式返回类型
与变量一样,我通常建议不必为具有类型注释的函数显式声明返回类型。然而,有几种情况下这样做可能会有用:
-
你可能希望强制函数返回多种可能值时始终返回相同类型的值。
-
TypeScript 将拒绝尝试推理递归函数的返回类型。
-
它可以加快在非常庞大的项目中 TypeScript 的类型检查速度——例如那些包含数百个 TypeScript 文件或更多的项目。
函数声明的返回类型注释放置在参数列表后的)之后。
对于函数声明,这是在{之前:
function singSongsRecursive(songs: string[], count = 0): number {
return songs.length ? singSongsRecursive(songs.slice(1), count + 1) : count;
}
对于箭头函数(也称为 lambda 函数),这是在=>之前:
const singSongsRecursive = (songs: string[], count = 0): number =>
songs.length ? singSongsRecursive(songs.slice(1), count + 1) : count;
如果函数中的return语句返回一个不可分配给函数返回类型的值,TypeScript 将会给出一个可分配性的投诉。
在这里,getSongRecordingDate函数明确声明为返回Date | undefined,但其中一个返回语句错误地提供了一个string:
function getSongRecordingDate(song: string): Date | undefined {
switch (song) {
case "Strange Fruit":
return new Date('April 20, 1939'); // Ok
case "Greensleeves":
return "unknown";
// Error: Type 'string' is not assignable to type 'Date'.
default:
return undefined; // Ok
}
}
函数类型
JavaScript 允许我们将函数作为值传递。这意味着我们需要一种声明参数或变量类型的方式,以便保存函数。
函数类型的语法看起来类似于箭头函数,但其主体是一个类型而不是一个实现。
这个nothingInGivesString变量的类型描述了一个没有参数并返回string值的函数:
let nothingInGivesString: () => string;
这个inputAndOutput变量的类型描述了一个带有string[]参数、可选的count参数以及返回number值的函数:
let inputAndOutput: (songs: string[], count?: number) => number;
函数类型经常用于描述回调参数(意味着将作为函数调用的参数)。
例如,以下runOnSongs片段声明了它的getSongAt参数的类型为一个接受index: number并返回string的函数。传递getSongAt符合该类型,但logSong因为接受了一个string而不是number作为其参数而失败:
const songs = ["Juice", "Shake It Off", "What's Up"];
function runOnSongs(getSongAt: (index: number) => string) {
for (let i = 0; i < songs.length; i += 1) {
console.log(getSongAt(i));
}
}
function getSongAt(index: number) {
return `${songs[index]}`;
}
runOnSongs(getSongAt); // Ok
function logSong(song: string) {
return `${song}`;
}
runOnSongs(logSong);
// ~~~~~~~
// Error: Argument of type '(song: string) => string' is not
// assignable to parameter of type '(index: number) => string'.
// Types of parameters 'song' and 'index' are incompatible.
// Type 'number' is not assignable to type 'string'.
runOnSongs(logSong)的错误消息是一个可赋值性错误的例子,其中包含几个层次的细节。当投诉两个函数类型不可分配给彼此时,TypeScript 通常会给出三个层次的详细信息,具体性逐级增加:
-
第一个缩进级别打印出这两种函数类型。
-
下一个缩进级别指定了哪个部分不匹配。
-
最后一个缩进级别是不匹配部分的精确可赋性投诉。
在上一个代码片段中,这些层次是:
-
logSong的(strong: string) => string是被赋予的类型,被分配给getSongAt: (index: number) => string的接收者 -
logSong的song参数被分配给getSongAt的index参数 -
song的number类型不可赋给index的string类型
提示
TypeScript 的多行错误一开始可能看起来令人生畏。逐行阅读并理解每个部分所传达的内容对于理解错误有很大帮助。
函数类型括号
函数类型可以放置在其他任何类型可以使用的地方。这包括联合类型。
在联合类型中,括号可以用来指示注释的哪一部分是函数返回还是周围的联合类型:
// Type is a function that returns a union: string | undefined
let returnsStringOrUndefined: () => string | undefined;
// Type is either undefined or a function that returns a string
let maybeReturnsString: (() => string) | undefined;
后续介绍更多类型语法的章节将展示函数类型必须用括号包裹的其他位置。
参数类型推断
如果我们必须为我们编写的每个函数声明参数类型,包括用作参数的内联函数,那将是很麻烦的。幸运的是,TypeScript 可以推断函数中参数的类型,前提是这些函数被提供给带有声明类型的位置。
这个singer变量被知道是一个接受string类型参数的函数,所以后来分配给singer的函数中的song参数被知道是一个string:
let singer: (song: string) => string;
singer = function (song) {
// Type of song: string
return `Singing: ${song.toUpperCase()}!`; // Ok
};
函数作为具有函数参数类型的参数传递时,它们的参数类型也将被推断。
例如,在这里,TypeScript 推断song和index参数分别为string和number:
const songs = ["Call Me", "Jolene", "The Chain"];
// song: string
// index: number
songs.forEach((song, index) => {
console.log(`${song} is at index ${index}`);
});
函数类型别名
记得第三章“联合和字面值”中的类型别名吗?它们也可以用于函数类型。
这个StringToNumber类型别名了一个接受string并返回number的函数,这意味着它可以用来描述变量的类型:
type StringToNumber = (input: string) => number;
let stringToNumber: StringToNumber;
stringToNumber = (input) => input.length; // Ok
stringToNumber = (input) => input.toUpperCase();
// ~~~~~~~~~~~~~~~~~~~
// Error: Type 'string' is not assignable to type 'number'.
同样,函数参数本身可以用别名进行类型化,这些别名碰巧指向一个函数类型。
这个usesNumberToString函数有一个参数,它本身是NumberToString别名的函数类型:
type NumberToString = (input: number) => string;
function usesNumberToString(numberToString: NumberToString) {
console.log(`The string is: ${numberToString(1234)}`);
}
usesNumberToString((input) => `${input}! Hooray!`); // Ok
usesNumberToString((input) => input * 2);
// ~~~~~~~~~
// Error: Type 'number' is not assignable to type 'string'.
类型别名在函数类型中特别有用。它们可以节省大量水平空间,而无需重复编写参数和/或返回类型。
更多返回类型
现在,让我们看看两种更多的返回类型:void和never。
空返回
有些函数不打算返回任何值。它们要么没有return语句,要么只有不返回值的return语句。TypeScript 允许使用void关键字来指代返回什么都没有的函数的返回类型。
返回类型为void的函数可能不会返回值。这个logSong函数被声明为返回void,因此不允许返回值:
function logSong(song: string | undefined): void {
if (!song) {
return; // Ok
}
console.log(`${song}`);
return true;
// Error: Type 'boolean' is not assignable to type 'void'.
}
void可以作为函数类型声明中的返回类型很有用。在函数类型声明中使用时,void表示函数的任何返回值都将被忽略。
例如,这个songLogger变量表示一个接受song: string并且不返回值的函数:
let songLogger: (song: string) => void;
songLogger = (song) => {
console.log(`${songs}`);
};
songLogger("Heart of Glass"); // Ok
请注意,尽管 JavaScript 函数如果没有返回真正的值,默认都会返回undefined,但void并不等同于undefined。void表示函数的返回类型将被忽略,而undefined是要返回的字面值。试图将void类型的值分配给其类型包括undefined的值是一种类型错误:
function returnsVoid() {
return;
}
let lazyValue: string | undefined;
lazyValue = returnsVoid();
// Error: Type 'void' is not assignable to type 'string | undefined'.
undefined和void返回之间的区别对于忽略从传递给返回类型声明为void的位置的函数返回的任何值特别有用。例如,数组上的内置forEach方法接受一个返回void的回调。提供给forEach的函数可以返回任何值。以下saveRecords函数中的records.push(record)返回一个number(从数组的.push()返回的值),但仍然允许作为传递给newRecords.forEach的箭头函数的返回值:
const records: string[] = [];
function saveRecords(newRecords: string[]) {
newRecords.forEach(record => records.push(record));
}
saveRecords(['21', 'Come On Over', 'The Bodyguard'])
void类型不是 JavaScript。它是 TypeScript 中用于声明函数返回类型的关键字。记住,它表示函数的返回值不打算被使用,而不是可以被返回的值。
永不返回
有些函数不仅不返回值,而且根本不打算返回。永不返回的函数是那些总是抛出错误或运行无限循环的函数(希望是有意的!)。
如果一个函数永远不会返回,添加显式的: never类型注释表示调用该函数后的任何代码都不会运行。这个fail函数只会抛出错误,因此它可以帮助 TypeScript 的控制流分析将param缩小到string:
function fail(message: string): never {
throw new Error(`Invariant failure: ${message}.`);
}
function workWithUnsafeParam(param: unknown) {
if (typeof param !== "string") {
fail(`param should be a string, not ${typeof param}`);
}
// Here, param is known to be type string
param.toUpperCase(); // Ok
}
注意
never不同于void。void用于不返回任何内容的函数。never用于永远不返回的函数。
函数重载
一些 JavaScript 函数可以使用完全不同的参数集调用,这些参数集不能仅通过可选和/或剩余参数表示。这些函数可以用 TypeScript 语法描述为重载签名:在最终的实现签名和函数体之前多次声明函数名称、参数和返回类型的不同版本。
在确定是否为重载函数调用发出语法错误时,TypeScript 只会查看函数的重载签名。实现签名仅由函数的内部逻辑使用。
此 createDate 函数旨在使用一个 timestamp 参数或三个参数 month、day 和 year 来调用。允许使用这些参数数量中的任何一个进行调用,但是如果使用两个参数进行调用,则会导致类型错误,因为没有重载签名允许两个参数。在此示例中,前两行是重载签名,第三行是实现签名:
function createDate(timestamp: number): Date;
function createDate(month: number, day: number, year: number): Date;
function createDate(monthOrTimestamp: number, day?: number, year?: number) {
return day === undefined || year === undefined
? new Date(monthOrTimestamp)
: new Date(year, monthOrTimestamp, day);
}
createDate(554356800); // Ok
createDate(7, 27, 1987); // Ok
createDate(4, 1);
// Error: No overload expects 2 arguments, but overloads
// do exist that expect either 1 or 3 arguments.
重载签名与其他类型系统语法一样,在将 TypeScript 编译为输出 JavaScript 时会被擦除。
上述代码片段的函数将编译为大致以下 JavaScript:
function createDate(monthOrTimestamp, day, year) {
return day === undefined || year === undefined
? new Date(monthOrTimestamp)
: new Date(year, monthOrTimestamp, day);
}
警告。
函数重载通常用作复杂、难以描述的函数类型的最后手段。通常最好保持函数简单,并在可能时避免使用函数重载。
调用签名兼容性。
用于重载函数实现的实现签名是函数实现使用的参数类型和返回类型。因此,函数重载签名中的返回类型和每个参数必须可分配给其实现签名中相同索引处的参数。换句话说,实现签名必须与所有重载签名兼容。
此 format 函数的实现签名声明其第一个参数为 string。虽然前两个重载签名也兼容于类型 string,但第三个重载签名的 () => string 类型是不兼容的:
function format(data: string): string; // Ok
function format(data: string, needle: string, haystack: string): string; // Ok
function format(getData: () => string): string;
// ~~~~~~
// This overload signature is not compatible with its implementation signature.
function format(data: string, needle?: string, haystack?: string) {
return needle && haystack ? data.replace(needle, haystack) : data;
}
总结。
在本章中,您看到了如何在 TypeScript 中推断或显式声明函数的参数和返回类型:
-
声明函数参数类型时使用类型注解。
-
使用可选参数、默认值和剩余参数来改变类型系统行为。
-
使用类型注解声明函数返回类型。
-
使用
void类型描述不返回可用值的函数。 -
使用
never类型描述根本不返回的函数。 -
使用函数重载描述不同的函数调用签名。
提示。
现在您已经完成了本章的阅读,请在 https://learningtypescript.com/functions 上练习您所学的内容。
什么使得 TypeScript 项目变得优秀?
它的功能良好。
第六章:数组
数组和元组
一个灵活一个固定
选择您的冒险
JavaScript 数组非常灵活,可以容纳任意混合的值:
const elements = [true, null, undefined, 42];
elements.push("even", ["more"]);
// Value of elements: [true, null, undefined, 42, "even", ["more"]]
大多数情况下,单个 JavaScript 数组的目的是只存储特定类型的值。添加不同类型的值可能会令读者困惑,或者更糟糕的是,可能导致程序错误。
TypeScript 遵循保持每个数组一个数据类型的最佳实践,通过记住数组初始时包含的数据类型,并仅允许数组在该数据类型上操作来实现。
在这个例子中,TypeScript 知道 warriors 数组最初包含 string 类型的值,因此允许添加更多的 string 类型的值,但不允许添加其他类型的数据:
const warriors = ["Artemisia", "Boudica"];
// Ok: "Zenobia" is a string
warriors.push("Zenobia");
warriors.push(true);
// ~~~~
// Argument of type 'boolean' is not assignable to parameter of type 'string'.
您可以将 TypeScript 对数组类型的推断视为类似于它如何从初始成员理解变量类型。TypeScript 通常尝试从分配值的方式理解代码的预期类型,数组也不例外。
数组类型
与其他变量声明一样,用于存储数组的变量不需要具有初始值。变量可以从 undefined 开始,并稍后接收数组值。
TypeScript 希望您通过给变量添加类型注解来告知它应将什么类型的值放入数组中。数组的类型注解要求数组中元素的类型,后跟一个 []:
let arrayOfNumbers: number[];
arrayOfNumbers = [4, 8, 15, 16, 23, 42];
注意
数组类型也可以像 Array<number> 这样的语法写成 类泛型。大多数开发者更喜欢简单的 number[]。类在 第八章,“类” 中讨论,泛型在 第九章,“类型修饰符” 中讨论。
数组和函数类型
数组类型是一个语法容器的示例,其中函数类型可能需要使用括号来区分函数类型中的内容。括号可以用来指示注解的哪一部分是函数返回或周围的数组类型。
此处的 createStrings 类型是一个函数类型,与 stringCreators(数组类型)不同:
// Type is a function that returns an array of strings
let createStrings: () => string[];
// Type is an array of functions that each return a string
let stringCreators: (() => string)[];
联合类型数组
您可以使用联合类型来表示数组的每个元素可以是多种选择类型中的一种。
在使用联合类型数组时,可能需要使用括号来指示注解的哪一部分是数组的内容或周围的联合类型。在数组联合类型中使用括号很重要——以下两种类型不同:
// Type is either a number or an array of strings
let stringOrArrayOfNumbers: string | number[];
// Type is an array of elements that are each either a number or a string
let arrayOfStringOrNumbers: (string | number)[];
TypeScript 将从数组的声明中理解为联合类型数组,如果数组中包含多种类型的元素。换句话说,数组元素的类型是数组中所有可能元素类型的并集。
在这里,namesMaybe 是 (string | undefined)[],因为它既有 string 值,也有 undefined 值:
// Type is (string | undefined)[]
const namesMaybe = [
"Aqualtune",
"Blenda",
undefined,
];
发展任意数组
如果你在初始设置为空数组的变量上没有包含类型注解,TypeScript 将把该数组视为逐步演化为any[],这意味着它可以接收任何内容。和逐步演化的any变量一样,我们不喜欢逐步演化的any[]数组。它们部分抵消了 TypeScript 类型检查器的好处,因为允许你添加潜在不正确的值。
这个values数组最初包含any元素,然后演变为包含string元素,然后再次演变为包含number | string元素:
// Type: any[]
let values = [];
// Type: string[]
values.push('');
// Type: (number | string)[]
values[0] = 0;
和变量一样,允许数组成为逐步演化的any类型——以及通常使用any类型——部分地削弱了 TypeScript 类型检查的目的。当 TypeScript 知道你的值应该是什么类型时,它的工作效果最佳。
多维数组
一个 2D 数组,或者说是数组的数组,会有两个“[]”:
let arrayOfArraysOfNumbers: number[][];
arrayOfArraysOfNumbers = [
[1, 2, 3],
[2, 4, 6],
[3, 6, 9],
];
一个 3D 数组,或者说是数组的数组的数组,会有三个“[]”。4D 数组有四个“[]”。5D 数组有五个“[]”。你可以猜想,对于 6D 数组及以上情况也是如此。
这些多维数组类型并没有为数组类型引入任何新的概念。将 2D 数组视为接受原始类型,只是在末尾添加了[]。
这个arrayOfArraysOfNumbers数组的类型是number[][],也可以表示为(number[])[]:
// Type: number[][]
let arrayOfArraysOfNumbers: (number[])[];
数组成员
TypeScript 理解通过典型的基于索引的访问来检索数组成员,以返回该数组类型的元素。
这个defenders数组的类型是string[],所以defender是一个string:
const defenders = ["Clarenza", "Dina"];
// Type: string
const defender = defenders[0];
联合类型数组的成员本身是相同的联合类型。
在这里,soldiersOrDates的类型是(string | Date)[],因此soldierOrDate变量的类型是string | Date:
const soldiersOrDates = ["Deborah Sampson", new Date(1782, 6, 3)];
// Type: Date | string
const soldierOrDate = soldiersOrDates[0];
注意:不完备成员
TypeScript 类型系统被认为在技术上是不完备的:它可以大部分时间得到正确的类型,但有时它对值的类型理解可能是错误的。特别是数组在类型系统中是不完备性的源头。默认情况下,TypeScript 假设所有数组成员访问都会返回该数组的成员,即使在 JavaScript 中,使用大于数组长度的索引访问数组元素会返回undefined。
这段代码在默认的 TypeScript 编译器设置下没有任何投诉:
function withElements(elements: string[]) {
console.log(elements[9001].length); // No type error
}
withElements(["It's", "over"]);
作为读者,我们可以推断它会在运行时崩溃,显示“Cannot read property 'length' of undefined”,但 TypeScript 故意不确保检索到的数组成员存在。它在代码片段中看到elements[9001]被视为string类型,而不是undefined类型。
注意
TypeScript 确实有一个--noUncheckedIndexedAccess标志,使得数组查找更加受限和类型安全,但它相当严格,大多数项目不使用它。我在本书中没有涵盖它。第十二章,“使用 IDE 功能”链接到解释 TypeScript 所有配置选项深入的资源。
展开和剩余参数
还记得来自第五章,“函数”的... rest 参数吗?...操作符的 rest 参数和数组展开是与 JavaScript 中数组交互的关键方式。 TypeScript 理解这两者。
Spreads
使用...展开操作符可以将数组连接在一起。TypeScript 理解结果数组将包含可以来自任一输入数组的值。
如果输入数组是相同类型,则输出数组将是相同类型。如果将两个不同类型的数组一起展开以创建新数组,则新数组将被理解为原始两种类型的并集数组。
在这里,conjoined数组已知包含类型为string和number的值,因此其类型被推断为(string | number)[]:
// Type: string[]
const soldiers = ["Harriet Tubman", "Joan of Arc", "Khutulun"];
// Type: number[]
const soldierAges = [90, 19, 45];
// Type: (string | number)[]
const conjoined = [...soldiers, ...soldierAges];
展开 rest 参数
TypeScript 识别并将对 JavaScript 中...展开数组作为 rest 参数的做法进行类型检查。用作 rest 参数的数组必须具有与 rest 参数相同的数组类型。
下面的logWarriors函数仅接受其...names rest 参数的string值。允许展开string[]类型的数组,但不允许number[]:
function logWarriors(greeting: string, ...names: string[]) {
for (const name of names) {
console.log(`${greeting}, ${name}!`);
}
}
const warriors = ["Cathay Williams", "Lozen", "Nzinga"];
logWarriors("Hello", ...warriors);
const birthYears = [1844, 1840, 1583];
logWarriors("Born in", ...birthYears);
// ~~~~~~~~~~~~~
// Error: Argument of type 'number' is not
// assignable to parameter of type 'string'.
元组
虽然 JavaScript 数组在理论上可以是任意大小,但有时使用固定大小的数组也很有用,也称为元组。元组数组在每个索引处具有特定的已知类型,可能比数组所有可能成员的联合类型更具体。声明元组类型的语法类似于数组文字,但在元素值的位置上有类型。
在这里,数组yearAndWarrior被声明为具有索引 0 处为number,索引 1 处为string的元组类型:
let yearAndWarrior: [number, string];
yearAndWarrior = [530, "Tomyris"]; // Ok
yearAndWarrior = [false, "Tomyris"];
// ~~~~~
// Error: Type 'boolean' is not assignable to type 'number'.
yearAndWarrior = [530];
// Error: Type '[number]' is not assignable to type '[number, string]'.
// Source has 1 element(s) but target requires 2.
元组经常与数组解构一起在 JavaScript 中使用,以便根据单个条件设置两个变量的初始值。
例如,TypeScript 在这里认识到year将始终是number,而warrior将始终是string:
// year type: number
// warrior type: string
let [year, warrior] = Math.random() > 0.5
? [340, "Archidamia"]
: [1828, "Rani of Jhansi"];
元组可赋值性
TypeScript 将元组类型视为比可变长度数组类型更具体。这意味着可变长度数组类型不能赋值给元组类型。
在这里,虽然我们人类可能会认为pairLoose内部有[boolean, number],但 TypeScript 推断它为更一般的(boolean | number)[]类型:
// Type: (boolean | number)[]
const pairLoose = [false, 123];
const pairTupleLoose: [boolean, number] = pairLoose;
// ~~~~~~~~~~~~~~
// Error: Type '(number | boolean)[]' is not
// assignable to type '[boolean, number]'.
// Target requires 2 element(s) but source may have fewer.
如果pairLoose本身被声明为[boolean, number],则允许将其值分配给pairTuple。
不同长度的元组也不能相互赋值,因为 TypeScript 在元组类型中包含了知道元组中有多少成员。
在这里,tupleTwoExtra必须具有精确两个成员,因此虽然tupleThree以正确的成员开始,但其第三个成员阻止了其分配给tupleTwoExtra:
const tupleThree: [boolean, number, string] = [false, 1583, "Nzinga"];
const tupleTwoExact: [boolean, number] = [tupleThree[0], tupleThree[1]];
const tupleTwoExtra: [boolean, number] = tupleThree;
// ~~~~~~~~~~~~~
// Error: Type '[boolean, number, string]' is
// not assignable to type '[boolean, number]'.
// Source has 3 element(s) but target allows only 2.
Tuples as rest parameters
因为元组被视为具有更多特定类型信息的数组,包括长度和元素类型,它们可以特别有用于存储要传递给函数的参数。对于作为 ... 剩余参数传递的元组,TypeScript 能够提供准确的类型检查。
在这里,logPair 函数的参数被类型化为 string 和 number。尝试将类型为 (string | number)[] 的值作为参数传入是不安全的,因为内容可能不匹配:它们可能是相同类型,或者一个是每种类型的错误顺序。然而,如果 TypeScript 知道该值是 [string, number] 元组,它就理解值是匹配的:
function logPair(name: string, value: number) {
console.log(`${name} has ${value}`);
}
const pairArray = ["Amage", 1];
logPair(...pairArray);
// Error: A spread argument must either have a
// tuple type or be passed to a rest parameter.
const pairTupleIncorrect: [number, string] = [1, "Amage"];
logPair(...pairTupleIncorrect);
// Error: Argument of type 'number' is not
// assignable to parameter of type 'string'.
const pairTupleCorrect: [string, number] = ["Amage", 1];
logPair(...pairTupleCorrect); // Ok
如果您确实希望对剩余参数元组进行大胆尝试,您可以将它们与数组混合使用,以存储多个函数调用的参数列表。在这里,trios 是一个元组数组,其中每个元组的第二个成员也是一个元组。trios.forEach(trio => logTrio(...trio)) 是安全的,因为每个 ...trio 恰好匹配 logTrio 的参数类型。然而,trios.forEach(logTrio) 是不可分配的,因为它试图将整个 [string, [number, boolean] 作为第一个参数传递,这是类型 string:
function logTrio(name: string, value: [number, boolean]) {
console.log(`${name} has ${value[0]} (${value[1]}`);
}
const trios: [string, [number, boolean]][] = [
["Amanitore", [1, true]],
["Æthelflæd", [2, false]],
["Ann E. Dunwoody", [3, false]]
];
trios.forEach(trio => logTrio(...trio)); // Ok
trios.forEach(logTrio);
// ~~~~~~~
// Argument of type '(name: string, value: [number, boolean]) => void'
// is not assignable to parameter of type
// '(value: [string, [number, boolean]], ...) => void'.
// Types of parameters 'name' and 'value' are incompatible.
// Type '[string, [number, boolean]]' is not assignable to type 'string'.
元组推断
TypeScript 通常将创建的数组视为可变长度数组,而不是元组。如果看到一个数组被用作变量的初始值或函数的返回值,那么它将假定为灵活大小的数组,而不是固定大小的元组。
下面的 firstCharAndSize 函数被推断为返回 (string | number)[],而不是 [string, number],因为这是对其返回的数组文字推断的类型:
// Return type: (string | number)[]
function firstCharAndSize(input: string) {
return [input[0], input.length];
}
// firstChar type: string | number
// size type: string | number
const [firstChar, size] = firstCharAndSize("Gudit");
在 TypeScript 中有两种常见的方法来指示一个值应该是一个更具体的元组类型而不是一般的数组类型:显式元组类型和 const 断言。
显式元组类型
元组类型可以在类型注释中使用,例如函数的返回类型注释。如果函数被声明为返回元组类型并返回数组文字,那么该数组文字将被推断为元组而不是更一般的可变长度数组。
此 firstCharAndSizeExplicit 函数版本明确声明它返回一个 string 和 number 的元组:
// Return type: [string, number]
function firstCharAndSizeExplicit(input: string): [string, number] {
return [input[0], input.length];
}
// firstChar type: string
// size type: number
const [firstChar, size] = firstCharAndSizeExplicit("Cathay Williams");
Const 断言元组
在显式类型注释中键入元组类型可能会因为与任何显式类型注释键入相同的原因而让人感到头疼。这是额外的语法需要您在代码更改时编写和更新。
作为替代方案,TypeScript 提供了一个称为 const 断言 的 as const 操作符,可以放置在一个值之后。Const 断言告诉 TypeScript 在推断其类型时使用最字面、最只读的可能形式。如果在数组文字之后放置一个 const 断言,它将指示该数组应该被视为元组:
// Type: (string | number)[]
const unionArray = [1157, "Tomoe"];
// Type: readonly [1157, "Tomoe"]
const readonlyTuple = [1157, "Tomoe"] as const;
请注意,as const 断言不仅仅是将灵活大小的数组转换为固定大小的元组:它们还指示 TypeScript 元组是只读的,不能在期望修改值的地方使用。
在这个例子中,pairMutable 允许修改,因为它具有传统的显式元组类型。然而,as const 使得不可修改的值不能赋给可变的 pairAlsoMutable,并且常量 pairConst 的成员也不允许修改:
const pairMutable: [number, string] = [1157, "Tomoe"];
pairMutable[0] = 1247; // Ok
const pairAlsoMutable: [number, string] = [1157, "Tomoe"] as const;
// ~~~~~~~~~~~~~~~
// Error: The type 'readonly [1157, "Tomoe"]' is 'readonly'
// and cannot be assigned to the mutable type '[number, string]'.
const pairConst = [1157, "Tomoe"] as const;
pairConst[0] = 1247;
// ~
// Error: Cannot assign to '0' because it is a read-only property.
在实践中,只读元组对于函数返回值非常方便。从返回元组的函数中返回的值通常会立即解构,因此只读元组不会妨碍函数的使用。
这个 firstCharAndSizeAsConst 返回一个 readonly [string, number],但是消费代码只关心从元组中检索值:
// Return type: readonly [string, number]
function firstCharAndSizeAsConst(input: string) {
return [input[0], input.length] as const;
}
// firstChar type: string
// size type: number
const [firstChar, size] = firstCharAndSizeAsConst("Ching Shih");
注意
只读对象和 as const 断言在第九章,“类型修饰符”中有更详细的介绍。
摘要
在本章中,您将学习如何声明数组并检索其成员:
-
使用
[]声明数组类型 -
使用括号声明函数数组或联合类型
-
TypeScript 如何理解数组元素作为数组类型的一部分
-
使用
...展开和剩余参数 -
声明元组类型以表示固定大小的数组
-
使用类型注解或
as const断言来创建元组
提示
现在您已经完成了本章的阅读,请在https://learningtypescript.com/arrays上实践所学内容。
海盗最喜欢的数据结构是什么?
阵列!
第七章:接口
为什么仅使用
无聊的内置类型形状何时
我们可以自己创建!
我在第四章,“对象”中提到,虽然 { ... } 对象类型的类型别名是描述对象形状的一种方式,TypeScript 还包括了许多开发人员更喜欢的“接口”特性。接口是另一种使用相关名称声明对象形状的方式。接口在很多方面与别名对象类型类似,但通常更受欢迎,因为它们具有更可读的错误消息、更快的编译器性能以及与类更好的互操作性。
类型别名与接口
这里是一个快速回顾如何使用类型别名描述具有 born: number 和 name: string 的对象的语法:
type Poet = {
born: number;
name: string;
};
这是接口的等效语法:
interface Poet {
born: number;
name: string;
}
这两种语法几乎是相同的。
提示
喜欢分号的 TypeScript 开发人员通常将它们放在类型别名后面,而不是接口后面。这种偏好反映了使用 ; 声明变量与声明类或函数时的差异。
TypeScript 的可赋值性检查和接口的错误消息在对象类型上的工作和外观几乎相同。如果 Poet 是一个接口或类型别名,则对 valueLater 变量进行赋值的以下可赋值性错误大致相同:
let valueLater: Poet;
// Ok
valueLater = {
born: 1935,
name: 'Sara Teasdale',
};
valueLater = "Emily Dickinson";
// Error: Type 'string' is not assignable to 'Poet'.
valueLater = {
born: true,
// Error: Type 'boolean' is not assignable to type 'number'.
name: 'Sappho'
};
然而,接口和类型别名之间存在一些关键差异:
-
正如您稍后在本章中看到的,接口可以“合并”在一起进行增强——这在处理第三方代码(如内置全局或 npm 包)时特别有用。
-
正如您将在下一章第八章,“类”中看到的那样,接口可以用于对类声明的结构进行类型检查,而类型别名则不能。
-
接口通常更适合 TypeScript 类型检查器使用:它们声明了一个可以更轻松地在内部缓存的命名类型,而不是像类型别名那样动态复制和粘贴新的对象文字。
-
因为接口被认为是具有命名对象而不是未命名对象文本别名的对象,它们的错误消息在硬边缘情况下更可能是可读的。
基于后两个原因并为保持一致性,本书及其相关项目默认使用接口而不是别名对象形状。我通常建议尽可能使用接口(即,直到需要类型别名的联合类型等功能)。
属性类型
JavaScript 对象在实际使用中可能非常复杂和奇特,包括 getter 和 setter、有时仅存在的属性或接受任意属性名称。TypeScript 提供了一套接口类型系统工具,帮助我们建模这种奇特性。
提示
由于接口和类型别名的行为非常相似,本章介绍的以下类型属性也适用于别名对象类型。
可选属性
与对象类型一样,接口属性并非都必须是对象中必需的。您可以通过在类型注释中的:之前包含?来指示接口的属性是可选的。
此Book接口仅需要一个required属性,并可选地允许一个optional属性。符合此接口的对象可以提供optional或者将其省略,只要它们提供required即可:
interface Book {
author?: string;
pages: number;
};
// Ok
const ok: Book = {
author: "Rita Dove",
pages: 80,
};
const missing: Book = {
pages: 80
};
// Error: Property 'author' is missing in type
// '{ pages: number; }' but required in type 'Book'.
在接口和对象类型中,关于可选属性和类型联合中包含undefined的属性之间的差异同样适用。第十三章,“配置选项”将描述 TypeScript 在可选属性周围的严格设置。
只读属性
有时,您可能希望阻止接口的用户重新分配符合接口的对象的属性。TypeScript 允许您在属性名称之前添加readonly修饰符,以指示一旦设置,该属性不应该被设置为其他值。这些readonly属性可以正常读取,但不能重新分配为新值。
例如,下面Page接口中的text属性在访问时返回一个string,但如果分配一个新值则会导致类型错误:
interface Page {
readonly text: string;
}
function read(page: Page) {
// Ok: reading the text property doesn't attempt to modify it
console.log(page.text);
page.text += "!";
// ~~~~
// Error: Cannot assign to 'text'
// because it is a read-only property.
}
注意,readonly修饰符仅存在于类型系统中,并且仅适用于该接口的使用。除非该对象在使用位置声明为该接口,否则不会应用于对象。
在这个exclaim示例的延续中,text属性可以在函数外部修改,因为其父对象直到函数内部才显式用作Text。pageIsh可以用作Page,因为可写属性可分配给readonly属性(可变属性可以从中读取,这正是readonly属性所需的):
const pageIsh = {
text: "Hello, world!",
};
// Ok: messengerIsh is an inferred object type with text, not a Page
page.text += "!";
// Ok: read takes in Page, which happens to
// be a more specific version of pageIsh's type
read(messengerIsh);
使用显式类型注释: Page声明变量pageIsh将表明其text属性是readonly的。然而,其推断类型却不是readonly的。
只读接口成员是一种确保代码区域不会意外修改其不应修改的对象的便捷方式。但请记住,它们仅仅是类型系统的构造,不存在于编译后的 JavaScript 输出代码中。它们仅在 TypeScript 类型检查期间保护不被修改。
函数和方法
在 JavaScript 中,对象成员为函数是非常常见的。因此,TypeScript 允许声明接口成员为之前在第五章,“函数”中介绍的函数类型。
TypeScript 提供了两种将接口成员声明为函数的方法:
-
方法语法:声明接口成员是作为对象的成员调用的函数,如
member(): void -
属性语法:声明接口成员等于独立函数,如
member: () => void
这两种声明形式是声明 JavaScript 对象具有函数的两种方式的类比。
这里显示的method和property成员都是可以不带参数调用并返回string的函数:
interface HasBothFunctionTypes {
property: () => string;
method(): string;
}
const hasBoth: HasBothFunctionTypes = {
property: () => "",
method() {
return "";
}
};
hasBoth.property(); // Ok
hasBoth.method(); // Ok
两种形式都可以接收?可选修饰符,表示它们不需要提供:
interface OptionalReadonlyFunctions {
optionalProperty?: () => string;
optionalMethod?(): string;
}
方法和属性声明大部分可以互换使用。我将在本书中介绍它们之间的主要区别是:
-
方法不能声明为
readonly;属性可以。 -
接口合并(本章后面介绍)会对它们进行不同处理。
-
在第十五章,“类型操作”中涵盖的类型上执行的一些操作会有所不同。
未来的 TypeScript 版本可能会增加更严格地区分方法和属性函数的选项。
目前,我推荐的一般风格指南是:
-
如果您知道底层函数可能引用
this,最常见的是类的实例(在第八章,“类”中介绍),请使用方法函数。 -
否则使用属性函数。
如果你混淆了这两种方式,或者不理解它们之间的区别,不要担心。除非你有意关注this作用域和你选择的形式,否则它很少会影响你的代码。
调用签名
接口和对象类型可以声明调用签名,这是一个描述值如何被调用的类型系统描述,就像一个函数一样。只有符合调用签名声明的方式调用的值才能赋值给接口,即具有可赋值参数和返回类型的函数。调用签名看起来类似于函数类型,但使用:冒号而不是=>箭头。
下面的FunctionAlias和CallSignature类型都描述了相同的函数参数和返回类型:
type FunctionAlias = (input: string) => number;
interface CallSignature {
(input: string): number;
}
// Type: (input: string) => number
const typedFunctionAlias: FunctionAlias = (input) => input.length; // Ok
// Type: (input: string) => number
const typedCallSignature: CallSignature = (input) => input.length; // Ok
调用签名可用于描述具有一些用户定义属性的函数。TypeScript 将识别添加到函数声明的属性作为增加到该函数声明类型的属性。
下面的keepsTrackOfCalls函数声明具有类型为number的count属性,使其可以赋值给FunctionWithCount接口。因此,它可以被赋值给类型为FunctionWithCount的hasCallCount参数。代码片段末尾的函数没有给出count:
interface FunctionWithCount {
count: number;
(): void;
}
let hasCallCount: FunctionWithCount;
function keepsTrackOfCalls() {
keepsTrackOfCalls.count += 1;
console.log(`I've been called ${keepsTrackOfCalls.count} times!`);
}
keepsTrackOfCalls.count = 0;
hasCallCount = keepsTrackOfCalls; // Ok
function doesNotHaveCount() {
console.log("No idea!");
}
hasCallCount = doesNotHaveCount;
// Error: Property 'count' is missing in type
// '() => void' but required in type 'FunctionWithCalls'
索引签名
一些 JavaScript 项目创建的对象旨在将值存储在任意string键下。对于这些“容器”对象,声明一个接口并为每个可能的键添加一个字段是不切实际或不可能的。
TypeScript 提供了一种称为 索引签名 的语法,指示接口的对象允许接收任何键并返回该键下的特定类型。它们最常与字符串键一起使用,因为 JavaScript 对象属性查找会隐式将键转换为字符串。索引签名看起来像常规的属性定义,但在键后面有一个类型,并用大括号括起来,例如 { [i: string]: ... }。
此 WordCounts 接口声明允许任何 string 键和 number 值。该类型的对象不限于接收任何特定的键,只要值是 number:
interface WordCounts {
[i: string]: number;
}
const counts: WordCounts = {};
counts.apple = 0; // Ok
counts.banana = 1; // Ok
counts.cherry = false;
// Error: Type 'boolean' is not assignable to type 'number'.
索引签名方便为对象分配值,但并不完全类型安全。它们指示对象无论访问哪个属性,都应返回一个值。
此 publishDates 值安全地返回 Frankenstein 作为 Date,但会让 TypeScript 认为其 Beloved 已定义,尽管实际上是 undefined。
interface DatesByName {
[i: string]: Date;
}
const publishDates: DatesByName = {
Frankenstein: new Date("1 January 1818"),
};
publishDates.Frankenstein; // Type: Date
console.log(publishDates.Frankenstein.toString()); // Ok
publishDates.Beloved; // Type: Date, but runtime value of undefined!
console.log(publishDates.Beloved.toString()); // Ok in the type system, but...
// Runtime error: Cannot read property 'toString'
// of undefined (reading publishDates.Beloved)
可能的话,如果您希望存储键值对并且事先不知道键,通常更安全的做法是使用 Map。其 .get 方法总是返回带有 | undefined 的类型,以指示可能不存在该键。第九章,“类型修饰符” 将讨论如何使用通用容器类,如 Map 和 Set。
混合属性和索引签名
接口能够包含显式命名属性和通用的 string 索引签名,但有一个限制:每个命名属性的类型必须可分配给其通用索引签名的类型。您可以将它们混合使用,告诉 TypeScript 命名属性提供更具体的类型,而任何其他属性则回退到索引签名的类型。
在这里,HistoricalNovels 声明所有属性的类型都是 number,并且另外 Oroonoko 属性必须首先存在:
interface HistoricalNovels {
Oroonoko: number;
[i: string]: number;
}
// Ok
const novels: HistoricalNovels = {
Outlander: 1991,
Oroonoko: 1688,
};
const missingOroonoko: HistoricalNovels = {
Outlander: 1991,
};
// Error: Property 'Oroonoko' is missing in type
// '{ Outlander: number; }' but required in type 'HistoricalNovels'.
一种常见的类型系统技巧,混合属性和索引签名的是,使用比索引签名的原始类型更具体的属性类型文字。只要命名属性的类型可分配给索引签名的类型(分别对于文字和原始类型),TypeScript 就会允许它。
在这里,ChapterStarts 声明 preface 下的属性必须是 0,而所有其他属性都是更一般的 number。这意味着任何符合 ChapterStarts 的对象必须具有 preface 属性等于 0:
interface ChapterStarts {
preface: 0;
[i: string]: number;
}
const correctPreface: ChapterStarts = {
preface: 0,
night: 1,
shopping: 5
};
const wrongPreface: ChapterStarts = {
preface: 1,
// Error: Type '1' is not assignable to type '0'.
};
数字索引签名
尽管 JavaScript 隐式将对象属性查找键转换为字符串,有时仅允许数字作为对象的键是可取的。TypeScript 索引签名可以使用 number 类型而不是 string,但与命名属性相同的是,它们的类型必须可分配给通用的 string 索引签名。
以下MoreNarrowNumbers接口将被允许,因为string可以分配给string | undefined,但MoreNarrowStrings不会,因为string | undefined不能分配给string:
// Ok
interface MoreNarrowNumbers {
[i: number]: string;
[i: string]: string | undefined;
}
// Ok
const mixesNumbersAndStrings: MoreNarrowNumbers = {
0: '',
key1: '',
key2: undefined,
}
interface MoreNarrowStrings {
[i: number]: string | undefined;
// Error: 'number' index type 'string | undefined'
// is not assignable to 'string' index type 'string'.
[i: string]: string;
}
嵌套接口
就像对象类型可以作为其他对象类型的属性进行嵌套一样,接口类型也可以具有作为接口类型(或对象类型)的属性。
这个Novel接口包含一个必须满足内联对象类型的author属性和一个必须满足Setting接口的setting属性:
interface Novel {
author: {
name: string;
};
setting: Setting;
}
interface Setting {
place: string;
year: number;
}
let myNovel: Novel;
// Ok
myNovel = {
author: {
name: 'Jane Austen',
},
setting: {
place: 'England',
year: 1812,
}
};
myNovel = {
author: {
name: 'Emily Brontë',
},
setting: {
place: 'West Yorkshire',
},
// Error: Property 'year' is missing in type
// '{ place: string; }' but required in type 'Setting'.
};
接口扩展
有时您可能会得到多个看起来相似的接口。一个接口可能包含另一个接口的所有相同成员,并添加一些额外的成员。
TypeScript 允许一个接口扩展另一个接口,这表示它复制另一个接口的所有成员。通过在名称后添加extends关键字(“派生”接口)并在其后跟要扩展的接口的名称(“基”接口),可以将接口标记为扩展另一个接口。这样做告诉 TypeScript,所有符合派生接口的对象也必须具有基接口的所有成员。
在以下示例中,Novella接口从Writing扩展,因此要求对象至少具有Novella的pages和Writing的title成员:
interface Writing {
title: string;
}
interface Novella extends Writing {
pages: number;
}
// Ok
let myNovella: Novella = {
pages: 195,
title: "Ethan Frome",
};
let missingPages: Novella = {
// ~~~~~~~~~~~~
// Error: Property 'pages' is missing in type
// '{ title: string; }' but required in type 'Novella'.
title: "The Awakening",
}
let extraProperty: Novella = {
// ~~~~~~~~~~~~~
// Error: Type '{ genre: string; name: string; strategy: string; }'
// is not assignable to type 'Novella'.
// Object literal may only specify known properties,
// and 'genre' does not exist in type 'Novella'.
pages: 300,
strategy: "baseline",
style: "Naturalism"
};
接口扩展是一种巧妙的方式,表示项目中的一种实体类型是另一种实体类型的超集(它包含另一个实体的所有成员)。它们允许您避免在多个接口中重复键入相同的代码以表示该关系。
覆盖属性
派生接口可以通过使用不同类型重新声明属性来覆盖或替换其基接口的属性。TypeScript 的类型检查器将强制执行覆盖属性必须可分配给其基属性。它这样做是为了确保派生接口类型的实例仍然可以分配给基接口类型。
大多数重新声明属性的派生接口要么使这些属性成为类型联合的更具体子集,要么使属性成为扩展自基接口类型的类型。
例如,这个WithNullableName类型在WithNonNullableName中被正确地设置为非空。然而,WithNumericName不被允许作为number | string,并且不可分配给string | null:
interface WithNullableName {
name: string | null;
}
interface WithNonNullableName extends WithNullableName {
name: string;
}
interface WithNumericName extends WithNullableName {
name: number | string;
}
// Error: Interface 'WithNumericName' incorrectly
// extends interface 'WithNullableName'.
// Types of property 'name' are incompatible.
// Type 'string | number' is not assignable to type 'string | null'.
// Type 'number' is not assignable to type 'string'.
扩展多个接口
在 TypeScript 中,允许将接口声明为扩展多个其他接口。在派生接口名称后的extends关键字之后可以使用任意数量的由逗号分隔的接口名称。派生接口将接收所有基接口的成员。
在这里,GivesBothAndEither有三种方法:一个是自己的,一个来自GivesNumber,一个来自GivesString:
interface GivesNumber {
giveNumber(): number;
}
interface GivesString {
giveString(): string;
}
interface GivesBothAndEither extends GivesNumber, GivesString {
giveEither(): number | string;
}
function useGivesBoth(instance: GivesBothAndEither) {
instance.giveEither(); // Type: number | string
instance.giveNumber(); // Type: number
instance.giveString(); // Type: string
}
通过将接口标记为扩展多个其他接口,可以减少代码重复,并使对象形状在不同代码区域中更易于重用。
接口合并
接口的重要特性之一是它们能够合并。接口合并意味着如果在同一作用域中声明了两个名称相同的接口,则它们将合并为一个更大的接口,在该名称下具有所有声明的字段。
此片段声明了一个具有两个属性fromFirst和fromSecond的Merged接口:
interface Merged {
fromFirst: string;
}
interface Merged {
fromSecond: number;
}
// Equivalent to:
// interface Merged {
// fromFirst: string;
// fromSecond: number;
// }
接口合并在日常 TypeScript 开发中并不经常使用。我建议在可能的情况下避免使用它,因为在一个接口在多个位置声明时,代码理解起来可能会比较困难。
然而,接口合并特别适用于增强来自外部包或内置全局接口(如Window)的接口。例如,在使用默认的 TypeScript 编译器选项时,在一个文件中声明了一个带有myEnvironmentVariable属性的Window接口,会使window.myEnvironmentVariable可用:
interface Window {
myEnvironmentVariable: string;
}
window.myEnvironmentVariable; // Type: string
我将在第十一章,“声明文件”中更深入地讨论类型定义,以及在第十三章,“配置选项”中讨论 TypeScript 全局类型选项。
成员命名冲突
请注意,合并接口可能不会多次使用不同类型声明同一属性名称。如果在接口中已经声明了一个属性,则稍后合并的接口必须使用相同的类型。
在这个MergedProperties接口中,same属性是允许的,因为它在两个声明中是相同的,但different因为类型不同而报错:
interface MergedProperties {
same: (input: boolean) => string;
different: (input: string) => string;
}
interface MergedProperties {
same: (input: boolean) => string; // Ok
different: (input: number) => string;
// Error: Subsequent property declarations must have the same type.
// Property 'different' must be of type '(input: string) => string',
// but here has type '(input: number) => string'.
}
然而,合并接口可以定义具有相同名称但不同签名的方法。这样做会为方法创建一个函数重载。
此MergedMethods接口创建了一个具有两个重载的different方法:
interface MergedMethods {
different(input: string): string;
}
interface MergedMethods {
different(input: number): string; // Ok
}
摘要
本章介绍了如何通过接口描述对象类型:
-
使用接口而不是类型别名来声明对象类型
-
各种接口属性类型:可选的、只读的、函数和方法
-
使用索引签名捕获对象属性
-
使用嵌套接口和
extends继承重用接口 -
如何合并具有相同名称的接口
接下来将是设置多个对象具有相同属性的本机 JavaScript 语法:类。
提示
现在您已经完成了本章的阅读,请在https://learningtypescript.com/objects-and-interfaces上练习所学内容。
接口为什么是好的驱动程序?
它们在合并时非常出色。
第八章:类
一些功能开发人员
尽量永远不使用类
对我来说太强烈了
在 TypeScript 创建和发布于 2010 年代初期的 JavaScript 世界与今天大不相同。例如箭头函数和let/const变量等功能,这些功能后来在 ES2015 中标准化,当时还只是遥远的希望。Babel 离第一个提交还有几年时间;其前身工具如 Traceur,将新的 JavaScript 语法转换为旧的,尚未完全普及。
TypeScript 早期的市场营销和功能设置是针对那个世界的。除了类型检查之外,还强调了其转译器,以类作为一个常见的例子。如今,TypeScript 的类支持只是支持所有 JavaScript 语言特性之一的一部分。TypeScript 既不鼓励也不阻止类的使用或任何其他流行的 JavaScript 模式。
类方法
TypeScript 通常将方法理解为独立函数。参数类型默认为any,除非给定类型或默认值;调用方法需要接受适当数量的参数;如果函数不是递归的话,通常可以推断返回类型。
此代码片段定义了一个Greeter类,其中包含一个接受类型为number的单个必需参数的greet类方法:
class Greeter {
greet(name: string) {
console.log(`${name}, do your stuff!`);
}
}
new Greeter().greet("Miss Frizzle"); // Ok
new Greeter().greet();
// ~~~~~
// Error: Expected 1 arguments, but got 0.
类构造函数在其参数方面与典型的类方法一样对待。TypeScript 将执行类型检查,以确保方法调用提供了正确数量和正确类型的参数。
这个Greeted构造函数也期望提供message: string参数:
class Greeted {
constructor(message: string) {
console.log(`As I always say: ${message}!`);
}
}
new Greeted("take chances, make mistakes, get messy");
new Greeted();
// Error: Expected 1 arguments, but got 0.
我将在本章的子类上下文中涵盖构造函数。
类属性
要在 TypeScript 中从类的属性读取或写入,必须在类中明确声明它们。类属性使用与接口相同的语法声明:它们的名称后面可以选择跟上类型注释。
TypeScript 不会试图从构造函数中的赋值推断出类可能存在的成员。
在这个例子中,destination允许在FieldTrip类的实例上分配和访问,因为它被明确声明为string。在构造函数中的this.nonexistent赋值是不允许的,因为该类没有声明nonexistent属性:
class FieldTrip {
destination: string;
constructor(destination: string) {
this.destination = destination; // Ok
console.log(`We're going to ${this.destination}!`);
this.nonexistent = destination;
// ~~~~~~~~~~~
// Error: Property 'nonexistent' does not exist on type 'FieldTrip'.
}
}
明确声明类属性允许 TypeScript 快速理解哪些实例中可以存在或不存在。稍后,在使用类实例时,TypeScript 利用这一理解,在代码尝试访问未知存在的类实例成员时,例如在本段续写中的trip.nonexistent,会给出类型错误:
const trip = new FieldTrip("planetarium");
trip.destination; // Ok
trip.nonexistent;
// ~~~~~~~~~~~
// Error: Property 'nonexistent' does not exist on type 'FieldTrip'.
函数属性
让我们简要回顾一些 JavaScript 方法作用域和语法基础知识,因为如果您不习惯,它们可能会让人惊讶。JavaScript 包含两种语法用于声明类的成员为可调用函数:方法 和 属性。
我已经展示了在成员名称后加括号的方法,例如 myFunction() {}。方法的方法会将函数分配给类的原型,因此所有类实例使用相同的函数定义。
此 WithMethod 类声明了一个 myMethod 方法,所有实例均能引用:
class WithMethod {
myMethod() {}
}
new WithMethod().myMethod === new WithMethod().myMethod; // true
另一种语法是声明一个其值恰好为函数的属性。这会为类的每个实例创建一个新的函数,对于箭头函数 () =>,这可以保证 this 的作用域始终指向类实例(以时间和内存开销换取)。
此 WithProperty 类包含一个名为 myProperty,类型为 () => void 的单一属性,每个类实例将重新创建该属性:
class WithProperty {
myProperty: () => {}
}
new WithMethod().myProperty === new WithMethod().myProperty; // false
函数属性可以使用与类方法和独立函数相同的语法提供参数和返回类型。毕竟,它们是分配给类成员的值,其值恰好是一个函数。
此 WithPropertyParameters 类具有类型为 (input: string) => number 的 takesParameters 属性:
class WithPropertyParameters {
takesParameters = (input: boolean) => input ? "Yes" : "No";
}
const instance = new WithPropertyParameters();
instance.takesParameters(true); // Ok
instance.takesParameters(123);
// ~~~
// Error: Argument of type 'number' is not
// assignable to parameter of type 'boolean'.
初始化检查
启用严格的编译器设置后,TypeScript 将检查每个声明的属性,确保其类型不包括 undefined,在构造函数中被赋值。这种严格的初始化检查非常有用,因为它可以防止代码意外地忘记为类属性赋值。
下面的 WithValue 类没有为其 unused 属性赋值,这在 TypeScript 中被识别为类型错误:
class WithValue {
immediate = 0; // Ok
later: number; // Ok (set in the constructor)
mayBeUndefined: number | undefined; // Ok (allowed to be undefined)
unused: number;
// Error: Property 'unused' has no initializer
// and is not definitely assigned in the constructor.
constructor() {
this.later = 1;
}
}
如果没有严格的初始化检查,可能允许类实例访问一个可能为 undefined 的值,尽管类型系统说它不可能。
如果没有发生严格的初始化检查,这个例子将编译通过,但生成的 JavaScript 代码在运行时会崩溃:
class MissingInitializer {
property: string;
}
new MissingInitializer().property.length;
// TypeError: Cannot read property 'length' of undefined
十亿美元的错误再次发生!
使用 TypeScript 的 strictPropertyInitialization 编译器选项配置严格的属性初始化检查详见第十二章 “使用 IDE 功能”。
明确分配的属性
尽管严格的初始化检查大多数时候很有用,您可能会遇到一些情况,其中一个类属性被有意允许在类构造函数后未分配。如果您确信某个属性不应该被应用严格的初始化检查,您可以在其名称后加上 ! 来禁用检查。这样做断言给 TypeScript,该属性在第一次使用前会被赋值为非 undefined 的值。
这个ActivitiesQueue类可以在任意次数中单独重新初始化,因此它的pending属性必须用!断言:
class ActivitiesQueue {
pending!: string[]; // Ok
initialize(pending: string[]) {
this.pending = pending;
}
next() {
return this.pending.pop();
}
}
const activities = new ActivitiesQueue();
activities.initialize(['eat', 'sleep', 'learn'])
activities.next();
警告
需要在类属性上禁用严格的初始化检查通常是代码设置方式不适合类型检查的标志。不要为了属性添加!断言并降低类型安全性,考虑重构类以不再需要断言。
可选属性
类似接口,TypeScript 中的类可以通过在声明名称后添加?将属性声明为可选。可选属性的行为与类型为包括| undefined的联合的属性大致相同。如果它们在构造函数中没有被明确设置,严格的初始化检查不会介意。
这个OptionalProperty类将其property标记为可选,因此允许在类构造函数中不分配它:
class MissingInitializer {
property?: string;
}
new MissingInitializer().property?.length; // Ok
new MissingInitializer().property.length;
// Error: Object is possibly 'undefined'.
只读属性
与接口类似,TypeScript 中的类可以通过在声明名称前添加readonly关键字将属性声明为只读。readonly关键字完全存在于类型系统中,并且在编译为 JavaScript 时会被移除。
声明为readonly的属性只能在声明它们或构造函数中分配初始值。任何其他位置 —— 包括类本身的方法 —— 只能从属性中读取,而不能写入它们。
在这个例子中,Quote类的text属性在构造函数中被赋值,但其他用法会导致类型错误:
class Quote {
readonly text: string;
constructor(text: string) {
this.text = ;
}
emphasize() {
this.text += "!";
// ~~~~
// Error: Cannot assign to 'text' because it is a read-only property.
}
}
const quote = new Quote(
"There is a brilliant child locked inside every student."
);
Quote.text = "Ha!";
// Error: Cannot assign to 'text' because it is a read-only property.
警告
外部使用你的代码的用户,比如你发布的任何 npm 包的消费者,可能不会尊重readonly修饰符 —— 特别是如果他们正在编写 JavaScript 并且没有类型检查。如果你需要真正的只读保护,请考虑使用#私有字段和/或get()函数属性。
初始值为原始值的属性声明为readonly,与其他属性相比有一个小小的怪癖:如果可能的话,它们被推断为其值的狭窄字面类型,而不是更宽的原始类型。 TypeScript 对更积极的初始类型缩小感到舒适,因为它知道这个值后来不会改变;这与const变量比let变量具有更窄类型类似。
在这个例子中,类的属性最初都声明为字符串字面值,因此为了将其中一个扩展为string,需要一个类型注释:
class RandomQuote {
readonly explicit: string = "Home is the nicest word there is.";
readonly implicit = "Home is the nicest word there is.";
constructor() {
if (Math.random () > 0.5) {
this.explicit = "We start learning the minute we're born." // Ok;
this.implicit = "We start learning the minute we're born.";
// Error: Type '"We start learning the minute we're born."' is
// not assignable to type '"Home is the nicest word there is."'.
}
}
}
const quote = new RandomQuote();
quote.explicit; // Type: string
quote.implicit; // Type: "Home is the nicest word there is."
明确扩展属性的类型通常不是很必要。但在构造函数中的条件逻辑的情况下有时会有用,比如RandomQuote中的构造函数:
类作为类型
类在类型系统中相对独特,因为类声明既创建一个运行时值(类本身),又创建一个可以用于类型注释的类型。
这个 Teacher 类的名称用于注释一个 teacher 变量,告诉 TypeScript 它只能被分配给可以分配给 Teacher 类的值,比如 Teacher 类的实例:
class Teacher {
sayHello() {
console.log("Take chances, make mistakes, get messy!");
}
}
let teacher: Teacher;
teacher = new Teacher(); // Ok
teacher = "Wahoo!";
// Error: Type 'string' is not assignable to type 'Teacher'.
有趣的是,TypeScript 将认为任何包含与类的所有相同成员的对象类型都可以分配给这个类。这是因为 TypeScript 的结构类型检查只关心对象的形状,而不关心它们的声明方式。
在这里,withSchoolBus 接受一个类型为 SchoolBus 的参数。任何对象,只要它恰好具有类型为 () => string[] 的 getAbilities 属性,比如 SchoolBus 类的一个实例,就可以满足这一要求:
class SchoolBus {
getAbilities() {
return ["magic", "shapeshifting"];
}
}
function withSchoolBus(bus: SchoolBus) {
console.log(bus.getAbilities());
}
withSchoolBus(new SchoolBus()); // Ok
// Ok
withSchoolBus({
getAbilities: () => ["transmogrification"],
});
withSchoolBus({
getAbilities: () => 123,
// ~~~
// Error: Type 'number' is not assignable to type 'string[]'.
});
小贴士
在大多数真实世界的代码中,开发者不会在需要类类型的地方传递对象值。这种结构检查行为可能看起来意外,但并不经常发生。
类与接口
回顾第七章,“接口”中,我向你展示了接口如何允许 TypeScript 开发者在代码中设置对象形状的期望。通过在类名后添加 implements 关键字,接着是接口的名称,允许一个类声明其实例应该可以分配给这些接口中的每一个。任何不匹配的地方将被类型检查器指出为类型错误。
在这个例子中,Student 类通过包含其属性 name 和方法 study 正确地实现了 Learner 接口,但 Slacker 缺少了 study 方法,因此导致类型错误:
interface Learner {
name: string;
study(hours: number): void;
}
class Student implements Learner {
name: string;
constructor(name: string) {
this.name = name;
}
study(hours: number) {
for (let i = 0; i < hours; i+= 1) {
console.log("...studying...");
}
}
}
class Slacker implements Learner {
// ~~~~~~~
// Error: Class 'Slacker' incorrectly implements interface 'Learner'.
// Property 'study' is missing in type 'Slacker'
// but required in type 'Learner'.
name = "Rocky";
}
注意
接口用于被类实现是使用方法语法声明接口成员作为函数的典型原因,正如 Learner 接口所示。
将类标记为实现接口不会改变类的使用方式。如果类已经恰好匹配接口,TypeScript 的类型检查器将允许将其实例用于需要接口实例的地方。 TypeScript 甚至不会从接口推断类的方法或属性的类型:如果我们在 Slacker 示例中添加了一个 study(hours) {} 方法,TypeScript 将会将 hours 参数视为隐式的 any,除非我们对其进行类型注释。
这个 Student 类的版本会因为没有为其成员提供类型注解而导致隐式的 any 类型错误:
class Student implements Learner {
name;
// Error: Member 'name' implicitly has an 'any' type.
study(hours) {
// Error: Parameter 'hours' implicitly has an 'any' type.
}
}
实现接口纯粹是一种安全检查。它不会为你在类定义中复制任何接口成员。而是通过实现接口向类型检查器表明你的意图,并在类定义中暴露类型错误,而不是在稍后使用类实例的地方。它的目的类似于为变量添加类型注解,即使它具有初始值。
实现多个接口
TypeScript 中的类允许声明为实现多个接口。类的实现接口列表可以是任意数量的接口名称,用逗号分隔。
在此示例中,两个类都必须至少有一个 grades 属性来实现 Graded,并且一个 report 属性来实现 Reporter。Empty 类由于未能正确实现任何接口而存在两个类型错误:
interface Graded {
grades: number[];
}
interface Reporter {
report: () => string;
}
class ReportCard implements Graded, Reporter {
grades: number[];
constructor(grades: number[]) {
this.grades = grades;
}
report() {
return this.grades.join(", ");
}
}
class Empty implements Graded, Reporter { }
// ~~~~~
// Error: Class 'Empty' incorrectly implements interface 'Graded'.
// Property 'grades' is missing in type 'Empty'
// but required in type 'Graded'.
// ~~~~~
// Error: Class 'Empty' incorrectly implements interface 'Reporter'.
// Property 'report' is missing in type 'Empty'
// but required in type 'Reporter'.
实际应用中,可能会存在一些接口定义使得同一个类无法实现两者。尝试声明一个类同时实现两个冲突接口将导致类至少有一个类型错误。
下面的 AgeIsANumber 和 AgeIsNotANumber 接口为 age 属性声明了非常不同的类型。AsNumber 类和 NotAsNumber 类都未能正确实现这两个接口:
interface AgeIsANumber {
age: number;
}
interface AgeIsNotANumber {
age: () => string;
}
class AsNumber implements AgeIsANumber, AgeIsNotANumber {
age = 0;
// ~~~
// Error: Property 'age' in type 'AsNumber' is not assignable
// to the same property in base type 'AgeIsNotANumber'.
// Type 'number' is not assignable to type '() => string'.
}
class NotAsNumber implements AgeIsANumber, AgeIsNotANumber {
age() { return ""; }
// ~~~
// Error: Property 'age' in type 'NotAsNumber' is not assignable
// to the same property in base type 'AgeIsANumber'.
// Type '() => string' is not assignable to type 'number'.
}
当两个接口描述非常不同的对象形状的情况下,通常表明你不应该尝试使用同一个类来实现它们。
扩展类
TypeScript 在 JavaScript 类扩展或子类化的概念上增加了类型检查。首先,基类上声明的任何方法或属性都将在子类(也称为派生类)上可用。
在此示例中,Teacher 声明了一个 teach 方法,可以被 StudentTeacher 子类的实例使用:
class Teacher {
teach() {
console.log("The surest test of discipline is its absence.");
}
}
class StudentTeacher extends Teacher {
learn() {
console.log("I cannot afford the luxury of a closed mind.");
}
}
const teacher = new StudentTeacher();
teacher.teach(); // Ok (defined on base)
teacher.learn(); // Ok (defined on subclass)
teacher.other();
// ~~~~~
// Error: Property 'other' does not exist on type 'StudentTeacher'.
扩展可分配性
子类像派生接口扩展基础接口一样继承其基类的成员。子类的实例具有其基类的所有成员,因此可以在需要基类实例的地方使用。如果基类没有子类具有的所有成员,则在需要更具体的子类时不能使用基类。
以下 Lesson 类的实例不能在需要其派生类 OnlineLesson 的地方使用,但派生实例可以用来满足基类或子类的要求:
class Lesson {
subject: string;
constructor(subject: string) {
this.subject = subject;
}
}
class OnlineLesson extends Lesson {
url: string;
constructor(subject: string, url: string) {
super(subject);
this.url = url;
}
}
let lesson: Lesson;
lesson = new Lesson("coding"); // Ok
lesson = new OnlineLesson("coding", "oreilly.com"); // Ok
let online: OnlineLesson;
online = new OnlineLesson("coding", "oreilly.com"); // Ok
online = new Lesson("coding");
// Error: Property 'url' is missing in type
// 'Lesson' but required in type 'OnlineLesson'.
根据 TypeScript 的结构类型,如果子类的所有成员在其基类上已经存在且类型相同,则仍允许使用基类的实例来代替子类的实例。
在此示例中,LabeledPastGrades 只向 PastGrades 添加了一个可选属性,因此基类的实例可以替换子类的实例:
class PastGrades {
grades: number[] = [];
}
class LabeledPastGrades extends PastGrades {
label?: string;
}
let subClass: LabeledPastGrades;
subClass = new LabeledPastGrades(); // Ok
subClass = new PastGrades(); // Ok
提示
在大多数真实世界的代码中,子类通常在其基类之上添加新的必需类型信息。这种结构检查行为可能看起来出乎意料,但并不经常发生。
覆盖构造函数
与原始 JavaScript 类似,TypeScript 中的子类不需要定义自己的构造函数。没有自己构造函数的子类隐式使用其基类的构造函数。
在 JavaScript 中,如果子类确实声明了自己的构造函数,则必须通过 super 关键字调用其基类构造函数。子类构造函数可以声明任何参数,而不管其基类需要什么参数。TypeScript 的类型检查器将确保对基类构造函数的调用使用了正确的参数。
在这个例子中,PassingAnnouncer 的构造函数正确地使用一个 number 参数调用了基类构造函数,而 FailingAnnouncer 由于忘记进行此调用而导致类型错误:
class GradeAnnouncer {
message: string;
constructor(grade: number) {
this.message = grade >= 65 ? "Maybe next time..." : "You pass!";
}
}
class PassingAnnouncer extends GradeAnnouncer {
constructor() {
super(100);
}
}
class FailingAnnouncer extends GradeAnnouncer {
constructor() { }
// ~~~~~~~~~~~~~~~~~
// Error: Constructors for subclasses must contain a 'super' call.
}
根据 JavaScript 规则,在子类的构造函数中,在访问 this 或 super 之前必须调用基类构造函数。如果 TypeScript 在 super() 之前看到 this 或 super 被访问,将报告类型错误。
下面的 ContinuedGradesTally 类在其构造函数中在调用 super() 之前错误地引用了 this.grades:
class GradesTally {
grades: number[] = [];
addGrades(...grades: number[]) {
this.grades.push(...grades);
return this.grades.length;
}
}
class ContinuedGradesTally extends GradesTally {
constructor(previousGrades: number[]) {
this.grades = [...previousGrades];
// Error: 'super' must be called before accessing
// 'this' in the constructor of a subclass.
super();
console.log("Starting with length", this.grades.length); // Ok
}
}
被重写的方法
子类可以重新声明与基类相同名称的新方法,只要子类方法上的方法可以分配给基类上的方法即可。请记住,由于子类可以用在原始类可用的任何地方,新方法的类型必须能够替代原始方法的位置。
在这个例子中,FailureCounter 的 countGrades 方法被允许,因为它与基类 GradeCounter 的 countGrades 方法具有相同的第一个参数和返回类型。AnyFailureChecker 的 countGrades 因返回类型不正确而导致类型错误:
class GradeCounter {
countGrades(grades: string[], letter: string) {
return grades.filter(grade => grade === letter).length;
}
}
class FailureCounter extends GradeCounter {
countGrades(grades: string[]) {
return super.countGrades(grades, "F");
}
}
class AnyFailureChecker extends GradeCounter {
countGrades(grades: string[]) {
// Property 'countGrades' in type 'AnyFailureChecker' is not
// assignable to the same property in base type 'GradeCounter'.
// Type '(grades: string[]) => boolean' is not assignable
// to type '(grades: string[], letter: string) => number'.
// Type 'boolean' is not assignable to type 'number'.
return super.countGrades(grades, "F") !== 0;
}
}
const counter: GradeCounter = new AnyFailureChecker();
// Expected type: number
// Actual type: boolean
const count = counter.countGrades(["A", "C", "F"]);
被重写的属性
子类也可以显式地重新声明其基类的同名属性,只要新类型可分配给基类的类型即可。与重写方法一样,子类必须与基类结构匹配。
大多数重新声明属性的子类都是为了将这些属性作为类型联合的更具体子集,或者使属性成为扩展自基类属性类型的类型。
在这个例子中,基类 Assignment 将其 grade 声明为 number | undefined,而子类 GradedAssignment 将其声明为一个始终存在的 number:
class Assignment {
grade?: number;
}
class GradedAssignment extends Assignment {
grade: number;
constructor(grade: number) {
super();
this.grade = grade;
}
}
扩展属性联合类型的允许值集合是不允许的,因为这样做将使子类属性不再可分配给基类属性的类型。
在这个例子中,VagueGrade 的 value 尝试在基类 NumericGrade 的 number 类型之上添加 | string,导致类型错误:
class NumericGrade {
value = 0;
}
class VagueGrade extends NumericGrade {
value = Math.random() > 0.5 ? 1 : "...";
// Error: Property 'value' in type 'NumberOrString' is not
// assignable to the same property in base type 'JustNumber'.
// Type 'string | number' is not assignable to type 'number'.
// Type 'string' is not assignable to type 'number'.
}
const instance: NumericGrade = new VagueGrade();
// Expected type: number
// Actual type: number | string
instance.value;
抽象类
有时创建一个基类,它本身不声明某些方法的实现,而是期望子类提供它们,这样做可以很有用。通过在类名前面加上 TypeScript 的 abstract 关键字和任何预期为抽象的方法前面加上 abstract 关键字来标记一个类为抽象。这些抽象方法声明在抽象基类中不提供具体实现;相反,它们的声明方式与接口相同。
在此示例中,School 类及其 getStudentTypes 方法被标记为 abstract。因此,其子类——Preschool 和 Absence——应当实现 getStudentTypes:
abstract class School {
readonly name: string;
constructor(name: string) {
this.name = name;
}
abstract getStudentTypes(): string[];
}
class Preschool extends School {
getStudentTypes() {
return ["preschooler"];
}
}
class Absence extends School { }
// ~~~~~~~
// Error: Nonabstract class 'Absence' does not implement
// inherited abstract member 'getStudentTypes' from class 'School'.
抽象类不能直接实例化,因为它没有某些方法的定义,其实现可能假定存在。只有非抽象(“具体的”)类可以被实例化。
继续 School 示例,尝试调用 new School 将导致 TypeScript 类型错误:
let school: School;
school = new Preschool("Sunnyside Daycare"); // Ok
school = new School("somewhere else");
// Error: Cannot create an instance of an abstract class.
抽象类通常在期望消费者填写类细节的框架中使用。类可以用作类型注解,以指示值必须遵守该类,就像早期示例中的 school: School 一样,但创建新实例必须使用子类完成。
成员可见性
JavaScript 允许在类成员名称前面加 # 标记为“私有”类成员。私有类成员只能由该类的实例访问。JavaScript 运行时通过在类外部的代码尝试访问私有方法或属性时抛出错误来强制执行此隐私。
TypeScript 的类支持早于 JavaScript 的真正的 # 隐私功能,虽然 TypeScript 支持私有类成员,但它还允许在仅存在于类型系统中的类方法和属性上定义略微更加微妙的隐私定义。通过在类成员的声明名称之前添加以下关键字之一,可以实现 TypeScript 的成员可见性:
public(默认)
允许任何人在任何地方访问
protected
只允许类本身及其子类访问
private
只允许类本身访问
这些关键字仅存在于类型系统中。当代码编译为 JavaScript 时,它们与所有其他类型系统语法一起被移除。
在这里,Base 声明了两个 public 成员,一个 protected 成员,一个 private 成员和一个真正的私有成员 #truePrivate。Subclass 可以访问 public 和 protected 成员,但不能访问 private 或 #truePrivate:
class Base {
isPublicImplicit = 0;
public isPublicExplicit = 1;
protected isProtected = 2;
private isPrivate = 3;
#truePrivate = 4;
}
class Subclass extends Base {
examples() {
this.isPublicImplicit; // Ok
this.isPublicExplicit; // Ok
this.isProtected; // Ok
this.isPrivate;
// Error: Property 'isPrivate' is private
// and only accessible within class 'Base'.
this.#truePrivate;
// Property '#truePrivate' is not accessible outside
// class 'Base' because it has a private identifier.
}
}
new Subclass().isPublicImplicit; // Ok
new Subclass().isPublicExplicit; // Ok
new Subclass().isProtected;
// ~~~~~~~~~~~
// Error: Property 'isProtected' is protected
// and only accessible within class 'Base' and its subclasses.
new Subclass().isPrivate;
// ~~~~~~~~~~~
// Error: Property 'isPrivate' is private
// and only accessible within class 'Base'.
TypeScript 的成员可见性与 JavaScript 的真正私有声明的关键区别在于 TypeScript 的存在仅存在于类型系统中,而 JavaScript 的私有成员也存在于运行时。 TypeScript 中声明为 protected 或 private 的类成员在编译为 JavaScript 代码时,与显式或隐式声明为 public 的代码相同。与接口和类型注解一样,可见性关键字在输出 JavaScript 时会被擦除。只有 # 私有字段在运行时的 JavaScript 中才是真正私有的。
可见性修饰符可以与 readonly 一起标记。要同时声明成员为 readonly 和显式可见性,可见性应该先声明。
此 TwoKeywords 类将其 name 成员声明为 private 和 readonly:
class TwoKeywords {
private readonly name: string;
constructor() {
this.name = "Anne Sullivan"; // Ok
}
log() {
console.log(this.name); // Ok
}
}
const two = new TwoKeywords();
two.name = "Savitribai Phule";
// ~~~~
// Error: Property 'name' is private and
// only accessible within class 'TwoKeywords'.
// ~~~~
// Error: Cannot assign to 'name'
// because it is a read-only property.
请注意,不允许将 TypeScript 的旧成员可见性关键字与 JavaScript 的新 # 私有字段混合使用。私有字段默认始终为私有,因此无需额外使用 private 关键字标记它们。
静态字段修饰符
JavaScript 允许在类本身上声明成员——而不是其实例——使用 static 关键字。 TypeScript 支持在其自身上使用 static 关键字和/或与 readonly 和/或一个可见性关键字结合使用。当组合使用时,可见性关键字首先出现,然后是 static,然后是 readonly。
这个 HasStatic 类将它们全部整合到一起,使其 static 的 prompt 和 answer 属性都是 readonly 和 protected:
class Question {
protected static readonly answer: "bash";
protected static readonly prompt =
"What's an ogre's favorite programming language?";
guess(getAnswer: (prompt: string) => string) {
const answer = getAnswer(Question.prompt);
// Ok
if (answer === Question.answer) {
console.log("You got it!");
} else {
console.log("Try again...")
}
}
}
Question.answer;
// ~~~~~~
// Error: Property 'answer' is protected and only
// accessible within class 'HasStatic' and its subclasses.
使用只读和/或可见性修饰符来限制静态类字段,对于阻止这些字段在其类外被访问或修改很有用。
摘要
本章介绍了关于类的大量类型系统特性和语法:
-
声明和使用类的方法和属性
-
将属性标记为
readonly和/或可选 -
将类名用作类型注释的类型
-
实现接口以强制执行类实例的形状
-
扩展类,以及子类的可赋值性和覆盖规则
-
将类和方法标记为抽象
-
向类字段添加类型系统修饰符
提示
现在您已经完成了阅读本章,可以将所学的内容练习到 https://learningtypescript.com/classes 上。
为什么面向对象编程开发人员总是穿着西装?
因为它们拥有类。
第九章:类型修饰符
类型的类型来自于类型。
“世界之大无奇不有。”
Anders 喜欢说。
到现在为止,你已经详细了解了 TypeScript 类型系统如何与现有的 JavaScript 构造(例如数组、类和对象)配合工作。对于本章和第十章,“泛型”,我将进一步深入到类型系统本身,并展示侧重于编写更精确类型以及基于其他类型的特性。
顶部类型
在第四章,“对象”中,我提到了底部类型的概念,用来描述一种不能有可能值且不能被访问的类型。有道理认为类型理论中可能也存在相反的情况。确实存在!
顶部类型,或通用类型,是一种可以表示系统中任何可能值的类型。所有其他类型的值都可以提供给类型为顶部类型的位置。换句话说,所有类型都可以分配给顶部类型。
再次提到 any
any 类型可以充当顶部类型,因为任何类型都可以提供给类型为 any 的位置。通常情况下,当位置允许接受任何类型的数据时,如 console.log 的参数:
let anyValue: any;
anyValue = "Lucille Ball"; // Ok
anyValue = 123; // Ok
console.log(anyValue); // Ok
any 的问题在于它明确告诉 TypeScript 不要对该值的可分配性或成员进行类型检查。如果你想快速绕过 TypeScript 的类型检查器,这种缺乏安全性很有用,但类型检查的禁用会降低 TypeScript 对该值的实用性。
例如,下面的 name.toUpperCase() 调用肯定会崩溃,但因为 name 声明为 any,TypeScript 不会报类型错误:
function greetComedian(name: any) {
// No type error...
console.log(`Announcing ${name.toUpperCase()}!`);
}
greetComedian({ name: "Bea Arthur" });
// Runtime error: name.toUpperCase is not a function
如果你想表示一个值可以是任何东西,unknown 类型会更安全。
unknown
TypeScript 中的 unknown 类型是其真正的顶部类型。unknown 类似于 any,因为所有对象都可以传递给类型为 unknown 的位置。与 any 的关键区别在于,TypeScript 对 unknown 类型的值更加严格:
-
TypeScript 不允许直接访问
unknown类型值的属性。 -
unknown不能分配给非顶部类型(如any或unknown)的类型。
尝试访问 unknown 类型值的属性,例如以下片段,将导致 TypeScript 报告类型错误:
function greetComedian(name: unknown) {
console.log(`Announcing ${name.toUpperCase()}!`);
// ~~~~
// Error: Object is of type 'unknown'.
}
TypeScript 只有在值的类型被缩小(例如使用 instanceof 或 typeof,或使用类型断言)时,才允许对 unknown 类型的名称访问成员。
此代码片段使用 typeof 将 name 从 unknown 缩小为 string:
function greetComedianSafety(name: unknown) {
if (typeof value === "string") {
console.log(`Announcing ${name.toUpperCase()}!`); // Ok
} else {
console.log("Well, I'm off.");
}
}
greetComedianSafety("Betty White"); // Logs: 4
greetComedianSafety({}); // Does not log
这两个限制使得 unknown 比 any 更安全。通常情况下,应尽可能使用 unknown 而不是 any。
类型谓词
我之前向你展示过 JavaScript 构造(例如 instanceof 和 typeof)如何用于缩小类型。直接使用有限的这组检查是完全没问题的,但如果将逻辑封装在函数中,就会丢失这些检查。
例如,这个isNumberOrString函数接受一个值并返回一个布尔值,指示该值是number还是string。我们作为人类可以推断if语句中的value必须是这两种类型之一,因为isNumberOrString(value)返回 true,但 TypeScript 不知道。它只知道isNumberOrString返回一个布尔值,而不知道它意味着缩小参数的类型:
function isNumberOrString(value: unknown) {
return ['number', 'string'].includes(typeof value);
}
function logValueIfExists(value: number | string | null | undefined) {
if (isNumberOrString(value)) {
// Type of value: number | string | null | undefined
value.toString();
// Error: Object is possibly undefined.
} else {
console.log("Value does not exist:", value);
}
}
TypeScript 有一种专门的语法用于返回一个布尔值,表示参数是否是特定类型。这被称为类型谓词,有时也称为“用户定义的类型保护”:您作为开发人员正在创建自己的类型保护,类似于instanceof或typeof。类型谓词通常用于指示传递为参数的参数是否比参数的更具体类型。
类型谓词的返回类型可以声明为参数的名称、is关键字和某种类型:
function typePredicate(input: WideType): input is NarrowType;
我们可以将前面示例中的辅助函数更改为具有显式返回类型,明确说明value is number | string。TypeScript 将能够推断出仅在value is number | string为true时可达的代码块必须具有number | string类型的value。此外,仅在value is number | string为false时可达的代码块必须具有null | undefined类型的value:
function isNumberOrString(value: unknown): value is number | string {
return ['number', 'string'].includes(typeof value);
}
function logValueIfExists(value: number | string | null | undefined) {
if (isNumberOrString(value)) {
// Type of value: number | string
value.toString(); // Ok
} else {
// Type of value: null | undefined
console.log("value does not exist:", value);
}
}
您可以将类型谓词视为返回不仅是布尔值,而且还指示参数是更具体类型的指示。
类型谓词通常用于检查已知为一个接口实例的对象是否是更具体接口的实例。
在这里,StandupComedian接口包含了Comedian之上的附加信息。isStandupComedian类型守卫可用于检查一般的Comedian是否特别是StandupComedian:
interface Comedian {
funny: boolean;
}
interface StandupComedian extends Comedian {
routine: string;
}
function isStandupComedian(value: Comedian): value is StandupComedian {
return 'routine' in value;
}
function workWithComedian(value: Comedian) {
if (isStandupComedian(value)) {
// Type of value: StandupComedian
console.log(value.routine); // Ok
}
// Type of value: Comedian
console.log(value.routine);
// ~~~~~~~
// Error: Property 'routine' does not exist on type 'Comedian'.
}
警告:因为类型谓词在 false 情况下也会缩小类型,如果类型谓词检查的不仅仅是其输入的类型,可能会得到令人惊讶的结果。
这个isLongString类型谓词在其input参数为undefined或长度小于7的string时返回false。因此,else语句(其 false 情况)被缩小为认为text必须是undefined类型:
function isLongString(input: string | undefined): input is string {
return !!(input && input.length >= 7);
}
function workWithText(text: string | undefined) {
if (isLongString(text)) {
// Type of text: string
console.log("Long text:", text.length);
} else {
// Type of text: undefined
console.log("Short text:", text?.length);
// ~~~~~~
// Error: Property 'length' does not exist on type 'never'.
}
}
做更多事情而不仅仅是验证属性或值类型的类型谓词很容易被误用。我通常建议尽可能避免使用它们。对于大多数情况,简单的类型谓词就足够了。
类型运算符
并非所有类型都可以仅使用关键字或现有类型的名称来表示。有时可能需要创建一个结合两者的新类型,对现有类型的属性进行一些转换。
键名
JavaScript 对象可以使用动态值检索成员,这些成员通常(但不一定)是 string 类型。在类型系统中表示这些键可能会很棘手。使用类似 string 的通用类型将允许容器值的无效键。
这就是为什么在使用更严格的配置设置时,TypeScript 会在下一个示例中报告 ratings[key] 的错误。string 类型允许不在 Ratings 接口上声明的属性,而 Ratings 没有声明索引签名来允许任何 string 键:
interface Ratings {
audience: number;
critics: number;
}
function getRating(ratings: Ratings, key: string): number {
return ratings[key];
// ~~~~~~~~~~~
// Error: Element implicitly has an 'any' type because expression
// of type 'string' can't be used to index type 'Ratings'.
// No index signature with a parameter of
// type 'string' was found on type 'Ratings'.
}
const ratings: Ratings = { audience: 66, critic: 84 };
getRating(ratings, 'audience'); // Ok
getRating(ratings, 'not valid'); // Ok, but shouldn't be
另一个选项是使用文字的类型联合来允许的键。这将更准确地限制为仅容器值上存在的键:
function getRating(ratings: Ratings, key: 'audience' | 'critic'): number {
return ratings[key]; // Ok
}
const ratings: Ratings = { audience: 66, critic: 84 };
getCountLiteral(ratings, 'audience'); // Ok
getCountLiteral(ratings, 'not valid');
// ~~~~~~~~~~~
// Error: Argument of type '"not valid"' is not
// assignable to parameter of type '"audience" | "critic"'.
但是,如果接口有几十个或更多成员怎么办?你必须将每个成员的键手动输入到联合类型中,并保持更新。真是太麻烦了。
TypeScript 提供了 keyof 操作符,它接受一个现有类型,并返回该类型上允许的所有键的联合。将其放置在类型名称的前面,可以在任何可能使用类型的地方,例如类型注释。
在这里,keyof Ratings 等同于 'audience' | 'critic',但写出来更快,如果 Ratings 接口发生变化,也不需要手动更新:
function getCountKeyof(ratings: Ratings, key: keyof Ratings): number {
return ratings[key]; // Ok
}
const ratings: Ratings = { audience: 66, critic: 84 };
getCountKeyof(ratings, 'audience'); // Ok
getCountKeyof(ratings, 'not valid');
// ~~~~~~~~~~~
// Error: Argument of type '"not valid"' is not
// assignable to parameter of type 'keyof Ratings'.
keyof 是一个很棒的功能,用于基于现有类型的键创建联合类型。它还与 TypeScript 中的其他类型操作符结合得很好,允许使用一些非常巧妙的模式,您将在本章和第十五章,“类型操作”中看到。
typeof
TypeScript 提供的另一个类型操作符是 typeof。它返回提供值的类型。如果值的类型手动编写会很复杂,则这将非常有用。
在这里,adaptation 变量声明为与 original 相同的类型:
const original = {
medium: "movie",
title: "Mean Girls",
};
let adaptation: typeof original;
if (Math.random() > 0.5) {
adaptation = { ...original, medium: "play" }; // Ok
} else {
adaptation = { ...original, medium: 2 };
// ~~~~~~
// Error: Type 'number' is not assignable to type 'string'.
}
尽管 typeof 类型 操作符在视觉上看起来像是 运行时 的 typeof 操作符,用于返回值类型的字符串描述,但它们是不同的。它们只是偶然使用相同的词。请记住:JavaScript 运算符是运行时运算符,返回值的类型字符串名称。因为 TypeScript 版本是类型运算符,只能用于类型,并不会出现在编译后的代码中。
keyof typeof
typeof 可以检索值的类型,而 keyof 则检索类型上允许的键。TypeScript 允许这两个关键字链接在一起,以简洁地检索值类型的允许键。将它们结合起来,typeof 类型操作符在处理 keyof 类型操作时非常有用。
在此示例中,logRating 函数旨在接受 ratings 值类型的键之一。代码使用 keyof typeof 表示 key 必须是 ratings 值类型的键之一,而不是创建一个接口:
const ratings = {
imdb: 8.4,
metacritic: 82,
};
function logRating(key: keyof typeof ratings) {
console.log(ratings[key]);
}
logRating("imdb"); // Ok
logRating("invalid");
// ~~~~~~~~~
// Error: Argument of type '"missing"' is not assignable
// to parameter of type '"imdb" | "metacritic"'.
通过结合 keyof 和 typeof,我们可以避免编写并更新对象上允许的键的类型。
类型断言
当您的代码“强类型”时,TypeScript 的运作效果最佳:代码中的所有值都具有精确已知的类型。诸如顶级类型和类型守卫之类的功能提供了方法,将复杂的代码理顺,以便 TypeScript 的类型检查器能够理解。然而,有时不可能百分之百准确地告知类型系统代码的预期行为。
例如,JSON.parse 故意返回顶级类型 any。无法安全地通知类型系统给定给 JSON.parse 的特定字符串值应返回任何特定值类型。(正如我们将在第十章,“泛型”中看到的,为 parse 添加一个只用于返回类型的泛型类型将违反一个被称为泛型黄金法则的最佳实践。)
TypeScript 提供了一种语法来覆盖值类型系统理解的方式:“类型断言”,也称为“类型转换”。对于希望是不同类型的值,您可以使用 as 关键字后跟一个类型。TypeScript 将遵循您的断言,并将该值视为该类型。
在此片段中,JSON.parse 返回的结果可能是诸如 string[]、[string, string] 或 ["grace", "frankie"] 之类的类型。片段使用类型断言将代码中的三行类型从 any 转换为这些类型之一:
const rawData = `["grace", "frankie"]`;
// Type: any
JSON.parse(rawData);
// Type: string[]
JSON.parse(rawData) as string[];
// Type: [string, string]
JSON.parse(rawData) as [string, string];
// Type: ["grace", "frankie"]
JSON.parse(rawData) as ["grace", "frankie"];
类型断言仅存在于 TypeScript 类型系统中。它们与编译为 JavaScript 时移除的所有其他类型系统语法一起被移除。编译为 JavaScript 后,前述代码将如下所示:
const rawData = `["grace", "frankie"]`;
// Type: any
JSON.parse(rawData);
// Type: string[]
JSON.parse(rawData);
// Type: [string, string]
JSON.parse(rawData);
// Type: ["grace", "frankie"]
JSON.parse(rawData);
注意
如果您使用旧版本库或代码,则可能会看到不同的类型转换语法,看起来像 <type>item 而不是 item as type。因为此语法与 JSX 语法不兼容,因此在 .tsx 文件中无法使用,不建议使用。
TypeScript 的最佳实践通常是尽量避免使用类型断言。最好使您的代码完全类型化,不需要使用断言干预 TypeScript 对其类型的理解。但偶尔会有必要使用类型断言的情况。
断言捕获的错误类型
错误处理是另一个可能需要类型断言的地方。通常不可能知道 catch 块中捕获的错误类型是什么,因为 try 块中的代码可能会意外地抛出与预期不同的任何对象。此外,尽管 JavaScript 的最佳实践是始终抛出 Error 类的实例,但某些项目可能会抛出字符串文字或其他令人惊讶的值。
如果您绝对确定代码区域只会抛出Error类的实例,可以使用类型断言将捕获的断言视为Error。此代码片段访问了假定为Error类实例的捕获error的message属性:
try {
// (code that may throw an error)
} catch (error) {
console.warn("Oh no!", (error as Error).message);
}
通常更安全的做法是使用类型缩小的形式,比如使用instanceof检查来确保抛出的错误是预期的错误类型。此代码片段检查抛出的错误是否是Error类的实例,以决定是记录该消息还是记录错误本身:
try {
// (code that may throw an error)
} catch (error) {
console.warn("Oh no!", error instanceof Error ? error.message : error);
}
非空断言
类型断言的另一个常见用例是从只在理论上而非实际上可能包含null和/或undefined的变量中去除它们。这种情况非常普遍,TypeScript 包含了一个简写形式。不必编写as和排除null和undefined的完整类型,您可以使用!来表示相同的意思。换句话说,!非空断言断言类型不是null或undefined。
下面的两个类型断言在结果上是相同的,它们都产生Date而不是Date | undefined:
// Inferred type: Date | undefined
let maybeDate = Math.random() > 0.5
? undefined
: new Date();
// Asserted type: Date
maybeDate as Date;
// Asserted type: Date
maybeDate!;
非空断言在诸如Map.get这样的 API 中特别有用,如果不存在,则返回值或undefined。
在这里,seasonCounts是一个通用的Map<string, number>。我们知道它包含一个"I Love Lucy"键,所以knownValue变量可以使用!来从其类型中删除| undefined:
const seasonCounts = new Map([
["I Love Lucy", 6],
["The Golden Girls", 7],
]);
// Type: string | undefined
const maybeValue = seasonCounts.get("I Love Lucy");
console.log(maybeValue.toUpperCase());
// ~~~~~~~~~~
// Error: Object is possibly 'undefined'.
// Type: string
const knownValue = seasonCounts.get("I Love Lucy")!;
console.log(knownValue.toUpperCase()); // Ok
类型断言注意事项
类型断言,就像any类型一样,是 TypeScript 类型系统的一个必要的逃生口。因此,也像any类型一样,在合理的情况下应尽量避免使用。通常更好的做法是使用更准确的类型来表示您的代码,而不是为了断言值的类型而使其变得更容易。这些断言通常是错误的——要么在编写时已经错误,要么随着代码库的变化后来变得错误。
例如,假设seasonCounts示例随时间改变而在映射中具有不同的值。其非空断言可能仍然使代码通过 TypeScript 类型检查,但可能会导致运行时错误:
const seasonCounts = new Map([
["Broad City", 5],
["Community", 6],
]);
// Type: string
const knownValue = seasonCounts.get("I Love Lucy")!;
console.log(knownValue.toUpperCase()); // No type error, but...
// Runtime TypeError: Cannot read property 'toUpperCase' of undefined.
一般来说,应该节制地使用类型断言,只有在确保安全的情况下才使用。
断言与声明
在声明变量类型与使用类型断言来改变具有初始值的变量类型之间存在差异。当变量的初始值和变量的类型注释同时存在时,TypeScript 的类型检查器会执行可赋值性检查。然而,类型断言明确告诉 TypeScript 跳过一些类型检查。
下面的代码创建了两个具有相同缺陷的Entertainer类型的对象:缺少acts成员。 TypeScript 能够在declared变量中捕获错误,因为它具有: Entertainer类型注释。 由于类型断言,它无法在asserted变量上捕获错误:
interface Entertainer {
acts: string[];
name: string;
}
const declared: Entertainer = {
name: "Moms Mabley",
};
// Error: Property 'acts' is missing in type
// '{ one: number; }' but required in type 'Entertainer'.
const asserted = {
name: "Moms Mabley",
} as Entertainer; // Ok, but...
// Both of these statements would fail at runtime with:
// Runtime TypeError: Cannot read properties of undefined (reading 'toPrecision')
console.log(declared.acts.join(", "));
console.log(asserted.acts.join(", "));
因此,强烈建议要么使用类型注释,要么允许 TypeScript 从其初始值推断变量的类型。
断言可赋值性
类型断言仅用于一小部分情况,即某些值的类型略有错误。如果类型断言涉及两种完全不相关的类型,则 TypeScript 将会注意并报告类型错误。
例如,不允许从一个基本类型切换到另一个基本类型,因为基本类型彼此无关:
let myValue = "Stella!" as number;
// ~~~~~~~~~~~~~~~~~~~
// Error: Conversion of type 'string' to type 'number'
// may be a mistake because neither type sufficiently
// overlaps with the other. If this was intentional,
// convert the expression to 'unknown' first.
如果您绝对必须将一个值从一种类型切换到完全不相关的另一种类型,则可以使用双重类型断言。 首先将值强制转换为顶级类型 —— any或unknown —— 然后将该结果转换为不相关类型:
let myValueDouble = "1337" as unknown as number; // Ok, but... eww.
as unknown as...双重类型断言是危险的,并且几乎总是代码类型错误的标志。在代码周围使用它们作为逃逸舱从类型系统中,意味着当周围代码发生变化导致先前工作的代码出现问题时,类型系统可能无法帮助您。我只是将双重类型断言作为一个警示故事来帮助解释类型系统,并不鼓励它们的使用。
Const 断言
回到第四章,“对象”,我介绍了一个as const语法,用于将可变数组类型更改为只读元组类型,并承诺在书中的其他地方更多地使用它。 现在是时候了!
Const 断言通常可用于指示任何值 —— 数组、基本类型、值,你怎么称呼它 —— 应被视为不可变版本的常量。 具体来说,as const将以下三条规则应用于接收到的任何类型:
-
数组被视为
readonly元组,而不是可变数组。 -
字面量被视为字面量,而不是它们的一般基本类型的等价物。
-
对象上的属性被视为
readonly。
您已经看到数组变为元组,如将此数组断言为元组:
// Type: (number | string)[]
[0, ''];
// Type: readonly [0, '']
[0, ''] as const;
让我们深入了解as const产生的另外两个变化。
字面量到基本类型
让类型系统理解字面值为特定的字面量值,而不是扩展为其一般的基本类型,可能会很有用。
例如,类似于返回元组的函数,一个函数可能会返回一个特定的字面值,而不是一般的基本类型。 这些函数还返回可以更为具体的值 —— 在这里,getNameConst的返回类型是更具体的"Maria Bamford",而不是一般的string:
// Type: () => string
const getName = () => "Maria Bamford";
// Type: () => "Maria Bamford"
const getNameConst = () => "Maria Bamford" as const;
在值上具体字段可能也是有用的。许多流行的库要求值上的判别字段是特定的文字,以便它们的代码类型可以更具体地推断值。在这里,narrowJoke变量具有类型"one-liner"的style,而不是string类型,因此它可以在需要Joke类型的位置提供:
interface Joke {
quote: string;
style: "story" | "one-liner";
}
function tellJoke(joke: Joke) {
if (joke.style === "one-liner") {
console.log(joke.quote);
} else {
console.log(joke.quote.split("\n"));
}
}
// Type: { quote: string; style: "one-liner" }
const narrowJoke = {
quote: "If you stay alive for no other reason do it for spite.",
style: "one-liner" as const,
};
tellJoke(narrowJoke); // Ok
// Type: { quote: string; style: string }
const wideObject = {
quote: "Time flies when you are anxious!",
style: "one-liner",
};
tellJoke(wideObject);
// Error: Argument of type '{ quote: string; style: string; }'
// is not assignable to parameter of type 'LogAction'.
// Types of property 'style' are incompatible.
// Type 'string' is not assignable to type '"story" | "one-liner"'.
只读对象
对象文字,例如用作变量初始值的值通常会扩展属性的类型,就像let变量的初始值扩展一样。例如,字符串值如'apple'会变成基本类型string,数组被类型化为数组而不是元组,等等。当这些值中的一些或全部后来被用于需要其特定文字类型的地方时,这可能会不方便。
使用as const断言一个值的文字,然而,将推断类型转换为尽可能具体。所有成员属性变为readonly,文字被视为其自身的文字类型而不是它们的一般基本类型,数组变为只读元组,依此类推。换句话说,对值的文字应用 const 断言使得该值不可变,并递归地将相同的 const 断言逻辑应用于其所有成员。
例如,后续的preferencesMutable值是在没有as const声明的情况下声明的,因此其名称是原始类型string,并且允许修改。然而,favoritesConst使用as const声明,因此其成员值是文字类型而不允许修改:
function describePreference(preference: "maybe" | "no" | "yes") {
switch (preference) {
case "maybe":
return "I suppose...";
case "no":
return "No thanks.";
case "yes":
return "Yes please!";
}
}
// Type: { movie: string, standup: string }
const preferencesMutable = {
movie: "maybe"
standup: "yes",
};
describePreference(preferencesMutable.movie);
// ~~~~~~~~~~~~~~~~~~~~~~~~
// Error: Argument of type 'string' is not assignable
// to parameter of type '"maybe" | "no" | "yes"'.
preferencesMutable.movie = "no"; // Ok
// Type: readonly { readonly movie: "maybe", readonly standup: "yes" }
const preferencesReadonly = {
movie: "maybe"
standup: "yes",
} as const;
describePreference(preferencesReadonly.movie); // Ok
preferencesReadonly.movie = "no";
// ~~~~~
// Error: Cannot assign to 'movie' because it is a read-only property.
概要
在本章中,您使用类型修饰符将现有对象和/或类型转换为新类型:
-
顶级类型:高度宽容的
any和高度限制的unknown -
类型操作符:使用
keyof来获取类型的键和/或typeof来获取值的类型 -
使用和不使用类型断言来偷偷地改变值的类型
-
使用
as const断言来缩小类型
Tip
现在您已经完成了本章的阅读,请在https://learningtypescript.com/type-modifiers上练习所学内容。
为什么文字类型如此固执呢?
它思想狭隘。
第十章:泛型
您
在类型系统中声明?
全新的(类型化)世界!
到目前为止,您学到的所有类型语法都是用于在编写时完全知道它们的类型的类型。但有时,一段代码可能旨在根据调用方式与不同类型一起工作。
拿这个 identity 函数作为例子,在 JavaScript 中,它意味着接收任何可能类型的输入,并将相同的输入作为输出返回。您将如何描述其参数类型和返回类型?
function identity(input) {
return input;
}
identity("abc");
identity(123);
identity({ quote: "I think your self emerges more clearly over time." });
我们可以将 input 声明为 any,但那么函数的返回类型也将是 any:
function identity(input: any) {
return input;
}
let value = identity(42); // Type of value: any
鉴于 input 可以是任何输入,我们需要一种方式来表明 input 类型与函数返回的类型之间存在关系。TypeScript 使用 泛型 来捕获类型之间的关系。
在 TypeScript 中,诸如函数之类的构造物可以声明任意数量的泛型 类型参数:这些类型参数用作构造物中的类型,以表示每个实例中可以不同的某种类型。类型参数可以为构造物的每个实例提供不同的类型,称为 类型参数,但在该实例内部保持一致。
类型参数通常使用像T和U这样的单字母名称,或者像Key和Value这样的帕斯卡命名。在本章涵盖的所有结构中,泛型可以使用<和>括号声明,例如someFunction<T>或SomeInterface<T>。
泛型函数
函数可以通过在参数括号之前的尖括号内放置类型参数的别名来进行泛型化。该类型参数随后可用于参数类型注释、返回类型注释以及函数体内的类型注释。
下面版本的 identity 声明了一个类型参数 T 用于其 input 参数,这使得 TypeScript 能够推断函数的返回类型为 T。每次调用 identity 时,TypeScript 可以推断出不同的 T 类型:
function identity<T>(input: T) {
return input;
}
const numeric = identity("me"); // Type: "me"
const stringy = identity(123); // Type: 123
箭头函数也可以是泛型的。它们的泛型声明也放置在其参数列表之前的 ( 之前。
以下箭头函数在功能上与前面的声明相同:
const identity = <T>(input: T) => input;
identity(123); // Type: 123
警告
泛型箭头函数的语法在 .tsx 文件中有一些限制,因为它与 JSX 语法冲突。参见第十三章,“配置选项”以获取解决方法,以及配置 JSX 和 React 支持。
通过这种方式向函数添加类型参数,可以使其能够在不同的输入下重复使用,同时保持类型安全,并避免使用any类型。
显式泛型调用类型
大多数情况下,在调用泛型函数时,TypeScript 将能够根据函数调用的方式推断出类型参数。例如,在前面示例中的 identity 函数中,TypeScript 的类型检查器使用提供给 identity 的参数来推断相应的函数参数类型参数。
不幸的是,与类成员和变量类型一样,有时从函数调用中没有足够的信息来告诉 TypeScript 其类型参数应该解析为什么。如果泛型结构提供了其类型参数未知的另一个泛型结构,这种情况通常会发生。
对于无法推断其类型参数的任何类型参数,TypeScript 将默认为 unknown 类型。
例如,下面的 logWrapper 函数接受一个带有参数类型设置为 logWrapper 的类型参数 Input 的回调函数。如果 logWrapper 被调用时带有明确声明其参数类型的回调函数,TypeScript 可以推断出类型参数。然而,如果参数类型是隐式的,TypeScript 将无法知道 Input 应该是什么类型:
function logWrapper<Input>(callback: (input: Input) => void) {
return (input: Input) => {
console.log("Input:", input);
callback(input);
};
}
// Type: (input: string) => void
logWrapper((input: string) => {
console.log(input.length);
});
// Type: (input: unknown) => void
logWrapper((input) => {
console.log(input.length);
// ~~~~~~
// Error: Property 'length' does not exist on type 'unknown'.
});
为了避免默认为 unknown,函数可以以显式的泛型类型参数调用,明确告诉 TypeScript 应该将该类型参数解析为什么。TypeScript 将对泛型调用执行类型检查,以确保请求的参数与提供的类型参数匹配。
在这个例子中,之前看到的 logWrapper 被提供了一个显式的 string 作为其 Input 泛型。然后 TypeScript 可以推断出回调函数的 input 参数的泛型类型 Input 解析为 string 类型:
// Type: (input: string) => void
logWrapper<string>((input) => {
console.log(input.length);
});
logWrapper<string>((input: boolean) => {
// ~~~~~~~~~~~~~~~~~~~~~~~
// Argument of type '(input: boolean) => void' is not
// assignable to parameter of type '(input: string) => void'.
// Types of parameters 'input' and 'input' are incompatible.
// Type 'string' is not assignable to type 'boolean'.
});
就像在变量上明确指定类型注解一样,泛型函数上也可以始终指定明确的类型参数,但通常并不是必需的。许多 TypeScript 开发人员通常只在需要时才指定它们。
下面的 logWrapper 使用明确指定了 string 作为类型参数和函数参数类型。两者都可以移除:
// Type: (input: string) => void
logWrapper<string>((input: string) => { /* ... */ });
用于指定类型参数的 Name<Type> 语法将与本章节中的其他泛型结构相同。
多个函数类型参数
函数可以定义任意数量的类型参数,用逗号分隔。泛型函数的每次调用可以为其类型参数解析其自己的一组值。
在这个例子中,makeTuple 声明了两个类型参数,并返回一个以只读元组的形式分别为一个,然后另一个的值为类型化的值:
function makeTuple<First, Second>(first: First, second: Second) {
return [first, second] as const;
}
let tuple = makeTuple(true, "abc"); // Type of value: readonly [boolean, string]
请注意,如果函数声明了多个类型参数,调用该函数时必须明确声明泛型类型的全部或不含任何泛型类型。TypeScript 尚不支持仅推断泛型调用中的某些类型。
在这里,makePair 也接受两个类型参数,因此要么两者都不明确指定,要么都明确指定:
function makePair<Key, Value>(key: Key, value: Value) {
return { key, value };
}
// Ok: neither type argument provided
makePair("abc", 123); // Type: { key: string; value: number }
// Ok: both type arguments provided
makePair<string, number>("abc", 123); // Type: { key: string; value: number }
makePair<"abc", 123>("abc", 123); // Type: { key: "abc"; value: 123 }
makePair<string>("abc", 123);
// ~~~~~~
// Error: Expected 2 type arguments, but got 1.
提示
尽量不要在任何通用结构中使用超过一个或两个类型参数。与运行时函数参数一样,使用越多,代码的可读性和理解性就越差。
泛型接口
接口也可以声明为通用的。它们遵循与函数类似的通用规则:它们可以在名称后的<和>之间声明任意数量的类型参数。该通用类型可以稍后在其声明的其他位置使用,例如在成员类型中。
下面的Box声明具有T类型参数作为属性。使用类型参数声明为Box的对象强制inside: T属性与该类型参数匹配:
interface Box<T> {
inside: T;
}
let stringyBox: Box<string> = {
inside: "abc",
};
let numberBox: Box<number> = {
inside: 123,
}
let incorrectBox: Box<number> = {
inside: false,
// Error: Type 'boolean' is not assignable to type 'number'.
}
有趣的事实:内置的Array方法在 TypeScript 中被定义为通用接口! Array使用类型参数T来表示数组中存储的数据类型。其pop和push方法大致如下:
interface Array<T> {
// ...
/**
* Removes the last element from an array and returns it.
* If the array is empty, undefined is returned and the array is not modified.
*/
pop(): T | undefined;
/**
* Appends new elements to the end of an array,
* and returns the new length of the array.
* @param items new elements to add to the array.
*/
push(...items: T[]): number;
// ...
}
推断泛型接口类型
与通用函数一样,通用接口类型参数可以从使用中推断出。 TypeScript 将尽其所能从声明为接受通用类型的位置提供的值的类型推断类型参数。
此getLast函数声明了一个类型参数Value,然后用于其node参数。 TypeScript 可以根据传入参数的类型推断出Value。当推断出的类型参数与值的类型不匹配时,甚至可以报告类型错误。允许向getLast提供不包含next属性的对象,或其推断出的Value类型参数与给定对象的value和next.value不匹配是允许的,但是,这是一个类型错误:
interface LinkedNode<Value> {
next?: LinkedNode<Value>;
value: Value;
}
function getLast<Value>(node: LinkedNode<Value>): Value {
return node.next ? getLast(node.next) : node.value;
}
// Inferred Value type argument: Date
let lastDate = getLast({
value: new Date("09-13-1993"),
});
// Inferred Value type argument: string
let lastFruit = getLast({
next: {
value: "banana",
},
value: "apple",
});
// Inferred Value type argument: number
let lastMismatch = getLast({
next: {
value: 123
},
value: false,
// ~~~~~
// Error: type 'boolean' is not assignable to type 'number'.
});
注意,如果接口声明了类型参数,则任何引用该接口的类型注释都必须提供相应的类型参数。在这里,对CrateLike的使用不正确,因为没有包含类型参数:
interface CrateLike<T> {
contents: T;
}
let missingGeneric: CrateLike = {
// ~~~~~~~~~
// Error: Generic type 'Crate<T>' requires 1 type argument(s).
inside: "??"
};
在本章的后面,我将展示如何为类型参数提供默认值,以避免此要求。
泛型类
类,就像接口一样,也可以声明任意数量的类型参数,以后可以在其成员上使用。类的每个实例可以具有不同的类型参数集。
此Secret类声明了Key和Value类型参数,然后将它们用于成员属性、构造函数参数类型以及方法的参数和返回类型:
class Secret<Key, Value> {
key: Key;
value: Value;
constructor(key: Key, value: Value) {
this.key = key;
this.value = value;
}
getValue(key: Key): Value | undefined {
return this.key === key
? this.value
: undefined;
}
}
const storage = new Secret(12345, "luggage"); // Type: Secret<number, string>
storage.getValue(1987); // Type: string | undefined
与通用接口一样,使用类的类型注释必须告诉 TypeScript 该类上的任何通用类型是什么。在本章的后面,我将展示如何为类提供类型参数的默认值,以避免此要求。
显式通用类类型
实例化泛型类遵循与调用泛型函数相同的类型参数推断规则。如果类型参数可以从传递给类构造函数的参数的类型推断出来,例如之前的 new Secret(12345, "luggage"),TypeScript 将使用推断类型。否则,如果无法从传递给其构造函数的参数推断出类类型参数,则类型参数将默认为 unknown。
此 CurriedCallback 类声明了一个接受泛型函数的构造函数。如果泛型函数具有已知类型——例如来自显式类型参数类型注释——那么类实例的 Input 类型参数可以由此知情。否则,类实例的 Input 类型参数将默认为 unknown:
class CurriedCallback<Input> {
#callback: (input: Input) => void;
constructor(callback: (input: Input) => void) {
this.#callback = (input: Input) => {
console.log("Input:", input);
callback(input);
};
}
call(input: Input) {
this.#callback(input);
}
}
// Type: CurriedCallback<string>
new CurriedCallback((input: string) => {
console.log(input.length);
});
// Type: CurriedCallback<unknown>
new CurriedCallback((input) => {
console.log(input.length);
// ~~~~~~
// Error: Property 'length' does not exist on type 'unknown'.
});
类实例也可以通过与其他泛型函数调用相同的方式提供显式的类型参数来避免默认为 unknown。
在这里,之前的 CurriedCallback 现在为其 Input 类型参数提供了一个显式的 string,因此 TypeScript 可以推断出回调的 Input 类型参数解析为 string:
// Type: CurriedCallback<string>
new CurriedCallback<string>((input) => {
console.log(input.length);
});
new CurriedCallback<string>((input: boolean) => {
// ~~~~~~~~~~~~~~~~~~~~~~
// Argument of type '(input: boolean) => void' is not
// assignable to parameter of type '(input: string) => void'.
// Types of parameters 'input' and 'input' are incompatible.
// Type 'string' is not assignable to type 'boolean'.
});
扩展泛型类
泛型类可以作为 extends 关键字后的基类使用。TypeScript 不会尝试从使用中推断基类的类型参数。任何没有默认值的类型参数都需要使用显式类型注释来指定。
下面的 SpokenQuote 类为其基类 Quote<T> 提供了 string 作为 T 类型参数:
class Quote<T> {
lines: T;
constructor(lines: T) {
this.lines = lines;
}
}
class SpokenQuote extends Quote<string[]> {
speak() {
console.log(this.lines.join("\n"));
}
}
new Quote("The only real failure is the failure to try.").lines; // Type: string
new Quote([4, 8, 15, 16, 23, 42]).lines; // Type: number[]
new SpokenQuote([
"Greed is so destructive.",
"It destroys everything",
]).lines; // Type: string[]
new SpokenQuote([4, 8, 15, 16, 23, 42]);
// ~~~~~~~~~~~~~~~~~~~~~~
// Error: Argument of type 'number' is not
// assignable to parameter of type 'string'.
泛型派生类可以通过将它们自己的类型参数传递给它们的基类来传递自己的类型参数。类型名称不必匹配;仅仅是为了好玩,这个 AttributedQuote 将一个名为 Value 的不同命名的类型参数传递给基类 Quote<T>:
class AttributedQuote<Value> extends Quote<Value> {
speaker: string
constructor(value: Value, speaker: string) {
super(value);
this.speaker = speaker;
}
}
// Type: AttributedQuote<string>
// (extending Quote<string>)
new AttributedQuote(
"The road to success is always under construction.",
"Lily Tomlin",
);
实现泛型接口
泛型类也可以通过为其提供任何必要的类型参数来实现泛型接口。这与扩展泛型基类类似:基接口上的任何类型参数必须由类声明。
这里,MoviePart 类将 ActingCredit 接口的 Role 类型参数指定为 string。IncorrectExtension 类引发类型投诉,因为它的 role 类型为 boolean,尽管它将 string[] 作为 ActingCredit 的类型参数提供:
interface ActingCredit<Role> {
role: Role;
}
class MoviePart implements ActingCredit<string> {
role: string;
speaking: boolean;
constructor(role: string, speaking: boolean) {
this.role = role;
this.speaking = speaking;
}
}
const part = new MoviePart("Miranda Priestly", true);
part.role; // Type: string
class IncorrectExtension implements ActingCredit<string> {
role: boolean;
// ~~~~~~~
// Error: Property 'role' in type 'IncorrectExtension' is not
// assignable to the same property in base type 'ActingCredit<string>'.
// Type 'boolean' is not assignable to type 'string'.
}
方法泛型
类方法可以声明它们自己的泛型类型,与它们的类实例分开。对泛型类方法的每次调用可能对其类型参数的每个类型参数使用不同的类型参数。
此泛型 CreatePairFactory 类声明了一个 Key 类型,并包含一个还声明了一个单独的 Value 泛型类型的 createPair 方法。createPair 的返回类型然后被推断为 { key: Key, value: Value }:
class CreatePairFactory<Key> {
key: Key;
constructor(key: Key) {
this.key = key;
}
createPair<Value>(value: Value) {
return { key: this.key, value };
}
}
// Type: CreatePairFactory<string>
const factory = new CreatePairFactory("role");
// Type: { key: string, value: number }
const numberPair = factory.createPair(10);
// Type: { key: string, value: string }
const stringPair = factory.createPair("Sophie");
静态类泛型
类的静态成员与实例成员是分离的,并且不与类的任何特定实例关联。它们无法访问任何类实例或特定于任何类实例的类型信息。因此,虽然静态类方法可以声明它们自己的类型参数,但无法访问类上声明的任何类型参数。
在这里,BothLogger类为其instanceLog方法声明了一个OnInstance类型参数,为其静态staticLog方法声明了一个单独的OnStatic类型参数。静态方法无法访问实例OnInstance,因为OnInstance是为类实例声明的。
class BothLogger<OnInstance> {
instanceLog(value: OnInstance) {
console.log(value);
return value;
}
static staticLog<OnStatic>(value: OnStatic) {
let fromInstance: OnInstance;
// ~~~~~~~~~~
// Error: Static members cannot reference class type arguments.
console.log(value);
return value;
}
}
const logger = new BothLogger<number[]>;
logger.instanceLog([1, 2, 3]); // Type: number[]
// Inferred OnStatic type argument: boolean[]
BothLogger.staticLog([false, true]);
// Explicit OnStatic type argument: string
BothLogger.staticLog<string>("You can't change the music of your soul.");
泛型类型别名
TypeScript 中的最后一个可以使用类型参数泛化的构造是类型别名。每个类型别名可以给予任意数量的类型参数,例如这个Nullish类型接收一个T:
type Nullish<T> = T | null | undefined;
泛型类型别名通常与函数一起使用,以描述泛型函数的类型:
type CreatesValue<Input, Output> = (input: Input) => Output;
// Type: (input: string) => number
let creator: CreatesValue<string, number>;
creator = text => text.length; // Ok
creator = text => text.toUpperCase();
// ~~~~~~~~~~~~~~~~~~
// Error: Type 'string' is not assignable to type 'number'.
泛型判别联合
我在第四章,“对象”中提到过,判别联合是 TypeScript 中我最喜欢的功能,因为它们精美地结合了 JavaScript 的一种常见优雅模式与 TypeScript 的类型缩小。我最喜欢使用判别联合的方式是添加类型参数以创建一个通用的“结果”类型,该类型代表具有数据的成功结果或带有错误的失败结果。
此Result泛型类型具有必须用于将结果缩小到成功或失败的succeeded判别标志。这意味着任何返回Result的操作都可以指示错误或数据结果,并确保消费者需要检查结果是否成功:
type Result<Data> = FailureResult | SuccessfulResult<Data>;
interface FailureResult {
error: Error;
succeeded: false;
}
interface SuccessfulResult<Data> {
data: Data;
succeeded: true;
}
function handleResult(result: Result<string>) {
if (result.succeeded) {
// Type of result: SuccessfulResult<string>
console.log(`We did it! ${result.data}`);
} else {
// Type of result: FailureResult
console.error(`Awww... ${result.error}`);
}
result.data;
// ~~~~
// Error: Property 'data' does not exist on type 'Result<string>'.
// Property 'data' does not exist on type 'FailureResult'.
}
综合起来,泛型类型和判别类型提供了一种出色的方式来建模可重用的类型,如Result。
通用修饰符
TypeScript 包含的语法允许您修改泛型类型参数的行为。
泛型默认值
到目前为止,我已经说明了如果在类型注释中使用泛型类型或作为类的基础(extends或implements),则必须为每个类型参数提供类型参数。您可以通过在类型参数声明后放置一个=符号,然后跟一个默认类型来避免显式提供类型参数。默认值将用于任何后续类型,其中未显式声明类型参数并且无法推断出来。
在这里,Quote接口接收一个T类型参数,如果未提供,则默认为string。explicit变量显式设置T为number,而implicit和mismatch均解析为string:
interface Quote<T = string> {
value: T;
}
let explicit: Quote<number> = { value: 123 };
let implicit: Quote = { value: "Be yourself. The world worships the original." };
let mismatch: Quote = { value: 123 };
// ~~~
// Error: Type 'number' is not assignable to type 'string'.
类型参数也可以默认为同一声明中较早的类型参数。由于每个类型参数为声明引入一个新类型,因此它们可作为该声明中后续类型参数的默认值。
KeyValuePair 类型可以具有不同类型的 Key 和 Value 泛型,但默认保持它们相同 — 虽然因为 Key 没有默认值,它仍然需要可推断或提供:
interface KeyValuePair<Key, Value = Key> {
key: Key;
value: Value;
}
// Type: KeyValuePair<string, string>
let allExplicit: KeyValuePair<string, number> = {
key: "rating",
value: 10,
};
// Type: KeyValuePair<string>
let oneDefaulting: KeyValuePair<string> = {
key: "rating",
value: "ten",
};
let firstMissing: KeyValuePair = {
// ~~~~~~~~~~~~
// Error: Generic type 'KeyValuePair<Key, Value>'
// requires between 1 and 2 type arguments.
key: "rating",
value: 10,
};
所有默认类型参数在其声明列表中必须放在最后,类似于默认函数参数。没有默认值的泛型类型不能跟在有默认值的泛型类型之后。
在此处,inTheEnd 是允许的,因为所有没有默认值的泛型类型在具有默认值的泛型类型之前。inTheMiddle 是个问题,因为没有默认值的泛型类型跟在有默认值的类型后面:
function inTheEnd<First, Second, Third = number, Fourth = string>() {} // Ok
function inTheMiddle<First, Second = boolean, Third = number, Fourth>() {}
// // ~~~~~~
// Error: Required type parameters may not follow optional type parameters.
受约束的泛型类型
默认情况下,泛型类型可以赋予世界上任何类型:类、接口、基本类型、联合类型,你名字它。然而,某些函数只能与有限集合的类型一起使用。
TypeScript 允许一个类型参数声明自己需要 扩展 一种类型:这意味着它只允许别名为可分配给该类型的类型。约束类型参数的语法是在类型参数名称之后放置 extends 关键字,然后是要将其约束为的类型。
例如,通过创建一个 WithLength 接口来描述任何具有 length: number 属性的对象,我们可以让我们的泛型函数接受任何具有 length 属性的类型作为其 T 泛型。字符串、数组,甚至只是具有 length: number 属性的对象,都是允许的,而像 Date 这样缺少数值 length 的类型形状会导致类型错误:
interface WithLength {
length: number;
}
function logWithLength<T extends WithLength>(input: T) {
console.log(`Length: ${input.length}`);
return input;
}
logWithLength("No one can figure out your worth but you."); // Type: string
logWithLength([false, true]); // Type: boolean[]
logWithLength({ length: 123 }); // Type: { length: number }
logWithLength(new Date());
// ~~~~~~~~~~
// Error: Argument of type 'Date' is not
// assignable to parameter of type 'WithLength'.
// Property 'length' is missing in type
// 'Date' but required in type 'WithLength'.
我将在 第十五章,“类型操作” 中详细介绍您可以使用泛型执行的更多类型操作。
keyof 和受约束的类型参数
在 第九章,“类型修饰符” 中引入的 keyof 运算符也与受约束的类型参数非常配合。使用 extends 和 keyof 结合允许一个类型参数被限制为前一个类型参数的键。这也是指定泛型类型键的唯一方法。
以流行库 Lodash 的 get 方法的简化版本为例。它接受一个被定义为 T 类型的容器值,以及 T 中某个键的 key 名称,用于从 container 中检索。因为 Key 类型参数被限制为 T 的 keyof,TypeScript 知道此函数被允许返回 T[Key]:
function get<T, Key extends keyof T>(container: T, key: Key) {
return container[key];
}
const roles = {
favorite: "Fargo",
others: ["Almost Famous", "Burn After Reading", "Nomadland"],
};
const favorite = get(roles, "favorite"); // Type: string
const others = get(roles, "others"); // Type: string[]
const missing = get(roles, "extras");
// ~~~~~~~~
// Error: Argument of type '"extras"' is not assignable
// to parameter of type '"favorite" | "others"'.
如果没有 keyof,将无法正确地为泛型 key 参数设置类型。
请注意前面示例中 Key 类型参数的重要性。如果仅提供 T 作为类型参数,并且允许 key 参数为 T 的任何 keyof,则返回类型将是 Container 中所有属性值的联合类型。这种不太具体的函数声明并未告知 TypeScript 每次调用可以通过类型参数具有特定的 key:
function get<T>(container: T, key: keyof T) {
return container[key];
}
const roles = {
favorite: "Fargo",
others: ["Almost Famous", "Burn After Reading", "Nomadland"],
};
const found = get(roles, "favorite"); // Type: string | string[]
在编写泛型函数时,务必了解参数类型依赖于前一个参数类型的情况。在这些情况下,通常需要使用约束类型参数以正确地指定参数类型。
Promises
现在你已经看到了泛型的工作原理,终于是时候讨论现代 JavaScript 的核心特性之一:Promises!回顾一下,在 JavaScript 中,Promise 表示可能仍处于挂起状态的某些操作,比如网络请求。每个 Promise 都提供了方法来注册回调函数,以便在待定操作“解析”(成功完成)或“拒绝”(抛出错误)时调用。
一种Promise在任意值类型上表示类似操作的能力,是 TypeScript 泛型的天然适用。Promise在 TypeScript 类型系统中以单一类型参数表示最终解析的值。
创建 Promises
在 TypeScript 中,Promise构造函数被定义为接受一个单一参数的类型。该参数的类型依赖于泛型Promise类声明的类型参数。简化形式大致如下:
class PromiseLike<Value> {
constructor(
executor: (
resolve: (value: Value) => void,
reject: (reason: unknown) => void,
) => void,
) { /* ... */ }
}
创建一个打算最终解析为值的 Promise 通常需要显式声明 Promise 的类型参数。如果没有明确的泛型类型参数,TypeScript 默认将参数类型视为unknown。通过显式提供 Promise 构造函数的类型参数,TypeScript 可以理解结果 Promise 实例的解析类型:
// Type: Promise<unknown>
const resolvesUnknown = new Promise((resolve) => {
setTimeout(() => resolve("Done!"), 1000);
});
// Type: Promise<string>
const resolvesString = new Promise<string>((resolve) => {
setTimeout(() => resolve("Done!"), 1000);
});
Promise的泛型.then方法引入了一个新的类型参数,表示返回的 Promise 的解析值。
例如,以下代码创建了一个textEventually Promise,在一秒后解析为string值,以及一个lengthEventually,在额外等待一秒后解析为number:
// Type: Promise<string>
const textEventually = new Promise<string>((resolve) => {
setTimeout(() => resolve("Done!"), 1000);
});
// Type: Promise<number>
const lengthEventually = textEventually.then((text) => text.length)
异步函数
在 JavaScript 中声明带有async关键字的任何函数都会返回一个Promise。如果 JavaScript 中async函数返回的值不是 Thenable(具有.then()方法的对象;实际上几乎总是一个 Promise),则会像调用Promise.resolve一样将其包装在Promise中。TypeScript 识别这一点,并推断async函数的返回类型始终为Promise,无论返回的值是什么。
在这里,lengthAfterSecond直接返回一个Promise<number>,而lengthImmediately被推断为返回Promise<number>,因为它是async并直接返回number:
// Type: (text: string) => Promise<number>
async function lengthAfterSecond(text: string) {
await new Promise((resolve) => setTimeout(resolve, 1000))
return text.length;
}
// Type: (text: string) => Promise<number>
async function lengthImmediately(text: string) {
return text.length;
}
任何在async函数上手动声明的返回类型都必须始终是Promise类型,即使函数在实现中没有明确提到 Promises:
// Ok
async function givesPromiseForString(): Promise<string> {
return "Done!";
}
async function givesString(): string {
// ~~~~~~
// Error: The return type of an async function
// or method must be the global Promise<T> type.
return "Done!";
}
正确使用泛型
就像本章早期的Promise<Value>实现一样,尽管泛型可以在代码中描述类型给予我们很大的灵活性,但它们很快就会变得相当复杂。 TypeScript 的最佳实践通常是仅在必要时使用泛型,并清楚地说明它们的用途。
警告
在 TypeScript 中,大多数你编写的代码不应该过度使用泛型以至于令人困惑。然而,对于实用程序库的类型,特别是通用模块,有时可能需要大量使用它们。理解泛型特别有助于能够有效地使用这些实用程序类型。
泛型的黄金法则
一个快速的测试,可以帮助确定是否需要一个类型参数是它应该至少用两次。泛型描述类型之间的关系,因此,如果泛型类型参数只出现在一个地方,它不可能定义多个类型之间的关系。
每个函数类型参数应该用于一个参数,然后也用于至少一个其他参数和/或函数的返回类型。
例如,这个logInput函数仅使用其Input类型参数一次,来声明其input参数:
function logInput<Input extends string>(input: Input) {
console.log("Hi!", input);
}
不像本章早期的identify函数那样,logInput不对其类型参数做任何操作,如返回或声明更多参数。因此,声明Input类型参数没有多少用处。我们可以在不声明它的情况下重写logInput:
function logInput(input: string) {
console.log("Hi!", input);
}
《Effective TypeScript》(丹·范德坎,O'Reilly,2019)包含了几条关于如何使用泛型的极好建议,其中包括一节名为“泛型的黄金法则”。我强烈推荐阅读《Effective TypeScript》,特别是如果你在代码中发现自己花费了大量时间与泛型纠结的话。
泛型命名约定
许多语言(包括 TypeScript)的类型参数的标准命名约定是默认将第一个类型参数称为“T”(表示“type”或“template”),如果存在后续类型参数,则称为“U”、“V”等。
如果关于类型参数如何使用的上下文信息已知,则惯例有时会扩展到使用术语的第一个字母来表示其用途:例如,状态管理库可能会将通用状态称为“S”。数据结构中的“K”和“V”通常指键和值。
不幸的是,用一个字母命名类型参数可能会和用一个字符命名函数或变量一样令人困惑:
// What on earth are L and V?!
function labelBox<L, V>(l: L, v: V) { /* ... */ }
当单个字母T的泛型意图不明确时,最好使用描述性的泛型类型名称,以指示类型的用途:
// Much more clear.
function labelBox<Label, Value>(label: Label, value: Value) { /* ... */ }
当一个结构有多个类型参数,或者单个类型参数的目的不明确时,请考虑使用完整的名称以提高可读性,而不是使用单个字母的缩写。
总结
在本章中,通过允许它们使用类型参数,你使类、函数、接口和类型别名变得“通用”:
-
使用类型参数表示在结构的不同用法之间的不同类型
-
在调用泛型函数时提供显式或隐式的类型参数
-
使用泛型接口来表示通用对象类型
-
向类添加类型参数,以及这如何影响它们的类型
-
在类型别名中添加类型参数,特别是在有歧义的类型联合中
-
修改泛型类型参数的默认值 (
=) 和约束 (extends) -
Promises 和
async函数如何使用泛型来表示异步数据流 -
泛型的最佳实践,包括它们的黄金法则和命名约定
因此,本书的 特性 部分到此结束。恭喜:你现在了解了 TypeScript 类型系统中大多数项目的最重要的语法和类型检查特性!
接下来的章节 用法 将介绍如何配置 TypeScript 在你的项目中运行,如何与外部依赖交互,并调整其类型检查和生成的 JavaScript。这些是在你自己的项目中使用 TypeScript 的重要特性。
在 TypeScript 语法中还有一些其他的杂项类型操作。你不需要完全理解它们就可以在大多数 TypeScript 项目中工作,但它们很有趣,也很有用。我把它们放在了第 IV 部分,“额外学分”之后的第 III 部分,“用法”中,如果你有时间,可以当作一个有趣的小礼物。
提示
现在你已经完成了本章的阅读,可以在https://learningtypescript.com/generics上练习所学内容。
泛型为何会激怒开发者?
它们总是在输入参数。
第三部分:使用方法
第十一章:声明文件
声明文件
纯类型系统代码
没有运行时结构
尽管在 TypeScript 中编写代码很棒,这也是你想做的全部,但你需要能够在 TypeScript 项目中使用原始 JavaScript 文件。许多包直接用 JavaScript 编写,而不是 TypeScript。即使使用 TypeScript 编写的包也会以 JavaScript 文件的形式分发。
此外,TypeScript 项目需要一种方式告诉环境特定功能的类型形状,比如全局变量和 API。在 Node.js 中运行的项目可能可以访问浏览器中不可用的内置 Node 模块,反之亦然。
TypeScript 允许单独声明类型形状,与其实现分开。类型声明通常写在以.d.ts结尾的文件中,称为声明文件。声明文件通常在项目中编写,随项目的编译 npm 包一起构建和分发,或者作为独立的“typings”包共享。
声明文件
.d.ts声明文件通常与.ts文件类似,但有一个明显的限制,即不允许包含运行时代码。.d.ts文件仅包含可用运行时值、接口、模块和一般类型的描述,不能包含任何可能编译为 JavaScript 的运行时代码。
声明文件可以像任何其他源 TypeScript 文件一样导入。
此types.d.ts文件导出了一个在index.ts文件中使用的Character接口:
// types.d.ts
export interface Character {
catchphrase?: string;
name: string;
}
// index.ts
import { Character } from "./types";
export const character: Character = {
catchphrase: "Yee-haw!",
name: "Sandy Cheeks",
};
提示
声明文件创建了所谓的环境上下文,意味着代码区域只能声明类型,而不能声明值。
本章主要介绍声明文件及其内部使用的最常见的类型声明形式。
声明运行时值
虽然定义文件可能不会创建诸如函数或变量之类的运行时值,但它们可以使用declare关键字声明这些构造存在。这样做告诉类型系统,某些外部影响——比如网页中的<script>标签——已经创建了该名称下特定类型的值。
使用declare声明变量的语法与普通变量声明相同,只是不允许有初始值。
此片段成功声明了一个declared变量,但在试图给initializer变量赋值时接收到了类型错误:
// types.d.ts
declare let declared: string; // Ok
declare let initializer: string = "Wanda";
// ~~~~~~~
// Error: Initializers are not allowed in ambient contexts.
函数和类的声明形式也类似于它们的正常形式,但没有函数或方法的主体部分。
以下canGrantWish函数和方法在没有主体的情况下被正确声明,但尝试设置主体的grantWish函数和方法是语法错误:
// fairies.d.ts
declare function canGrantWish(wish: string): boolean; // Ok
declare function grantWish(wish: string) { return true; }
// ~
// Error: An implementation cannot be declared in ambient contexts.
class Fairy {
canGrantWish(wish: string): boolean; // Ok
grantWish(wish: string) {
// ~
// Error: An implementation cannot be declared in ambient contexts.
return true;
}
}
提示
TypeScript 的隐式 any 规则对于声明在环境上下文中的函数和变量与普通源代码中的规则相同。因为环境上下文可能不提供函数主体或初始变量值,因此显式类型注解(包括显式返回类型注解)通常是阻止它们隐式成为 any 类型的唯一方法。
虽然使用 declare 关键字进行类型声明在 .d.ts 定义文件中最常见,但 declare 关键字也可以在声明文件之外的模块或脚本文件中使用。当全局可用的变量仅打算在该文件中使用时,这是很有用的。
在这里,myGlobalValue 变量在 index.ts 文件中定义,因此允许在该文件中使用:
// index.ts
declare const myGlobalValue: string;
console.log(myGlobalValue); // Ok
请注意,在 .d.ts 定义文件中,类型形状(如接口)可以带有或不带有 declare,但是运行时构造(如函数或变量)如果没有 declare 将触发类型投诉:
// index.d.ts
interface Writer {} // Ok
declare interface Writer {} // Ok
declare const fullName: string; // Ok: type is the primitive string
declare const firstName: "Liz"; // Ok: type is the literal "value"
const lastName = "Lemon";
// Error: Top-level declarations in .d.ts files must
// start with either a 'declare' or 'export' modifier.
全局值
因为没有 import 或 export 语句的 TypeScript 文件被视为 scripts 而不是 modules,因此在其中声明的构造(包括类型)在应用程序的所有文件中全局可用。没有任何导入或导出的定义文件可以利用该行为来全局声明类型。全局定义文件特别适用于在应用程序中所有文件中声明全局类型或变量。
在这里,globals.d.ts 文件声明全局存在一个 const version: string。然后 version.ts 文件可以引用全局的 version 变量,尽管没有从 globals.d.ts 导入:
// globals.d.ts
declare const version: string;
// version.ts
export function logVersion() {
console.log(`Version: ${version}`); // Ok
}
在使用全局变量的浏览器应用程序中,通常会使用全局声明的值。尽管大多数现代 Web 框架通常使用更新的技术,如 ECMAScript 模块,但在较小的项目中,能够全局存储变量仍然很有用。
提示
如果发现无法自动访问 .d.ts 文件中声明的全局类型,请仔细检查该 .d.ts 文件是否导入或导出了任何内容。即使只有一个导出,整个文件也将不再全局可用!
全局接口合并
变量不是 TypeScript 项目类型系统中漂浮的唯一全局元素。许多类型声明全局存在于全局 API 和值中。由于接口与同名其他接口合并,因此在全局脚本上下文中声明接口(如没有任何 import 或 export 语句的 .d.ts 声明文件)将全局增加该接口。
例如,依赖服务器设置的全局变量的 Web 应用程序可能希望在全局 Window 接口上声明其存在。接口合并将允许诸如 types/window.d.ts 这样的文件声明一个存在于 Window 类型的全局 window 变量的变量:
<script type="text/javascript">
window.myVersion = "3.1.1";
</script>
// types/window.d.ts
interface Window {
myVersion: string;
}
// index.ts
export function logWindowVersion() {
console.log(`Window version is: ${window.myVersion}`);
window.alert("Built-in window types still work! Hooray!")
}
全局增补
并非总是可行的要在 .d.ts 文件中避免 import 或 export 语句,当您的全局定义通过从其他地方导入的类型大大简化时,如当模块文件中声明的类型意味着要在全局范围内使用时。
对于这些情况,TypeScript 允许使用 declare global 语法来全局声明一段代码。这样做将标记该块内容在全局上下文中,即使它们的周围环境不是:
// types.d.ts
// (module context)
declare global {
// (global context)
}
// (module context)
在这里,types/data.d.ts 文件导出了一个 Data 接口,稍后将被 types/globals.d.ts 和运行时的 index.ts 导入:
// types/data.d.ts
export interface Data {
version: string;
}
另外,types/globals.d.ts 在 declare global 块内全局声明了一个类型为 Data 的变量,以及一个仅在该文件中可用的变量:
// types/globals.d.ts
import { Data } from "./data";
declare global {
const globallyDeclared: Data;
}
declare const locallyDeclared: Data;
index.ts 然后可以访问 globallyDeclared 变量而无需导入,并且仍然需要导入 Data:
// index.ts
import { Data } from "./types/data";
function logData(data: Data) { // Ok
console.log(`Data version is: ${data.version}`);
}
logData(globallyDeclared); // Ok
logData(locallyDeclared);
// ~~~~~~~~~~~~~~~
// Error: Cannot find name 'locallyDeclared'.
调和全局和模块声明以便良好协作可能有些棘手。正确使用 TypeScript 的 declare 和 global 关键字可以描述哪些类型定义应该在项目中全局可用。
内置声明
现在您已经看到声明是如何工作的,是时候揭示它们在 TypeScript 中的隐含用途了:它们一直在驱动其类型检查!诸如 Array、Function、Map 和 Set 等全局对象是类型系统需要了解但不在您的代码中声明的构造的示例。它们由您的代码所用的运行时(如 Deno、Node、Web 浏览器等)提供:
库声明
内置全局对象,如在所有 JavaScript 运行时中存在的 Array 和 Function,在名为 lib.[target].d.ts 的文件中声明。target 是您的项目所针对的 JavaScript 的最低支持版本,例如 ES5、ES2020 或 ESNext。
内置库定义文件或“lib 文件”相当庞大,因为它们代表了 JavaScript 内置 API 的全部。例如,内置 Array 类型的成员由全局 Array 接口表示,起始如下:
// lib.es5.d.ts
interface Array<T> {
/**
* Gets or sets the length of the array.
* This is a number one higher than the highest index in the array.
*/
length: number;
// ...
}
Lib 文件作为 TypeScript npm 包的一部分进行分发。您可以在包内的路径,如 node_modules/typescript/lib/lib.es5.d.ts 中找到它们。对于像 VS Code 这样使用其自己打包的 TypeScript 版本来对代码进行类型检查的 IDE,您可以通过右键单击代码中的内置方法,如数组的 forEach,然后选择类似“转到定义”(图 11-1)的选项来找到所使用的 lib 文件。

图 11-1. 左侧:在 forEach 上跳转到定义;右侧:打开的结果 lib.es5.d.ts 文件
库目标
TypeScript 默认会根据传递给 tsc CLI 的 target 设置以及项目的 tsconfig.json 文件(默认为 "es5")包含适当的 lib 文件。随着新版本 JavaScript 的 lib 文件的增加,它们通过接口合并进行构建。
例如,ES2015 中添加的静态 Number 成员,如 EPSILON 和 isFinite,列在 lib.es2015.d.ts 中:
// lib.es2015.d.ts
interface NumberConstructor {
/**
* The value of Number.EPSILON is the difference between 1 and the
* smallest value greater than 1 that is representable as a Number
* value, which is approximately:
* 2.2204460492503130808472633361816 x 10−16.
*/
readonly EPSILON: number;
/**
* Returns true if passed value is finite.
* Unlike the global isFinite, Number.isFinite doesn't forcibly
* convert the parameter to a number. Only finite values of the
* type number result in true.
* @param number A numeric value.
*/
isFinite(number: unknown): boolean;
// ...
}
TypeScript 项目将包含 JavaScript 各版本目标的 lib 文件,直到其最低目标版本。例如,目标是 "es2016" 的项目将包含 lib.es5.d.ts、lib.es2015.d.ts 和 lib.es2016.d.ts。
提示
只有比目标版本更新的 JavaScript 中可用的语言特性在类型系统中才可用。例如,如果目标是 "es5",那么 ES2015 或更高版本的语言特性如 String.prototype.startsWith 将不会被识别。
编译选项如 target 在 第十三章,“配置选项” 中有详细说明。
DOM 声明
除了 JavaScript 语言本身之外,类型声明最常引用的领域是网络浏览器。Web 浏览器类型通常称为“DOM”类型,涵盖诸如 localStorage 和主要在 Web 浏览器中可用的 HTMLElement 等 API 和类型形状。DOM 类型存储在 lib.dom.d.ts 文件中,与其他 lib..d.ts* 声明文件并列。
像许多内置全局一样,全局 DOM 类型通常用全局接口描述。例如,用于 localStorage 和 sessionStorage 的 Storage 接口大致如下所示:
// lib.dom.d.ts
interface Storage {
/**
* Returns the number of key/value pairs.
*/
readonly length: number;
/**
* Removes all key/value pairs, if there are any.
*/
clear(): void;
/**
* Returns the current value associated with the given key,
* or null if the given key does not exist.
*/
getItem(key: string): string | null;
// ...
}
TypeScript 在默认情况下会在不覆盖 lib 编译选项的项目中包含 DOM 类型。这有时可能会让在非浏览器环境(如 Node)工作的开发人员感到困惑,因为他们不应该能够访问全局 API(如 document 和 localStorage),但类型系统会声明这些全局 API 存在。编译选项如 lib 的详细信息在 第十三章,“配置选项” 中有详细说明。
模块声明
声明文件的另一个重要特性是描述模块形状的能力。可以在模块的字符串名称前使用 declare 关键字来通知类型系统该模块的内容。
在这里,"my-example-lib" 模块被声明存在于一个 modules.d.ts 声明脚本文件中,然后在 index.ts 文件中使用:
// modules.d.ts
declare module "my-example-lib" {
export const value: string;
}
// index.ts
import { value } from "my-example-lib";
console.log(value); // Ok
在自己的代码中,您不应该经常使用 declare module,或者根本不需要。它主要与以下部分的通配符模块声明以及本章后面涵盖的包类型一起使用。另外,请参阅 第十三章,“配置选项” 了解 resolveJsonModule,这是一个编译选项,允许 TypeScript 原生识别来自 .json 文件的导入。
通配符模块声明
模块声明的常见用途是告诉 Web 应用程序,特定的非 JavaScript/TypeScript 文件扩展名可供import到代码中使用。模块声明可能包含一个单独的*通配符,表示任何匹配该模式的模块都看起来相同。
例如,许多 Web 项目(如预配置在流行的 React 启动器中,如 create-react-app 和 create-next-app 中的项目)支持 CSS 模块,以从 CSS 文件导入样式作为可在运行时使用的对象。它们会使用类似"*.module.css"的模式定义模块,该模块默认导出一个类型为{ [i: string]: string }的对象:
// styles.d.ts
declare module "*.module.css" {
const styles: { [i: string]: string };
export default styles;
}
// component.ts
import styles from "./styles.module.css";
styles.anyClassName; // Type: string
警告
使用通配符模块来表示本地文件并不完全安全。TypeScript 没有提供一种机制来确保导入的模块路径与本地文件匹配。一些项目使用构建系统(如 Webpack)和/或从本地文件生成.d.ts文件来确保导入匹配。
包类型
现在您已经看到如何在项目中声明类型,现在是时候讨论如何在包之间消费类型。使用 TypeScript 编写的项目通常仍然分发包含编译的.js输出的包。它们通常使用.d.ts文件来声明这些 JavaScript 文件背后的 TypeScript 类型系统形状。
声明
TypeScript 提供了一个declaration选项,用于在 JavaScript 输出文件旁边创建.d.ts输出。
例如,给定以下index.ts源文件:
// index.ts
export const greet = (text: string) => {
console.log(`Hello, ${text}!`);
};
使用declaration,module为"es2015",target为"es2015",将生成以下输出:
// index.d.ts
export declare const greet: (text: string) => void;
// index.js
export const greet = (text) => {
console.log(`Hello, ${text}!`);
};
自动生成的.d.ts文件是项目创建供消费者使用的类型定义的最佳方式。通常建议大多数使用 TypeScript 编写并生成.js文件输出的包也应该将.d.ts文件与这些文件一起捆绑。
编译器选项,例如declaration,在第十三章,“配置选项”中有更详细的说明。
依赖包类型
TypeScript 能够检测并利用项目node_modules依赖中捆绑的.d.ts文件。这些文件会向类型系统提供关于该包导出的类型形状的信息,就像它们是在同一项目内编写或使用declare模块块声明一样。
典型的 npm 模块如果自带其自己的.d.ts声明文件,可能会有如下的文件结构:
lib/
index.js
index.d.ts
package.json
作为示例,备受欢迎的测试运行器Jest是用 TypeScript 编写的,并在其jest包中提供了自己捆绑的.d.ts文件。它依赖于@jest/globals包,该包提供诸如describe和it等函数,jest然后将其全局可用:
// package.json
{
"devDependencies": {
"jest": "³².1.0"
}
}
// using-globals.d.ts
describe("MyAPI", () => {
it("works", () => { /* ... */ });
});
// using-imported.d.ts
import { describe, it } from "@jest/globals";
describe("MyAPI", () => {
it("works", () => { /* ... */ });
});
如果我们要从头开始重新创建 Jest 类型定义包的非常有限子集,它们可能看起来像这些文件一样。@jest/globals 包导出 describe 和 it 函数。然后,jest 包导入这些函数,并使用其相应函数类型增强全局作用域的 describe 和 it 变量:
// node_modules/@jest/globals/index.d.ts
export function describe(name: string, test: () => void): void;
export function it(name: string, test: () => void): void;
// node_modules/jest/index.d.ts
import * as globals from "@jest/globals";
declare global {
const describe: typeof globals.describe;
const it: typeof globals.it;
}
这种结构允许使用 Jest 的项目引用全局版本的 describe 和 it。项目也可以选择从 @jest/globals 包导入这些函数。
暴露包类型
如果您的项目旨在在 npm 上分发并为消费者提供类型,请在包的 package.json 文件中添加 "types" 字段,指向根声明文件。types 字段的工作方式与 main 字段类似,通常看起来相同,但扩展名为 .d.ts 而不是 .js。
例如,在这个 fictional 包文件中,./lib/index.js 的主运行时文件与 ./lib/index.d.ts 的类型文件并行:
{
"author": "Pendant Publishing",
"main": "./lib/index.js",
"name": "coffeetable",
"types": "./lib/index.d.ts",
"version": "0.5.22",
}
TypeScript 随后将使用 ./lib/index.d.ts 的内容,作为导入 utilitarian 包的消费文件应提供的内容。
注意
如果包的 package.json 中不存在 types 字段,则 TypeScript 将假定默认值为 ./index.d.ts。这反映了 npm 的默认行为,即如果未指定,则假定 ./index.js 文件为包的 main 入口点。
大多数包使用 TypeScript 的 declaration 编译选项,在源文件生成 .js 输出的同时创建 .d.ts 文件。编译器选项在第十三章,“配置选项”中有详细介绍。
DefinitelyTyped
不幸的是,并非所有项目都是用 TypeScript 编写的。一些不幸的开发人员仍在用纯老式 JavaScript 写他们的项目,没有类型检查器来帮助他们。可怕。
我们的 TypeScript 项目仍然需要了解这些包的模块类型形状。TypeScript 团队和社区创建了一个名为DefinitelyTyped的巨大存储库,用于容纳社区编写的包定义。DefinitelyTyped,或简称 DT,是 GitHub 上最活跃的存储库之一。它包含数千个 .d.ts 定义的包,以及围绕审核变更提案和发布更新的自动化。
DT 包在 npm 上发布,在 @types 范围下使用与其提供类型的包同名。例如,截至 2022 年,@types/react 为 react 包提供类型定义。
注意
@types通常安装为dependencies或devDependencies,尽管这两者之间的区别在近年来变得模糊。通常来说,如果你的项目是作为 npm 包分发的,它应该使用dependencies,这样包的使用者也会引入所使用的类型定义。如果你的项目是一个独立的应用程序,比如在服务器上构建和运行的应用,它应该使用devDependencies来表明类型只是开发时工具。
例如,对于一个依赖于lodash的实用程序包(截至 2022 年,有一个单独的@types/lodash包),package.json应包含类似以下行:
// package.json
{
"dependencies": {
"@types/lodash": "⁴.14.182",
"lodash": "⁴.17.21",
}
}
建立在 React 上的独立应用程序的package.json可能包含类似以下行:
// package.json
{
"dependencies": {
"react": "¹⁸.1.0"
},
"devDependencies": {
"@types/react": "¹⁸.0.9"
},
}
请注意,语义化版本号(“semver”)在@types/包和它们代表的包之间不一定匹配。你经常会发现它们可能会有一些偏差,如 React 早期的补丁版本,Lodash 早期的次版本,甚至主版本。
警告
因为这些文件由社区编写,它们可能落后于父项目或存在小的不准确之处。如果你的项目编译成功但在调用库时出现运行时错误,请调查你正在访问的 API 签名是否发生了变化。这在成熟项目和稳定 API 表面的情况下较少见,但仍然不罕见。
类型可用性
大多数流行的 JavaScript 包要么使用自己的类型要么通过 DefinitelyTyped 提供类型。
如果你想为一个还没有可用类型的包获取类型,你最常见的三个选项将是:
-
向 DefinitelyTyped 发送一个拉取请求以创建其
@types/包。 -
使用早期引入的
declare module语法在你的项目中编写类型。 -
禁用
noImplicitAny,如在第十三章“配置选项”中所述,并强烈警告不要这样做。
我建议如果你有时间的话,为 DefinitelyTyped 贡献类型。这样做有助于其他可能想使用该包的 TypeScript 开发者。
提示
查看aka.ms/types以显示一个包是通过捆绑类型还是通过单独的@types/包提供类型。
摘要
在本章中,您使用声明文件和值声明来告知 TypeScript 关于未在源代码中声明的模块和值:
-
使用.d.ts创建声明文件
-
使用
declare关键字声明类型和值 -
使用全局值、全局接口合并和全局扩展来更改全局类型
-
配置和使用 TypeScript 内置的目标、库和 DOM 声明
-
声明模块类型,包括通配符模块
-
TypeScript 如何从包中获取类型
-
使用 DefinitelyTyped 获取不包含类型的包的类型
提示
现在您已经完成阅读本章,可以在https://learningtypescript.com/declaration-files上练习所学内容。
TypeScript 的类型在美国南部说什么呢?
“Why, I do
declare!”
第十二章:使用 IDE 功能
编程时
第一次使用 IDE 的感觉
就像超能力一样。
没有流行的编程语言会完整无缺地提供语法高亮和其他 IDE 功能来帮助开发。TypeScript 最大的优势之一是其语言服务为 JavaScript 和 TypeScript 代码提供了一套强大的开发助手。本章将介绍一些最有用的项目。
我强烈建议你在阅读本书的同时尝试这些 IDE 功能,应用于你已经构建的 TypeScript 项目中。尽管本章中的所有示例和截图都是在我最喜欢的编辑器 VS Code 中完成的,但任何支持 TypeScript 的 IDE 都将支持本章的大多数或全部功能。截至 2022 年,这包括原生支持或至少支持 TypeScript 插件的所有编辑器:Atom,Emacs,Vim,Visual Studio 和 WebStorm。
注意
本章列出了一些常用的 TypeScript IDE 功能,并附有它们在 VS Code 中的默认快捷键。在你继续编写 TypeScript 代码时,可能会发现更多功能。
许多 IDE 功能通常通过在代码中右键单击名称来显示上下文菜单。诸如 VS Code 之类的 IDE 通常也在上下文菜单中显示键盘快捷键。熟悉你的 IDE 键盘快捷键可以帮助你更快地编写代码和执行重构。
此截图显示了在 VS Code 中,用于 TypeScript 变量的命令列表及其快捷方式(图 12-1)。

图 12-1 VS Code 显示右键上下文菜单中用于变量的命令列表
小贴士
在 VS Code 中,与大多数应用程序一样,上下箭头选择下拉选项,Enter 激活选项之一。
代码导航
开发者通常花费更多时间阅读代码而不是主动编写代码。帮助导航代码的工具对于加速这一过程非常有用。TypeScript 语言服务提供的许多功能都旨在帮助理解代码,特别是在类型定义或代码中值之间跳转的功能。
现在我将逐一介绍上下文菜单中常用的导航选项,以及它们在 VS Code 中的快捷键。
查找定义
TypeScript 可以从类型定义或值的引用开始,导航到代码中它们的原始位置。VS Code 还提供了一些以此方式回溯的方法:
-
转到定义(F12)直接导航到请求的名称最初定义的位置。
-
Cmd(Mac)/ Ctrl(Windows)+ 单击名称也会触发转到定义。
-
Peek > Peek Definition(Option(Mac)/ Alt(Windows)+ F12)会显示定义的 Peek 框。
Go to Type Definition 是 Go to Definition 的专业版本,用于查找值的类型定义。对于类或接口的实例,它将显示类或接口本身,而不是实例定义的位置。
这些截图展示了查找导入到带有 Go to Definition 的文件中的 data 变量定义(参见图 12-2)。

图 12-2. 左侧:点击变量名进行定义;右侧:结果打开的 data.ts 文件
当定义声明在您自己的代码中时,如相对文件,编辑器将会带您进入该文件。在您的代码之外的模块,例如 npm 包,通常会使用 .d.ts 声明文件。
查找引用
给定类型定义或值,TypeScript 可以显示项目中所有引用它或使用它的位置的列表。VS Code 提供了几种可视化该列表的方法。
前往引用(Shift + F12)显示对该类型定义或值的引用列表——从它本身开始——在右键单击的名称下方的可展开的 Peek 框中。
例如,这里展示了一个 data 变量在一个文件 data.ts 中声明的引用的 Go to References,显示了该声明以及它在另一个文件 index.ts 中的使用(参见图 12-3)。

图 12-3. Peek 菜单显示对变量的引用
该 Peek 框包含引用文件的视图。您可以像通常打开的文件一样使用该文件——例如,运行编辑器命令等。您还可以双击 Peek 框中文件的视图以打开该文件。
单击 Peek 框右侧的文件名列表将切换 Peek 框的文件视图到单击的文件。双击列表中文件的一行将打开该文件并选择其匹配的引用。
在此,VS Code 显示了相同 data 变量的声明和使用,但是在右侧的侧边栏视图中展开(参见图 12-4)。

图 12-4. Peek 菜单显示对变量的打开引用
查找所有引用(Option(Mac)/ Alt(Windows)+ Shift + F12)还显示引用的列表,但以侧边栏视图形式呈现,在代码导航后保持可见。这对于一次打开或执行多个引用的操作非常有用(参见图 12-5)。

图 12-5. 查找变量的所有引用菜单
查找实现
转到实现(Cmd(Mac)/ Ctrl(Windows)+ F12)和查找所有实现是专为接口和抽象类方法而制作的 Go To / Find All References 版本。它们可以在代码中查找接口或抽象方法的所有实现(图 12-6)。

图 12-6. AI 接口的查找所有实现菜单
当您特别搜索作为类或接口类型的值如何使用时,这些功能尤其有帮助。查找所有引用可能会过于嘈杂,因为它还将显示类或接口的定义及其他类型引用。
编写代码
IDE 语言服务,例如 VS Code 的 TypeScript 服务,在编辑器的后台运行,并对文件中的操作做出反应。它们可以在您输入文件时即时看到文件的编辑,甚至在对文件进行保存之前。这样做可以启用一系列功能,帮助在编写 TypeScript 代码时自动化常见任务。
完成名称
编辑器可以使用 TypeScript 的 API 填写同一文件中存在的名称。当您开始键入名称时,例如在将先前声明的变量作为函数参数提供时,使用 TypeScript 的编辑器通常会建议具有匹配名称的变量列表的自动完成。单击列表中的名称或按 Enter 键将完成名称(图 12-7)。

图 12-7. 左侧:在变量 dat 上的自动完成;右侧:从导入的 data 的结果
自动导入功能还将为包依赖项提供添加选项。这些截图展示了 TypeScript 文件在从 "lodash" 包导入 sortBy 前后的导入和模块代码(图 12-8)。

图 12-8. 左侧:在变量 sortBy 上的自动完成;右侧:从 lodash 导入的 sortBy 的结果
TypeScript 体验中我最喜欢的功能之一是自动导入。它们极大地加快了通常费时的过程,即找出导入的来源,然后显式地将其键入。
类似地,如果您开始输入类型值的属性名称,由 TypeScript 支持的编辑器将提供自动完成到该值类型已知属性的选项(图 12-9)。

图 12-9. 左侧:在属性 forE 上的自动完成;右侧:自动完成为 .forEach 的结果
自动导入更新
如果您重命名文件或将其移动到另一个文件夹,您可能需要更新该文件的许多导入语句。更新可能需要在该文件本身以及任何从中导入的其他文件中进行。
如果您使用 VS Code 文件资源管理器拖放文件或将其重命名为嵌套文件夹路径,VS Code 将提供使用 TypeScript 来为您更新文件路径的选项。
这些截图展示了 src/logging.ts 文件被重命名为 src/shared/logging.ts 并相应地更新文件导入(图 12-10)。

图 12-10. 左图:src/index.ts 文件从 "./logging" 进行导入;中图:将 src/logging.ts 重命名为 src/shared/logging.ts;右图:src/index.ts 中更新的导入路径
小贴士
多文件编辑可能会导致文件变更未保存。在对它们运行编辑后,请记得保存任何更改过的文件。
代码操作
许多 TypeScript 的 IDE 实用工具都是您可以触发的操作。虽然其中一些只修改当前正在编辑的文件,但有些可以一次修改多个文件。使用这些代码操作是引导 TypeScript 自动执行您的手动代码编写任务的绝佳方式,例如计算导入路径和常见重构。
编辑器通常在可用时使用某种图标来表示代码操作。例如,VS Code 在至少有一个代码操作可用时,会显示一个可点击的灯泡图标,显示在您的文本光标旁边(图 12-11)。

图 12-11. 代码操作灯泡,旁边是一个导致类型错误的名称
小贴士
编辑器通常会暴露键盘快捷键,用于操作代码操作菜单或类似功能,允许您在本章中触发任何操作,而无需使用鼠标。VS Code 在 Mac 上打开代码操作菜单的默认快捷键是 Cmd + .,在 Linux/Windows 上是 Ctrl + .。使用上下箭头选择下拉选项,按 Enter 键激活选中的操作。
这些代码操作,特别是重命名和重构,因 TypeScript 的类型系统而特别强大。当对类型应用操作时,TypeScript 将理解所有文件中属于该类型的值,并可以对这些值进行任何需要的更改。
重命名
对已存在的名称(如函数、接口或变量)进行更改可能会手动操作繁琐。TypeScript 可以为名称执行重命名,同时更新所有引用该名称的地方。
重命名符号(F2)上下文菜单选项会创建一个文本框,您可以在其中输入新名称。例如,对函数名称进行重命名将提供一个文本框,用于重命名该函数及其所有调用。按 Enter 键应用该名称(图 12-12)。

图 12-12. 重命名 log 函数的框,插入了 logData
如果您希望在应用新名称之前查看会发生什么,按 Shift + Enter 打开重构预览窗格,其中列出了将发生的所有文本更改(图 12-13)。

图 12-13. 重命名 log 函数的重构预览,包括在两个文件中预览的 logData
删除未使用的代码
许多 IDE 在视觉上微调未使用的代码的外观,例如导入的值和从未引用的变量。例如,VS Code 会将它们的不透明度降低约三分之一。
TypeScript 提供了用于删除未使用代码的代码操作。(图 12-14) 展示了请求 TypeScript 删除未使用的 import 语句的结果。

图 12-14. 左:选择未使用的导入并打开重构菜单;右:TypeScript 删除后的文件
其他快速修复
许多 TypeScript 错误消息都是可以快速纠正的代码问题,例如关键字或变量名称中的小拼写错误。其他常用的 TypeScript 快速修复包括:
-
在类或接口上声明缺失的属性
-
更正拼写错误的字段名称
-
填写声明为类型的变量的缺失属性
我建议在发现之前未见过的错误消息时,检查快速修复列表。你永远不知道 TypeScript 提供了哪些有用的工具来解决它!
重构
TypeScript 语言服务为不同结构的代码提供了大量便捷的代码更改。有些简单到移动代码行,而其他一些则复杂到为您创建新函数。
当您选择了一段代码区域时,VS Code 会在选择旁边显示一个灯泡图标。点击它以查看可用的重构列表。
这是一个开发者将内联数组文本提取到常量变量的示例(图 12-15)。

图 12-15. 左:选择数组文本并打开重构菜单;右:提取为常量变量
有效地处理错误
阅读和处理错误消息是在任何编程语言中工作的一个不可避免的事实。每个开发者,无论在 TypeScript 语言方面的熟练程度如何,每次编写 TypeScript 代码时都会触发大量的 TypeScript 编译器错误。利用 IDE 功能增强您处理 TypeScript 编译器错误的能力,将帮助您在该语言中变得更加高效。
语言服务错误
编辑器通常会在有问题的代码下方显示由 TypeScript 语言服务报告的红色波浪线。将鼠标悬停在下划线字符上将显示一个悬停框,其中包含错误的文本(详见图 12-16)。

图 12-16. 变量不存在的悬停信息
VS Code 还会在“面板”部分的“问题”选项卡中显示任何打开文件的错误。悬停框中错误的底部左侧的“查看问题”链接将打开消息的内联显示,插入在问题行之后和后续行之前(详见图 12-17)。

图 12-17. 显示变量不存在的问题的视图问题内联显示
当同一源文件中存在多个问题时,它们的显示将包括向上和向下箭头,您可以使用它们在问题列表中前进和后退。快捷键 F8 和 Shift + F8 分别用于在列表中前进和后退(详见图 12-18)。

图 12-18. 两个显示变量不存在的问题的视图问题内联显示之一
问题标签
VS Code 在其面板中包含一个“问题”选项卡,顾名思义,会显示工作区中的任何问题。这包括 TypeScript 语言服务报告的错误。
此屏幕截图显示了一个 TypeScript 文件中“问题”选项卡显示的两个问题(详见图 12-19)。

图 12-19. 问题选项卡显示文件中的两个错误
在“问题”选项卡中点击任何错误将把您的文本光标移动到文件中相应的行和列。
请注意,VS Code 仅会列出当前打开的文件中的问题。如果您希望实时更新所有 TypeScript 编译器的问题列表,您需要在终端中运行 TypeScript 编译器。
运行终端编译器
推荐在 TypeScript 项目中工作时,在终端中以观察模式运行 TypeScript 编译器(详见第十三章,“配置选项”)。这样做可以实时更新所有问题的列表,而不仅仅是文件中的问题。
在 VS Code 中执行此操作,打开终端面板并运行 tsc -w(或者如果使用项目引用,则运行 tsc -b -w,同时参见第十三章,“配置选项”)。现在你应该看到终端显示出项目中所有 TypeScript 问题,如此屏幕截图所示(图 12-20)。

图 12-20. 在终端中运行 tsc -w 报告文件中的问题
在 Mac 上 Cmd / 在 Windows 上 Ctrl + 单击文件名将使文本光标移到其文件中的错误行和列。
提示
一些项目使用 VS Code 的 launch.json 配置启动 TypeScript 编译器的观察模式。请参阅code.visualstudio.com/docs/editor/tasks了解 VS Code 任务的完整参考。
理解类型
有时你会发现需要了解某个设定方式中类型并不明显的东西的类型。对于任何值,你可以将鼠标悬停在其名称上,以查看显示其类型的悬停框。
此屏幕截图显示了一个变量的悬停框(图 12-21)。

图 12-21. 变量的悬停信息
悬停时按住 Ctrl 还可以显示名称声明的位置。
此屏幕截图显示与之前相同变量的 Ctrl 悬停框(图 12-22)。

图 12-22. 展开变量的悬停信息
悬停信息框也适用于类型,比如类型别名。此屏幕截图显示悬停在 keyof typeof 类型上以查看其等效字符串文字联合(图 12-23)。

图 12-23. 展开类型的悬停信息
我发现的一种策略在试图理解复杂类型的组成部分时非常有帮助,那就是创建一个代表类型的别名,仅表示类型的一个组件。然后你可以将鼠标悬停在该类型别名上,看看其类型结果。
例如,对于之前的 FruitsType 类型,其 typeof fruits 部分可以通过重构提取为一个单独的中介类型。然后可以悬停在该中介类型上查看类型信息(图 12-24)。

图 12-24. 左:提取 FruitsType 类型的一部分;右:悬停在提取的类型上
中介类型别名策略特别适用于调试涵盖在第十五章,“类型操作”中讨论的类型操作。
总结
在本章中,您探索了使用 TypeScript 的 IDE 集成来提升编写 TypeScript 代码的能力:
-
在类型和值上打开上下文菜单以列出其可用命令
-
通过查找定义、引用和实现来导航代码
-
使用名称补全和自动导入自动化编写代码
-
包括重命名和重构在内的更多代码操作
-
查看和理解语言服务错误的策略
-
理解类型的策略
小贴士
现在你已经完成了本章的阅读,请在https://learningtypescript.com/using-ide-features上练习所学的内容。
IDE 间相爱的告白?
“你完美地补全了我!”
第十三章:配置选项
编译器选项:
类型和模块,还有更多!
tsc你的方式。
TypeScript 高度可配置,并且设计适应所有常见的 JavaScript 使用模式。它可以用于从传统浏览器代码到最现代的服务器环境的项目。
TypeScript 的大部分可配置性来自于其多达 100 多个配置选项,可以通过以下方式之一提供:
-
传递给
tsc的命令行(CLI)标志 -
“TSConfig” TypeScript 配置文件
本章不旨在作为所有 TypeScript 配置选项的完整参考。相反,我建议将本章视为您将经常使用的最常见选项的导览。我仅包含那些对大多数 TypeScript 项目设置更有用和广泛使用的选项。有关每个选项和更多信息,请参见aka.ms/tsc。
tsc 选项
回到第一章,“从 JavaScript 到 TypeScript”,您使用tsc index.ts编译了一个index.ts文件。tsc命令可以将大多数 TypeScript 配置选项作为--标志传递。
例如,要在index.ts文件上运行tsc并跳过发出index.js文件(因此,只运行类型检查),请传递--noEmit标志:
tsc index.ts --noEmit
您可以运行tsc --help来获取常用的 CLI 标志列表。从aka.ms/tsc查看所有tsc配置选项的完整列表,使用tsc --all。
漂亮模式
tsc CLI 具有“漂亮”模式的输出能力:使用颜色和间距进行样式化,使其更易于阅读。如果检测到输出终端支持彩色文本,则默认为漂亮模式。
这里展示了一个从文件中打印两个类型错误的tsc的示例(见图 13-1)。

图 13-1. tsc 报告两个错误,包含蓝色文件名、黄色行号和列号,以及红色波浪线
如果您希望 CLI 输出更加简洁和/或不具有不同的颜色,可以显式提供--pretty false,告诉 TypeScript 使用更简洁、无颜色的格式(见图 13-2)。

图 13-2. tsc 报告两个错误的普通文本模式
观察模式
我喜欢使用tsc CLI 的一种方式是使用其-w/--watch模式。在完成后不会退出,观察模式将持续运行 TypeScript,并实时更新终端中看到的所有错误列表。
在包含两个错误的文件上运行观察模式,见图 13-3。

图 13-3. tsc 在观察模式下报告两个错误
图 13-4 展示了tsc更新控制台输出以指示文件已更改以修复所有错误。

图 13-4. tsc在监视模式下报告没有错误
当您正在进行跨多个文件的大规模更改(如重构)时,监视模式特别有用。您可以使用 TypeScript 的类型错误作为一种清单,查看仍需清理的内容。
TSConfig 文件
您可以将大多数配置选项指定在一个目录中的tsconfig.json(“TSConfig”)文件中,而不是始终提供所有文件名和配置选项给tsc。
tsconfig.json的存在表明该目录是一个 TypeScript 项目的根目录。在目录中运行tsc将读取该tsconfig.json文件中的任何配置选项。
您还可以将-p/--project传递给tsc,并指定一个包含tsconfig.json或任何文件的目录路径,以便tsc使用该文件:
tsc -p path/to/tsconfig.json
在可能的情况下,强烈建议使用 TSConfig 文件来进行 TypeScript 项目。像 VS Code 这样的 IDE 将在给您提供 IntelliSense 功能时尊重它们的配置。
查看aka.ms/tsconfig.json获取 TSConfig 文件中可用配置选项的完整列表。
注意
如果你在你的tsconfig.json中没有设置选项,不用担心 TypeScript 的默认设置可能会改变并影响到你的项目编译设置。这几乎不会发生,即使发生了,也需要对 TypeScript 进行主要版本更新,并在发布说明中明确说明。
tsc --init
命令行中的tsc命令包括一个--init命令,用于创建一个新的tsconfig.json文件。这个新创建的 TSConfig 文件将包含一个链接到配置文档以及大多数允许的 TypeScript 配置选项,每个选项都有一行简短的注释描述其用途。
运行以下命令:
tsc --init
将生成一个完全注释的tsconfig.json文件:
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
// ...
}
}
我建议在您的前几个 TypeScript 项目中使用tsc --init来创建您的配置文件。其默认值适用于大多数项目,并且其文档注释有助于理解它们。
CLI 与配置
通过tsc --init创建的 TSConfig 文件中的配置选项可能会注意到,这些选项位于一个"compilerOptions"对象内。大多数同时适用于 CLI 和 TSConfig 文件的选项属于以下两个类别之一:
编译器
TypeScript 如何编译和/或类型检查每个包含的文件
文件
哪些文件将或不将在其上运行 TypeScript
我们将在这两个类别之后讨论其他设置,比如项目引用,通常只能在 TSConfig 文件中使用。
提示
如果向 tsc CLI 提供了设置(例如为 CI 或生产构建提供的一次性更改),它通常会覆盖 TSConfig 文件中指定的任何值。因为 IDE 通常从目录中的 tsconfig.json 读取 TypeScript 设置,建议将大多数配置选项放在 tsconfig.json 文件中。
文件包含
默认情况下,tsc 将运行当前目录及其子目录中所有非隐藏的 .ts 文件(即文件名不以 . 开头的文件),忽略隐藏目录和名为 node_modules 的目录。TypeScript 配置可以修改要运行的文件列表。
include
包含文件的最常见方式是在 tsconfig.json 中的顶级 "include" 属性中。它允许一个描述要在 TypeScript 编译中包含的目录和/或文件的字符串数组。
例如,此配置文件递归包含与 tsconfig.json 相对于 src/ 目录中的所有 TypeScript 源文件:
{
"include": ["src"]
}
include 字符串中允许使用全局通配符以更精细地控制要包含的文件:
-
*匹配零个或多个字符(不包括目录分隔符)。 -
?匹配任意一个字符(不包括目录分隔符)。 -
**/匹配任意级别的任何目录。
此配置文件仅允许在 typings/ 目录中嵌套的 .d.ts 文件和在其名称前至少具有两个字符的 src/ 文件的扩展名:
{
"include": [
"typings/**/*.d.ts",
"src/**/*??.*"
]
}
对于大多数项目来说,简单的 include 编译选项,比如 ["src"],通常已经足够了。
exclude
项目的 include 文件列表有时会包括 TypeScript 不需要编译的文件。TypeScript 允许 TSConfig 文件通过在顶层 "exclude" 属性中指定来从 include 中省略路径。与 include 类似,它允许一个描述要从 TypeScript 编译中排除的目录和/或文件的字符串数组。
以下配置包含 src/ 中所有文件,但不包括任何嵌套 external/ 目录和 node_modules 目录中的文件:
{
"exclude": ["**/external", "node_modules"],
"include": ["src"]
}
默认情况下,exclude 包含 ["node_modules", "bower_components", "jspm_packages"],以避免在编译的第三方库文件上运行 TypeScript 编译器。
提示
如果您编写自己的 exclude 列表,通常不需要重新添加 "bower_components" 或 "jspm_packages"。大多数 JavaScript 项目将 node 模块安装到项目内的一个文件夹中,只安装到 "node_modules"。
请注意,exclude 仅用于从 include 的起始列表中移除文件。TypeScript 将运行任何被任何包含的文件导入的文件,即使导入的文件明确列在 exclude 中。
替代扩展名
TypeScript 默认可以读取任何扩展名为 .ts 的文件。然而,某些项目需要能够读取具有不同扩展名的文件,例如用于 UI 库(如 React)的 JSON 模块或 JSX 语法。
JSX 语法
像<Component />这样的 JSX 语法通常在 UI 库(如 Preact 和 React)中使用。JSX 语法并不是 JavaScript 的技术规范。与 TypeScript 的类型定义一样,它是 JavaScript 语法的扩展,可以编译成普通的 JavaScript:
const MyComponent = () => {
// Equivalent to:
// return React.createElement("div", null, "Hello, world!");
return <div>Hello, world!</div>;
};
要在文件中使用 JSX 语法,您必须完成以下两项操作:
-
在配置选项中启用
"jsx"编译选项 -
将文件命名为.tsx扩展名
jsx
用于"jsx"编译选项的值决定了 TypeScript 为.tsx文件生成 JavaScript 代码的方式。项目通常使用以下三个值之一(Table 13-1)。
Table 13-1. JSX 编译选项输入和输出
| Value | Input code | Output code | Output file extension |
|---|---|---|---|
| “preserve” | <div /> |
<div /> |
.jsx |
| “react” | <div /> |
React.createElement("div") |
.js |
| “react-native” | <div /> |
<div /> |
.js |
可以向tsc CLI 或 TSConfig 文件提供jsx的值。
tsc --jsx preserve
{
"compilerOptions": {
"jsx": "preserve"
}
}
如果您没有直接使用 TypeScript 的内置转换器进行转译,而是使用像 Babel 这样的单独工具进行代码转译,您很可能可以使用允许的任何"jsx"值。大多数基于现代框架(如 Next.js 或 Remix)构建的 Web 应用程序处理 React 配置和编译语法。如果您使用这些框架之一,您可能不必直接配置 TypeScript 的内置转译器。
.tsx 文件中的通用箭头函数
Chapter 10, “Generics”提到了泛型箭头函数的语法与 JSX 语法冲突。在.tsx文件中为箭头函数写入类型参数<T>会导致语法错误,因为没有为该开放T元素的标签写入结束标记:
const identity = <T>(input: T) => input;
// ~~~
// Error: JSX element 'T' has no corresponding closing tag.
要解决此语法歧义,可以在类型参数中添加一个= unknown约束。类型参数默认为unknown类型,因此这不会改变代码行为。这只是告诉 TypeScript 读取一个类型参数,而不是一个 JSX 元素:
const identity = <T = unknown>(input: T) => input; // Ok
resolveJsonModule
如果将resolveJsonModule编译选项设置为true,TypeScript 将允许读取.json文件。这样一来,.json文件就可以像导出对象的.ts文件那样进行导入。TypeScript 将推断该对象的类型,就好像它是一个const变量。
对于包含对象的 JSON 文件,可以使用解构导入。以下是一个示例,其中activist.json文件定义了一个"activist"字符串,并将其导入到usesActivist.ts文件中:
// activist.json
{
"activist": "Mary Astell"
}
// usesActivist.ts
import { activist } from "./activist.json";
// Logs: "Mary Astell"
console.log(activist);
如果启用了esModuleInterop编译选项(稍后在本章中讨论),还可以使用默认导入:
// useActivist.ts
import data from "./activist.json";
对于包含其他文字类型(如数组或数字)的 JSON 文件,需要使用* as导入语法。以下是一个示例,其中activists.json文件定义了一个字符串数组,并将其导入到useActivists.ts文件中:
// activists.json
[
"Ida B. Wells",
"Sojourner Truth",
"Tawakkul Karmān"
]
// useActivists.ts
import * as activists from "./activists.json";
// Logs: "3 activists"
console.log(`${activists.length} activists`);
Emit
尽管专用编译工具(如 Babel)的兴起已经使得 TypeScript 在某些项目中的角色仅限于类型检查,但许多其他项目仍然依赖 TypeScript 将 TypeScript 语法编译为 JavaScript。对于项目来说,能够依赖于 typescript 并使用其 tsc 命令输出等效的 JavaScript 是非常有用的。
outDir
默认情况下,TypeScript 将输出文件放置在其对应的源文件旁边。例如,在包含 fruits/apple.ts 和 vegetables/zucchini.ts 的目录上运行 tsc 将导致 fruits/apple.js 和 vegetables/zucchini.js 的输出文件:
fruits/
apple.js
apple.ts
vegetables/
zucchini.js
zucchini.ts
有时将输出文件放置在不同的文件夹中可能更可取。例如,许多 Node 项目将转换后的输出放置在 dist 或 lib 目录中。
TypeScript 的 outDir 编译选项允许指定输出的不同根目录。输出文件的相对目录结构与输入文件相同。
例如,在前述目录上运行 tsc --outDir dist 将会在 dist/ 文件夹中放置输出:
dist/
fruits/
apple.js
vegetables/
zucchini.js
fruits/
apple.ts
vegetables/
zucchini.ts
TypeScript 通过查找所有输入文件(排除 .d.ts 声明文件)的最长公共子路径来计算放置输出文件的根目录。这意味着将所有输入源文件放在单个目录中的项目将该目录视为根目录。
例如,如果上述示例将所有输入放置在 src/ 目录中,并使用 --outDir lib 进行编译,则会创建 lib/fruits/apple.js 而不是 lib/src/fruits/apple.js:
lib/
fruits/
apple.js
vegetables/
zucchini.js
src/
fruits/
apple.ts
vegetables/
zucchini.ts
TypeScript 确实有一个 rootDir 编译选项,用于显式指定根目录,但除了 . 或 src 之外,很少需要或使用其他值。
target
TypeScript 能够生成可以在 ES3(大约 1999 年)等旧环境中运行的 JavaScript 输出。大多数环境都能够支持来自较新 JavaScript 版本的语法特性。
TypeScript 包含一个 target 编译选项,用于指定需要将 JavaScript 代码转译到多老的语法支持。当未指定时,默认情况下 target 为 "es3",而 tsc --init 的默认设置为 "es2016"。通常建议根据目标平台使用尽可能新的 JavaScript 语法。在旧环境中支持新的 JavaScript 特性会导致生成更多的 JavaScript 代码,这会稍微增加文件大小并略微降低运行时性能。
提示
截至 2022 年,全球 > 0.1% 用户使用的浏览器的所有最新版本都至少支持 ECMAScript 2019,并且几乎所有版本支持 ECMAScript 2020–2021,而 Node.js 的 LTS 支持版本则支持 ECMAScript 2021 的全部特性。因此,至少将 target 设置为 "es2019" 是非常合理的。
例如,考虑这个包含 ES2015 const 和 ES2020 ?? 空值合并的 TypeScript 源码:
function defaultNameAndLog(nameMaybe: string | undefined) {
const name = nameMaybe ?? "anonymous";
console.log("From", nameMaybe, "to", name);
return name;
}
使用 tsc --target es2020 或更新版本,const 和 ?? 都是支持的语法特性,因此 TypeScript 只需从片段中移除 : string | undefined:
function defaultNameAndLog(nameMaybe) {
const name = nameMaybe ?? "anonymous";
console.log("From", nameMaybe, "to", name);
return name;
}
使用 tsc --target es2015 到 es2019,?? 语法糖将编译为较旧版本 JavaScript 中的等效语法:
function defaultNameAndLog(nameMaybe) {
const name = nameMaybe !== null && nameMaybe !== void 0
? nameMaybe
: "anonymous";
console.log("From", nameMaybe, "to", name);
return name;
}
使用 tsc --target es3 或 es5,const 还需要额外转换为其等效的 var:
function defaultNameAndLog(nameMaybe) {
var name = nameMaybe !== null && nameMaybe !== void 0
? nameMaybe
: "anonymous";
console.log("From", nameMaybe, "to", name);
return name;
}
将 target 编译器选项指定为与代码运行的最旧环境相匹配的值,将确保代码以现代、简洁的语法形式输出,仍然可以在没有语法错误的情况下运行。
发出声明
第十一章,“声明文件” 讲述了如何在包中分发 .d.ts 声明文件以向消费者指示代码类型。大多数包使用 TypeScript 的 declaration 编译器选项从源文件中发出 .d.ts 输出文件:
tsc --declaration
{
"compilerOptions": {
"declaration": true
}
}
.d.ts 输出文件遵循与 .js 文件相同的输出规则,包括遵守 outDir。
例如,在包含 fruits/apple.ts 和 vegetables/zucchini.ts 的目录上运行 tsc --declaration 将导致输出声明文件 fruits/apple.d.ts 和 vegetables/zucchini.d.ts 以及输出的 .js 文件:
fruits/
apple.d.ts
apple.js
apple.ts
vegetables/
zucchini.d.ts
zucchini.js
zucchini.ts
emitDeclarationOnly
存在一个 emitDeclarationOnly 编译器选项,作为 declaration 编译器选项的专门补充,指示 TypeScript 只发出声明文件:完全没有 .js/.jsx 文件输出。这对于使用外部工具生成输出 JavaScript 但仍希望使用 TypeScript 生成输出定义文件的项目非常有用:
tsc --emitDeclarationOnly
{
"compilerOptions": {
"emitDeclarationOnly": true
}
}
如果启用了 emitDeclarationOnly,则必须启用 declaration 或本章后面介绍的 composite 编译器选项之一。
例如,在包含 fruits/apple.ts 和 vegetables/zucchini.ts 的目录上运行 tsc --declaration --emitDeclarationOnly 将导致输出声明文件 fruits/apple.d.ts 和 vegetables/zucchini.d.ts,而没有任何 .js 文件输出:
fruits/
apple.d.ts
apple.ts
vegetables/
zucchini.d.ts
zucchini.ts
源映射
源映射描述了输出文件的内容如何与原始源文件匹配。它们允许开发者工具(如调试器)在浏览输出文件时显示原始源代码。对于视觉调试器特别有用,比如浏览器开发者工具和集成开发环境(IDE),以便在调试时查看原始源文件内容。TypeScript 包括在输出文件旁边输出源映射的能力。
sourceMap
TypeScript 的 sourceMap 编译器选项使得可以在 .js 或 .jsx 输出文件旁边输出 .js.map 或 .jsx.map 源映射文件。否则,源映射文件将与相应的输出 JavaScript 文件同名并放置在相同目录中。
例如,在包含 fruits/apple.ts 和 vegetables/zucchini.ts 的目录上运行 tsc --sourceMap 将生成输出源映射文件 fruits/apple.js.map 和 vegetables/zucchini.js.map,以及输出的 .js 文件:
fruits/
apple.js
apple.js.map
apple.ts
vegetables/
zucchini.js
zucchini.js.map
zucchini.ts
declarationMap
TypeScript 还能够为 .d.ts 声明文件生成源映射。其 declarationMap 编译选项指示为每个 .d.ts 生成一个 .d.ts.map 源映射文件,该文件将原始源文件映射回去。声明映射使得诸如 VS Code 这样的 IDE 在使用编辑器功能如跳转到定义时能够到达原始源文件。
提示
当与项目引用一起使用时,declarationMap 特别有用。
例如,在包含 fruits/apple.ts 和 vegetables/zucchini.ts 的目录上运行 tsc --declaration --declarationMap 将生成输出声明源映射文件 fruits/apple.d.ts.map 和 vegetables/zucchini.d.ts.map,以及输出的 .d.ts 和 .js 文件:
fruits/
apple.d.ts
apple.d.ts.map
apple.js
apple.ts
vegetables/
zucchini.d.ts
zucchini.d.ts.map
zucchini.js
zucchini.ts
noEmit
对于完全依赖其他工具将源文件编译为输出 JavaScript 的项目,可以告诉 TypeScript 完全跳过生成文件。启用 noEmit 编译选项将使 TypeScript 纯粹作为类型检查器。
在任何先前示例上运行 tsc --noEmit 将导致不生成新文件。TypeScript 仅报告找到的任何语法或类型错误。
类型检查
大多数 TypeScript 的配置选项控制其类型检查器。您可以配置它以温和和宽容的方式工作,只有在完全确定错误时才会发出类型检查投诉,或者以严格和严厉的方式要求几乎所有代码都要有良好的类型。
lib
首先,TypeScript 假设在运行时环境中存在的全局 API 可以通过 lib 编译选项进行配置。它接受一个字符串数组,默认为您的 target 编译选项,以及 dom 表示包括浏览器类型。
大多数情况下,自定义 lib 的唯一理由是为了删除不在浏览器中运行的项目中的 dom 包含:
tsc --lib es2020
{
"compilerOptions": {
"lib": ["es2020"]
}
}
或者,对于使用 polyfill 支持更新的 JavaScript API 的项目,lib 可以包括 dom 和任何 ECMAScript 版本:
tsc --lib dom,es2021
{
"compilerOptions": {
"lib": ["dom", "es2021"]
}
}
谨慎修改 lib,如果没有提供所有正确的运行时 polyfill,可能会导致问题。例如,项目中将 lib 设置为 "es2021",但在只支持到 ES2020 的平台上运行,可能不会出现类型检查错误,但尝试使用 ES2021 或更新定义的 API(如 String.replaceAll)时可能会出现运行时错误:
const value = "a b c";
value.replaceAll(" ", ", ");
// Uncaught TypeError: value.replaceAll is not a function
提示
将 lib 编译选项视为指示可用的内置语言 API,而 target 编译选项则指示存在的语法特性。
skipLibCheck
TypeScript 提供了一个skipLibCheck编译选项,指示在未显式包含在您的源代码中的声明文件中跳过类型检查。这对依赖许多可能依赖不同、冲突的共享库定义的应用程序非常有用:
tsc --skipLibCheck
{
"compilerOptions": {
"skipLibCheck": true
}
}
skipLibCheck通过允许跳过一些类型检查来加速 TypeScript 性能。因此,通常建议在大多数项目中启用它。
严格模式
TypeScript 的大多数类型检查编译选项都被分组到所谓的严格模式中。每个严格性编译选项默认为false,启用时,指示类型检查器打开一些额外的检查。
我将在本章后面按字母顺序介绍最常用的严格选项。从这些选项中,noImplicitAny和strictNullChecks在强制执行类型安全代码方面特别有用和有影响。
您可以通过启用strict编译选项来启用所有严格模式检查:
tsc --strict
{
"compilerOptions": {
"strict": true
}
}
如果您想启用除了某些检查外的所有严格模式检查,可以同时启用strict并显式禁用某些检查。例如,此配置启用了除了noImplicitAny之外的所有严格模式:
tsc --strict --noImplicitAny false
{
"compilerOptions": {
"noImplicitAny": false,
"strict": true
}
}
警告
未来的 TypeScript 版本可能在strict下引入新的严格类型检查编译选项。因此,在更新 TypeScript 版本时,使用strict可能会导致新的类型检查投诉。您始终可以在您的 TSConfig 中选择退出特定设置。
noImplicitAny
如果 TypeScript 无法推断参数或属性的类型,那么它将回退到假定的any类型。通常最佳实践是不允许这些隐式的any类型存在于代码中,因为any类型允许绕过 TypeScript 的大部分类型检查。
noImplicitAny编译选项指示 TypeScript 在必须回退到隐式any时发出类型检查投诉。
例如,不带类型声明编写以下函数参数将在noImplicitAny下引发类型错误:
const logMessage = (message) => {
// ~~~~~~~
// Error: Parameter 'message' implicitly has an 'any' type.
console.log(`Message: ${message}!`);
};
大多数时候,可以通过在投诉位置添加类型注释来解决noImplicitAny投诉:
const logMessage = (message: string) => { // Ok
console.log(`Message: ${message}!`);
}
或者,在函数参数的情况下,将父函数放在指示函数类型的位置:
type LogsMessage = (message: string) => void;
const logMessage: LogsMessage = (message) => { // Ok
console.log(`Message: ${message}!`);
}
提示
noImplicitAny是确保项目中类型安全的一个很好的标志。我强烈建议在完全使用 TypeScript 编写的项目中努力将其打开。然而,如果项目仍在从 JavaScript 过渡到 TypeScript 阶段,可能更容易先完成所有文件的转换到 TypeScript。
strictBindCallApply
当 TypeScript 首次发布时,它没有足够丰富的类型系统功能来表示内置的Function.apply、Function.bind或Function.call函数实用程序。那些函数默认情况下必须接受any作为其参数列表。这不太类型安全!
例如,在没有strictBindCallApply的情况下,以下getLength变体的所有类型都包含any:
function getLength(text: string, trim?: boolean) {
return trim ? text.trim().length : text.length;
}
// Function type: (thisArg: Function, argArray?: any) => any
getLength.apply;
// Returned type: any
getLength.bind(undefined, "abc123");
// Returned type: any
getLength.call(undefined, "abc123", true);
现在,TypeScript 的类型系统功能足以表示那些函数的泛型剩余参数,TypeScript 允许选择使用更严格的类型来进行函数。
启用strictBindCallApply可以为getLength变体提供更精确的类型:
function getLength(text: string, trim?: boolean) {
return trim ? text.trim().length : text;
}
// Function type:
// (thisArg: typeof getLength, args: [text: string, trim?: boolean]) => number;
getLength.apply;
// Returned type: (trim?: boolean) => number
getLength.bind(undefined, "abc123");
// Returned type: number
getLength.call(undefined, "abc123", true);
TypeScript 的最佳实践是启用strictBindCallApply。它改进了内置函数工具的类型检查,有助于提高项目中使用它们的类型安全性。
strictFunctionTypes
strictFunctionTypes编译器选项导致函数参数类型检查更严格。如果函数类型的参数是该类型参数的子类型,则不再被认为可分配给另一个函数类型。
作为一个具体例子,在这里checkOnNumber函数接收一个应该能接收number | string的函数,但提供了一个期望只接收string类型参数的stringContainsA函数。 TypeScript 的默认类型检查会允许这种情况发生,并且程序将因尝试在number上调用.match()而崩溃:
function checkOnNumber(containsA: (input: number | string) => boolean) {
return containsA(1337);
}
function stringContainsA(input: string) {
return !!input.match(/a/i);
}
checkOnNumber(stringContainsA);
在strictFunctionTypes下,checkOnNumber(stringContainsA)将导致类型检查错误:
// Argument of type '(input: string) => boolean' is not assignable
// to parameter of type '(input: string | number) => boolean'.
// Types of parameters 'input' and 'input' are incompatible.
// Type 'string | number' is not assignable to type 'string'.
// Type 'number' is not assignable to type 'string'.
checkOnNumber(stringContainsA);
注意
在技术术语上,函数参数从bivariant变为contravariant。您可以在TypeScript 2.6 发布说明中了解更多关于这两者差异的信息。
strictNullChecks
回顾一下第三章,“联合类型和字面量”,我讨论了语言的十亿美元错误:允许空类型(如null和undefined)可分配给非空类型。禁用 TypeScript 的strictNullChecks标志大致会将null | undefined添加到代码中的每种类型中,从而允许任何变量接收null或undefined。
此代码片段只有在启用strictNullChecks时,将null赋给string类型的值才会引发类型错误:
let value: string;
value = "abc123"; // Always ok
value = null;
// With strictNullChecks enabled:
// Error: Type 'null' is not assignable to type 'string'.
TypeScript 的最佳实践是启用strictNullChecks。这样做有助于防止崩溃并消除十亿美元的错误。
有关更多详细信息,请参阅第三章,“联合类型和字面量”。
strictPropertyInitialization
回顾一下第八章,“类”,我讨论了类中的严格初始化检查:确保类上的每个属性在类构造函数中确实被分配。 TypeScript 的strictPropertyInitialization标志会导致对类属性发出类型错误,这些属性没有初始化程序并且在构造函数中也没有明确分配。
TypeScript 的最佳实践通常是启用strictPropertyInitialization。这样做有助于防止由于类初始化逻辑中的错误而导致的崩溃。
有关更多详细信息,请参阅第八章,“类”。
useUnknownInCatchVariables
任何语言中的错误处理都是一个本质上不安全的概念。理论上,任何函数都可以抛出许多错误,例如在undefined上读取属性或用户编写的throw语句。事实上,并没有保证抛出的错误是Error类的实例:代码总是可以throw "something-else"。
因此,TypeScript 对于错误的默认行为是将它们视为any类型,因为它们可能是任何类型。这允许在错误处理时灵活处理,但以默认情况下依赖不太类型安全的any。
由于 TypeScript 无法知道someExternalFunction()抛出的所有可能错误,因此以下片段的error被标记为any:
try {
someExternalFunction();
} catch (error) {
error; // Default type: any
}
与大多数any用法一样,将错误视为unknown类型在技术上更加合理,但往往需要明确的类型断言或缩小范围。捕获子句错误允许注释为any或unknown类型。
此片段修正了将error显式改为: unknown以将其切换为unknown类型:
try {
someExternalFunction();
} catch (error: unknown) {
error; // Type: unknown
}
严格的区域标志useUnknownInCatchVariables将 TypeScript 的默认捕获子句错误类型更改为unknown。启用useUnknownInCatchVariables后,两个片段的类型都将设置为unknown,并且会显示error。
TypeScript 的最佳实践通常是启用useUnknownInCatchVariables,因为不能总是安全地假设错误将是特定类型。
模块
JavaScript 的各种导出和导入模块内容的系统 —— AMD、CommonJS、ECMAScript 等 —— 是任何现代编程语言中最复杂的模块系统之一。JavaScript 相对不寻常的是文件之间如何导入对方内容通常由用户编写的框架(如 Webpack)驱动。TypeScript 尽最大努力提供配置选项,以表示大多数合理的用户模块配置。
大多数新的 TypeScript 项目都使用标准化的 ECMAScript 模块语法进行编写。总结一下,这里是 ECMAScript 模块如何从另一个模块("my-example-lib")导入值(value)并导出它们自己的值(logValue):
import { value } from "my-example-lib";
export const logValue = () => console.log(value);
模块
TypeScript 提供了一个module编译器选项来指定转译后的代码将使用哪个模块系统。当使用 ECMAScript 模块编写源代码时,TypeScript 可能会将export和import语句转译为基于module值的不同模块系统。
例如,命令行中指定 ECMAScript 编写的项目输出为 CommonJS 模块:
tsc --module commonjs
或在 TSConfig 中:
{
"compilerOptions": {
"module": "commonjs"
}
}
前面的代码片段大致输出为:
const my_example_lib = require("my-example-lib");
exports.logValue = () => console.log(my_example_lib.value);
如果您的target编译器选项是"es3"或"es5",则module的默认值将为"commonjs"。否则,module将默认为"es2015",以指定输出 ECMAScript 模块。
模块解析
模块解析 是指导入语句中导入路径如何映射到模块的过程。TypeScript 提供了一个 moduleResolution 选项,您可以使用它来指定这一过程的逻辑。通常情况下,您会希望为它提供两种逻辑策略之一:
-
node: 类似传统 Node.js 的 CommonJS 解析器所使用的行为 -
nodenext: 与 ECMAScript 模块指定的行为对齐
这两种策略相似。大多数项目可以使用其中任何一种而不会注意到差异。您可以在https://www.typescriptlang.org/docs/handbook/module-resolution.html上详细了解模块解析背后的复杂性。
注意
moduleResolution 完全不影响 TypeScript 如何生成代码。它仅用于描述代码运行时所需的环境。
下面的 CLI 片段和 JSON 文件片段都可以用来指定 moduleResolution 编译器选项:
tsc --moduleResolution nodenext
{
"compilerOptions": {
"moduleResolution": "nodenext"
}
}
提示
由于向后兼容的原因,TypeScript 保持了默认的 moduleResolution 值为一个 classic 值,这个值在多年前的项目中使用过。在任何现代项目中,几乎肯定不希望使用 classic 策略。
与 CommonJS 的互操作性
在处理 JavaScript 模块时,“默认”导出和“命名空间”输出之间存在差异。模块的 默认 导出是其导出对象的 .default 属性。模块的 命名空间 导出是导出对象本身。
表格 13-2 总结了默认和命名空间导出和导入之间的差异。
表格 13-2. CommonJS 和 ECMAScript 模块的导出和导入形式
| 语法区域 | CommonJS | ECMAScript 模块 |
|---|---|---|
| 默认导出 | module.exports.default = value; |
export default value; |
| 默认导入 | const { default: value } = require("..."); |
import value from "..."; |
| 命名空间导出 | module.exports = value; |
不支持 |
| 命名空间导入 | const value = require("..."); |
import * as value from "..."; |
TypeScript 的类型系统根据 ECMAScript 模块的文件导入和导出构建其理解。然而,如果您的项目依赖于大多数情况下都会依赖于 npm 包,很可能其中一些依赖项仍以 CommonJS 模块的形式发布。此外,尽管一些符合 ECMAScript 模块规则的包避免包含默认导出,但许多开发人员更喜欢更简洁的默认样式导入而不是命名空间样式导入。TypeScript 包括一些编译器选项,可以改善模块格式之间的互操作性。
esModuleInterop
当module不是诸如"es2015"或"esnext"之类的 ECMAScript 模块格式时,esModuleInterop配置选项会在 TypeScript 生成的 JavaScript 代码中添加一小部分逻辑。该逻辑允许 ECMAScript 模块从模块导入,即使它们未必遵循 ECMAScript 模块的默认或命名空间导入规则。
启用esModuleInterop的一个常见原因是对于诸如"react"等不提供默认导出的包。如果模块尝试从"react"包中使用默认样式导入,则在未启用esModuleInterop的情况下,TypeScript 将报告类型错误:
import React from "react";
// ~~~~~
// Module '"file:///node_modules/@types/react/index"' can
// only be default-imported using the 'esModuleInterop' flag.
注意,esModuleInterop仅直接更改与导入关系的生成 JavaScript 代码的方式。以下allowSyntheticDefaultImports配置选项告知类型系统有关导入互操作性的信息。
允许allowSyntheticDefaultImports
allowSyntheticDefaultImports编译器选项告知类型系统,ECMAScript 模块可以从其他不兼容的 CommonJS 命名空间导出文件中默认导入。
仅当以下条件之一为真时,默认为true:
-
module为"system"(一种较旧且很少使用的模块格式,在本书中未涵盖)。 -
当
esModuleInterop为true且module不是诸如"es2015"或"esnext"之类的 ECMAScript 模块格式时。
换句话说,如果esModuleInterop为true但module为"esnext",TypeScript 将假定输出的编译 JavaScript 代码未使用导入互操作性助手。对于从 "react" 等包中的默认导入,它将报告类型错误:
import React from "react";
// Module '"file:///node_modules/@types/react/index"' can only be
// default-imported using the 'allowSyntheticDefaultImports' flag`.
isolatedModules
诸如 Babel 等外部转换器一次只能处理一个文件,无法使用类型系统信息来生成 JavaScript。因此,依赖类型信息来生成 JavaScript 的 TypeScript 语法特性通常不受这些转换器的支持。启用isolatedModules编译器选项告诉 TypeScript 在可能导致这些转换器出现问题的语法实例上报告错误:
-
常量枚举,在第 14 章,“语法扩展”中涵盖
-
脚本(非模块)文件
-
独立类型导出,在第 14 章,“语法扩展”中涵盖
如果您的项目使用 TypeScript 以外的工具来转译为 JavaScript,则通常建议启用isolatedModules。
JavaScript
虽然 TypeScript 很可爱,我希望您总是想要用它来编写代码,但您不必将所有源文件都写成 TypeScript。尽管 TypeScript 默认会忽略带有 .js 或 .jsx 扩展名的文件,使用allowJs和/或checkJs编译器选项将允许它从这些文件中读取、编译,甚至在有限的情况下进行类型检查。
提示
将现有的 JavaScript 项目转换为 TypeScript 的常见策略是最初只转换少量文件为 TypeScript。随着时间的推移,可以逐步添加更多文件,直到没有剩余的 JavaScript 文件为止。在你准备好时,你不必完全依赖于 TypeScript!
allowJs
allowJs编译选项允许在类型检查 TypeScript 文件时考虑在 JavaScript 文件中声明的结构。与jsx编译选项结合使用时,也允许.jsx文件。
例如,看这个index.ts导入values.js文件中声明的value:
// index.ts
import { value } from "./values";
console.log(`Quote: '${value.toUpperCase()}'`);
// values.js
export const value = "We cannot succeed when half of us are held back.";
如果未启用allowJs,import语句将不会具有已知类型。默认情况下,它将隐式为any,或触发类似“找不到模块"./values"的声明文件”的类型错误。
allowJs还会将 JavaScript 文件添加到编译为 ECMAScript 目标并生成 JavaScript 的文件列表中。如果启用了相应选项,则还会生成源映射和声明文件:
tsc --allowJs
{
"compilerOptions": {
"allowJs": true
}
}
当启用allowJs选项时,导入的value将被视为string类型。不会报告任何类型错误。
checkJs
TypeScript 不仅可以将 JavaScript 文件引入到类型检查 TypeScript 文件中,还可以对 JavaScript 文件进行类型检查。checkJs编译选项有两个用途:
-
如果尚未设置,则将
allowJs默认为true -
在.js和.jsx文件上启用类型检查器
启用checkJs将使 TypeScript 将 JavaScript 文件视为没有任何特定于 TypeScript 的语法的 TypeScript 文件。类型不匹配、变量名拼写错误等问题都将像在 TypeScript 文件中一样导致类型错误:
tsc --checkJs
{
"compilerOptions": {
"checkJs": true
}
}
当启用checkJs时,这个 JavaScript 文件将因为变量名错误而导致类型检查投诉:
// index.js
let myQuote = "Each person must live their life as a model for others.";
console.log(quote);
// ~~~~~
// Error: Cannot find name 'quote'. Did you mean 'myQuote'?
如果未启用checkJs,TypeScript 将不会报告该可能的错误类型。
@ts-check
或者,可以通过在文件顶部包含// @ts-check注释来逐个文件启用checkJs。这样做仅为该 JavaScript 文件启用checkJs选项:
// index.js
// @ts-check
let myQuote = "Each person must live their life as a model for others.";
console.log(quote);
// ~~~~~~~
// Error: Cannot find name 'quote'. Did you mean 'myQuote'?
JSDoc 支持
因为 JavaScript 没有 TypeScript 丰富的类型语法,因此在 JavaScript 文件中声明的值的类型通常不像在 TypeScript 文件中声明的那样精确。例如,虽然 TypeScript 可以推断在 JavaScript 文件中声明为变量的对象的值,但在该文件中没有本地的 JavaScript 方法来声明该值符合任何特定接口。
我在第一章,“从 JavaScript 到 TypeScript”中提到过,JSDoc 社区标准提供了一些使用注释描述类型的方法。当启用allowJs和/或checkJs时,TypeScript 将识别代码中的任何 JSDoc 定义。
例如,此片段在 JSDoc 中声明 sentenceCase 函数接受一个 string。TypeScript 可以推断出它返回一个 string。启用 checkJs 后,TypeScript 将知道后续传递 string[] 时报告类型错误:
// index.js
/**
* @param {string} text
*/
function sentenceCase(text) {
return `${text[0].toUpperCase()} ${text.slice(1)}.`;
}
sentenceCase("hello world");// Ok
sentenceCase(["hello", "world"]);
// ~~~~~~~~~~~~~~~~~~
// Error: Argument of type 'string[]' is not
// assignable to parameter of type 'string'.
TypeScript 的 JSDoc 支持对于那些没有时间或者开发者不熟悉转换为 TypeScript 的项目逐步添加类型检查非常有用。
提示
支持的完整 JSDoc 语法列表可在 https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html 上找到。
配置扩展
随着编写越来越多的 TypeScript 项目,你可能会发现自己重复编写相同的项目设置。虽然 TypeScript 不允许使用 JavaScript 编写配置文件并使用 import 或 require,但它提供了一种机制,即 TSConfig 文件可以选择“扩展”或从另一个配置文件复制配置值。
extends
TSConfig 可以通过 extends 配置选项从另一个 TSConfig 扩展。extends 接受另一个 TSConfig 文件的路径,并指示应复制该文件中的所有设置。它的行为类似于类上的 extends 关键字:派生配置上声明的任何选项将覆盖基础配置上相同名称的任何选项。
例如,许多包含多个 TSConfig 的存储库(例如包含多个 packages/* 目录的单体存储库),按照惯例创建了一个 tsconfig.base.json 文件,供 tsconfig.json 文件扩展:
// tsconfig.base.json
{
"compilerOptions": {
"strict": true
}
}
// packages/core/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"includes": ["src"]
}
注意,编译器选项会递归地计算。每个基础 TSConfig 中的编译器选项将复制到派生 TSConfig 中,除非派生 TSConfig 覆盖了该特定选项。
如果前面的示例要添加一个 TSConfig,添加 allowJs 选项,那么新的派生 TSConfig 仍将具有设置 compilerOptions.strict 为 true:
// packages/js/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true
},
"includes": ["src"]
}
扩展模块
extends 属性可以指向任一种 JavaScript 导入:
绝对路径
以 @ 或字母开头
相对路径
以 . 开头的本地文件路径
当 extends 值为绝对路径时,表示从 npm 模块扩展 TSConfig。TypeScript 将使用正常的 Node 模块解析系统来查找匹配名称的包。如果该包的 package.json 包含一个包含相对路径字符串的 "tsconfig" 字段,则将使用该路径中的 TSConfig 文件。否则,将使用该包的 tsconfig.json 文件。
许多组织使用 npm 包来规范在代码库或者单体存储库中跨仓库使用的 TypeScript 编译器选项。以下是在 @my-org 组织中为单体存储库设置的 TSConfig 文件。packages/js 需要指定 allowJs 编译器选项,而 packages/ts 不需要更改任何编译器选项。
// packages/tsconfig.json
{
"compilerOptions": {
"strict": true
}
}
// packages/js/tsconfig.json
{
"extends": "@my-org/tsconfig",
"compilerOptions": {
"allowJs": true
},
"includes": ["src"]
}
// packages/ts/tsconfig.json
{
"extends": "@my-org/tsconfig",
"includes": ["src"]
}
配置基础
您可以从头开始创建自己的配置,也可以从预制的针对特定运行时环境定制的“基础”TSConfig 文件或--init建议开始。这些预制的配置基础可在 npm 包注册表中的@tsconfig/下找到,例如@tsconfig/recommended或@tsconfig/node16。
例如,要安装deno的推荐 TSConfig 基础:
npm install --save-dev @tsconfig/deno
# or
yarn add --dev @tsconfig/deno
安装配置基础包后,可以像引用任何其他 npm 包配置扩展一样引用它:
{
"extends": "@tsconfig/deno/tsconfig.json"
}
TSConfig 基础的完整列表在https://github.com/tsconfig/bases上有文档记录。
提示
通常最好知道您的文件正在使用哪些 TypeScript 配置选项,即使您自己不在更改它们。
项目引用
到目前为止,我展示的每个 TypeScript 配置文件都假定它们管理项目的所有源文件。在较大的项目中,使用不同的配置文件管理项目的不同区域可能很有用。TypeScript 允许定义一个“项目引用”系统,其中多个项目可以一起构建。设置项目引用比使用单个 TSConfig 文件要复杂一些,但带来了几个关键好处:
-
您可以为某些代码区域指定不同的编译器选项。
-
TypeScript 将能够为各个项目缓存构建输出,通常会显著加快大型项目的构建时间。
-
项目引用强制执行“依赖树”(仅允许某些项目从某些其他项目导入文件),这有助于结构化代码的离散区域。
提示
项目引用通常用于具有多个不同代码区域的较大项目,例如 monorepos 和模块化组件系统。您可能不希望在没有几十个或更多文件的小型项目中使用它们。
以下三个部分展示了如何构建项目设置以启用项目引用:
-
在 TSConfig 上的
composite模式强制它以适合多 TSConfig 构建模式的方式工作。 -
TSConfig 中的
references指示它依赖于哪些复合 TSConfigs。 -
构建模式使用复合 TSConfig 引用来编排构建它们的文件。
composite
TypeScript 允许项目选择composite配置选项,以指示其文件系统的输入和输出遵守约束,使构建工具更容易确定其构建输出是否与构建输入保持最新。当composite为true时:
-
如果尚未明确设置
rootDir设置,则默认为包含 TSConfig 文件的目录。 -
所有实现文件必须与包含模式匹配或列在
files数组中。 -
必须打开
declaration。
此配置片段匹配在core/目录中启用composite模式的所有条件:
// core/tsconfig.json
{
"compilerOptions": {
"declaration": true
},
"composite": true
}
这些更改有助于 TypeScript 强制所有项目输入文件创建匹配的 .d.ts 文件。 composite 通常与以下 references 配置选项结合使用。
引用
TypeScript 项目可以通过其 TSConfig 中的 references 设置指示它依赖于由复合 TypeScript 项目生成的输出。从引用项目导入模块将在类型系统中视为从其输出的 .d.ts 声明文件(s) 导入。
此配置片段设置一个 shell/ 目录以引用一个 core/ 目录作为其输入:
// shell/tsconfig.json
{
"references": [
{ "path": "../core" }
]
}
注意
references 配置选项不会通过 extends 从基本 TSConfigs 复制到派生 TSConfigs。
references 通常与以下构建模式结合使用,非常有用。
构建模式
一旦某个代码区域设置为使用项目引用,就可以通过 -b/--b CLI 标志在其备用的“构建”模式中使用 tsc。构建模式将 tsc 提升为项目构建协调器。它允许 tsc 仅重新构建自上次构建以来发生更改的项目,根据它们的内容和文件输出的生成时间。
更准确地说,给定 TSConfig 时,TypeScript 的构建模式将执行以下操作:
-
找到该 TSConfig 的引用项目。
-
检测它们是否最新。
-
按正确顺序构建过时的项目。
-
如果 TSConfig 或其任何依赖项已更改,则构建提供的 TSConfig。
TypeScript 的构建模式跳过重建最新项目的能力可以显著提高构建性能。
协调员配置
在仓库中设置 TypeScript 项目引用的常见便捷模式是在根级 tsconfig.json 中设置一个空的 files 数组,并引用仓库中所有项目的引用。该根 TSConfig 不会指导 TypeScript 自身构建任何文件。相反,它纯粹告诉 TypeScript 根据需要构建引用的项目。
此 tsconfig.json 指示在仓库中构建 packages/core 和 packages/shell 项目:
// tsconfig.json
{
"files": [],
"references": [
{ "path": "./packages/core" },
{ "path": "./packages/shell" }
]
}
我个人喜欢在 package.json 中标准化地添加一个名为 build 或 compile 的脚本,以调用 tsc -b 作为快捷方式:
// package.json
{
"scripts": {
"build": "tsc -b"
}
}
构建模式选项
构建模式支持几个构建特定的 CLI 选项:
-
--clean: 删除指定项目的输出(可与--dry结合使用) -
--dry: 显示将要执行的操作,但实际上不构建任何东西 -
--force: 表示所有项目都已过时 -
-w/--watch: 类似于典型的 TypeScript 监视模式
因为构建模式支持监视模式,运行像 tsc -b -w 这样的命令可以快速获取大型项目中所有编译器错误的最新列表。
概要
在本章中,您已经了解了 TypeScript 提供的许多重要配置选项:
-
使用
tsc,包括其 pretty 和 watch 模式 -
使用 TSConfig 文件,包括使用
tsc --init创建一个 TSConfig 文件。 -
更改 TypeScript 编译器将包含的文件
-
允许在.tsx文件中使用 JSX 语法和/或在.json文件中使用 JSON 语法
-
使用
files更改目录、ECMAScript 版本目标、声明文件和/或源映射输出 -
更改编译中使用的内置库类型
-
严格模式和有用的严格标志,如
noImplicitAny和strictNullChecks -
支持不同的模块系统和更改模块解析方式
-
允许包含 JavaScript 文件,并选择对这些文件进行类型检查
-
使用
extends来在文件之间共享配置选项 -
使用项目引用和构建模式来编排多个 TSConfig 构建
提示
现在您已经完成阅读本章,可以在https://learningtypescript.com/configuration-options上练习所学内容。
一个纪律者最喜欢的 TypeScript 编译器选项是什么?
strict。
第四部分:额外学分
JavaScript 已经存在了几十年,人们对它做了很多奇怪的事情。TypeScript 的语法和类型系统需要能够表示所有这些奇怪的东西,以便任何 JavaScript 开发者都能使用 TypeScript。因此,TypeScript 语言中有一些在大多数日常代码中不常见但对于某些项目来说却是相关甚至必要的角落。
我把这些语言的部分称为“额外学分”,因为你完全可以避开它们,仍然能成为一个高效的 TypeScript 开发者。事实上,对于在本节末尾介绍的逻辑类型,我希望你根本不需要经常使用,如果可以的话甚至不需要使用。
第十四章:语法扩展
“TypeScript 不会添加
到 JavaScript 运行时的影响。”
…那一切都是谎言吗?!
当 TypeScript 在 2012 年首次发布时,Web 应用程序的复杂性增长速度远远快于普通 JavaScript 添加支持深层复杂性的功能。当时最流行的 JavaScript 语言变体,CoffeeScript,通过引入新颖的语法结构,与 JavaScript 趋向分歧。
如今,通过引入针对 TypeScript 等超集语言的新运行时特性扩展 JavaScript 语法被认为是不良实践,原因有几个:
-
最重要的是,运行时语法扩展可能与较新版本的 JavaScript 中的新语法冲突。
-
它们使得对于新接触该语言的程序员来说更难理解 JavaScript 的结束和其他语言的开始。
-
它们增加了将超集语言代码转译为 JavaScript 的转译器的复杂性。
因此,我怀着沉重的心情和深深的遗憾告诉你,早期的 TypeScript 设计者在 TypeScript 语言中引入了三个 JavaScript 语法扩展:
-
类,在规范确定的情况下与 JavaScript 类对齐
-
枚举,类似于键和值的普通对象的简单语法糖
-
命名空间,一种在现代模块之前结构化和排列代码的解决方案
注意
TypeScript 在 JavaScript 的运行时语法扩展“原罪”在 TypeScript 早期并不是一种设计决策。TypeScript 不会在这些新的运行时语法构造通过严格的审议过程被添加到 JavaScript 本身之前引入它们。
TypeScript 类最终看起来和行为几乎与 JavaScript 类相同(哦!),除了 useDefineForClassFields 行为(本书未涵盖的配置选项)和参数属性(在此涵盖)。由于它们偶尔有用,枚举仍然在某些项目中使用。几乎没有新项目再使用命名空间。
TypeScript 也采纳了 JavaScript “装饰器”的实验性提案,我也会涵盖这个内容。
类参数属性
提示
我建议避免使用类参数属性,除非你在一个大量使用类或从中受益的框架的项目中工作。
在 JavaScript 类中,通常希望在构造函数中接收一个参数并立即将其分配给类属性。
这个 Engineer 类接受一个 area 参数,类型为 string,并将其赋给类型为 string 的 area 属性:
class Engineer {
readonly area: string;
constructor(area: string) {
this.area = area;
console.log(`I work in the ${area} area.`);
}
}
// Type: string
new Engineer("mechanical").area;
TypeScript 包括一种用于声明这些“参数属性”的快捷语法:在类构造函数的开头为同一类型的成员属性分配属性。在构造函数参数前面放置readonly和/或其中一个隐私修饰符——public、protected或private,告诉 TypeScript 还要声明一个同名和类型的属性。
前述的Engineer示例可以使用参数属性在 TypeScript 中重写area:
class Engineer {
constructor(readonly area: string) {
console.log(`I work in the ${area} area.`);
}
}
// Type: string
new Engineer("mechanical").area;
参数属性在类构造函数的最开始(或在继承自基类的情况下super()调用之后)被分配。它们可以与类的其他参数和/或属性混合使用。
下面的NamedEngineer类声明了一个常规属性fullName,一个常规参数name和一个参数属性area:
class NamedEngineer {
fullName: string;
constructor(
name: string,
public area: string,
) {
this.fullName = `${name}, ${area} engineer`;
}
}
没有参数属性的等效 TypeScript 看起来类似,但需要更多代码行来显式赋值area:
class NamedEngineer {
fullName: string;
area: string;
constructor(
name: string,
area: string,
) {
this.area = area;
this.fullName = `${name}, ${area} engineer`;
}
}
参数属性在 TypeScript 社区中有时是一个争议性问题。大多数项目倾向于彻底避免它们,因为它们是运行时语法扩展,因此遭受我之前提到的相同缺点的影响。它们也无法与新的#类私有字段语法一起使用。
另一方面,在那些非常偏爱创建类的项目中,它们的使用效果非常好。参数属性解决了一个便利性问题,即需要两次声明参数属性名称和类型,这是 TypeScript 固有的,而不是 JavaScript。
实验性装饰器
小贴士
我建议尽可能避免装饰器,直到 ECMAScript 的一个版本通过装饰器语法。如果您正在使用像 Angular 或 NestJS 这样的框架版本,该框架的文档将指导如何使用它们。
许多其他允许类注释或装饰这些类及其成员的语言,使用某种运行时逻辑修改它们。装饰器函数是 JavaScript 允许通过首先放置@和函数名称来注释类和成员的建议。
例如,以下代码段展示了如何在类MyClass上使用装饰器的语法:
@myDecorator
class MyClass { /* ... */ }
ECMAScript 尚未正式通过装饰器,因此截至版本 4.7.2,TypeScript 默认不支持它们。但是,TypeScript 包含一个experimentalDecorators编译选项,允许在代码中使用旧的实验版本。可以通过tsc命令行或 TSConfig 文件启用,如下所示,与其他编译选项一样:
{
"compilerOptions": {
"experimentalDecorators": true
}
}
每次使用装饰器时,它都会在所装饰的实体创建时执行一次。每种装饰器——访问器、类、方法、参数和属性——都接收一组描述所装饰实体的不同参数。
例如,此logOnCall装饰器用于Greeter类方法上,接收Greeter类本身,属性键("log"),以及描述该属性的descriptor对象。修改descriptor.value以在调用Greeter类上的原始greet方法之前记录“装饰”greet方法:
function logOnCall(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
console.log("[logOnCall] I am decorating", target.constructor.name);
descriptor.value = function (...args: unknown[]) {
console.log(`[descriptor.value] Calling '${key}' with:`, ...args);
return original.call(this, ...args);
}
}
class Greeter {
@logOnCall
greet(message: string) {
console.log(`[greet] Hello, ${message}!`);
}
}
new Greeter().greet("you");
// Output log:
// "[logOnCall] I am decorating", "Greeter"
// "[descriptor.value] Calling 'greet' with:", "you"
// "[greet] Hello, you!"
我不会深入探讨旧的experimentalDecorators如何为每种可能的装饰器类型工作的细微差别和具体信息。 TypeScript 的装饰器支持是实验性的,并且与 ECMAScript 提案的最新草案不一致。尤其是在任何 TypeScript 项目中编写自己的装饰器很少是合理的。
枚举
提示
我建议不要使用枚举,除非您有一组经常重复的字面值,所有这些字面值都可以用一个常见名称描述,并且如果切换到枚举将更容易阅读其代码。
大多数编程语言包含“枚举”或枚举类型的概念,用于表示一组相关值。枚举可以被视为存储在对象中的一组字面值,并为每个值提供友好的名称。
JavaScript 不包括枚举语法,因为传统对象可以用来替代它们。例如,虽然 HTTP 状态码可以存储并用作数字,但许多开发人员发现将它们存储在由友好名称键入的对象中更易于阅读:
const StatusCodes = {
InternalServerError: 500,
NotFound: 404,
Ok: 200,
// ...
} as const;
StatusCodes.InternalServerError; // 500
TypeScript 中类似枚举对象的棘手之处在于没有很好的类型系统方法来表示值必须是它们的值之一。一种常见的方法是使用来自第九章,“类型修饰符”的keyof和typeof类型修改器来“拼凑”一个,但这需要一定的语法输入。
下面的StatusCodeValue类型使用先前的StatusCodes值创建其可能状态码数字值的类型联合:
// Type: 200 | 404 | 500
type StatusCodeValue = (typeof StatusCodes)[keyof typeof StatusCodes];
let statusCodeValue: StatusCodeValue;
statusCodeValue = 200; // Ok
statusCodeValue = -1;
// Error: Type '-1' is not assignable to type 'StatusCodeValue'.
TypeScript 提供了一个用于创建类型为number或string字面值的对象的enum语法。从enum关键字开始,然后是一个对象的名称——通常是 PascalCase——然后是包含枚举中逗号分隔键的{}对象。每个键可以选择在初始值前使用=。
前一个StatusCodes对象将类似于此StatusCode枚举:
enum StatusCode {
InternalServerError = 500,
NotFound = 404,
Ok = 200,
}
StatusCode.InternalServerError; // 500
与类名一样,StatusCode等枚举名称可以用作类型注释中的类型名称。在此处,类型为StatusCode的statusCode变量可以给予StatusCode.Ok或一个数字值:
let statusCode: StatusCode;
statusCode = StatusCode.Ok; // Ok
statusCode = 200; // Ok
警告
TypeScript 允许将任何数字分配给数值枚举值以方便,但会略微降低类型安全性。在先前的代码片段中,statusCode = -1也将被允许。
枚举编译为输出编译后的 JavaScript 中的等效对象。它们的每个成员都变成了具有相应值的对象成员键,反之亦然。
先前的enum StatusCode将大致创建以下 JavaScript:
var StatusCode;
(function (StatusCode) {
StatusCode[StatusCode["InternalServerError"] = 500] = "InternalServerError";
StatusCode[StatusCode["NotFound"] = 404] = "NotFound";
StatusCode[StatusCode["Ok"] = 200] = "Ok";
})(StatusCode || (StatusCode = {}));
枚举是 TypeScript 社区中一个略具争议的话题。 一方面,它们违反了 TypeScript 不向 JavaScript 添加新的运行时语法结构的一般原则。 它们为开发人员学习提供了一种新的非 JavaScript 语法,并围绕选项(如 preserveConstEnums,稍后在本章中讨论)存在一些怪癖。
另一方面,它们对于显式声明已知值集合非常有用。 枚举在 TypeScript 和 VS Code 源代码库中广泛使用!
自动数值
枚举成员不需要具有显式初始值。 当省略值时,TypeScript 将从 0 开始第一个值,并递增每个后续值 1。 当值在只需唯一关联键名而不重要时,允许 TypeScript 选择枚举成员的值是一个不错的选择。
此 VisualTheme 枚举允许 TypeScript 完全选择值,结果是三个整数:
enum VisualTheme {
Dark, // 0
Light, // 1
System, // 2
}
发出的 JavaScript 看起来与如果值已被显式设置时相同:
var VisualTheme;
(function (VisualTheme) {
VisualTheme[VisualTheme["Dark"] = 0] = "Dark";
VisualTheme[VisualTheme["Light"] = 1] = "Light";
VisualTheme[VisualTheme["System"] = 2] = "System";
})(VisualTheme || (VisualTheme = {}));
在带有数值的枚举中,任何未显式设置值的成员都会比前一个值高1。
例如,Direction 枚举可能只关心其 Top 成员的值为 1,其余值也是正整数:
enum Direction {
Top = 1,
Right,
Bottom,
Left,
}
其输出的 JavaScript 也将与其余成员显式值 2、3 和 4 相同:
var Direction;
(function (Direction) {
Direction[Direction["Top"] = 1] = "Top";
Direction[Direction["Right"] = 2] = "Right";
Direction[Direction["Bottom"] = 3] = "Bottom";
Direction[Direction["Left"] = 4] = "Left";
})(Direction || (Direction = {}));
警告
修改枚举的顺序将导致底层数值发生变化。 如果你将这些值持久化存储在某个地方(如数据库),请小心更改枚举顺序或删除条目。 因为保存的数值将不再代表你的代码所期望的内容,你的数据可能会突然损坏。
字符串值枚举
枚举也可以使用字符串作为其成员而不是数字。
此 LoadStyle 枚举使用友好的字符串值作为其成员:
enum LoadStyle {
AsNeeded = "as-needed",
Eager = "eager",
}
使用字符串成员值的枚举在输出 JavaScript 方面与使用数值成员值的枚举结构上看起来一样:
var LoadStyle;
(function (LoadStyle) {
LoadStyle["AsNeeded"] = "as-needed";
LoadStyle["Eager"] = "eager";
})(LoadStyle || (LoadStyle = {}));
字符串值枚举对于为共享常量起别名很有用。 而不是使用字符串文字的类型联合,字符串值枚举允许更强大的编辑器自动完成和重命名这些属性,正如在 第十二章,“使用 IDE 功能” 中所述。
字符串成员值的一个缺点是 TypeScript 不能自动计算它们。 仅允许跟随数值成员值的枚举成员可以自动计算。
TypeScript 可以为此枚举的 ImplicitNumber 隐式提供值 9001,因为前一个成员值是数字 9000,但其 NotAllowed 成员将会因其后跟字符串成员值而发出错误:
enum Wat {
FirstString = "first",
SomeNumber = 9000,
ImplicitNumber, // Ok (value 9001)
AnotherString = "another",
NotAllowed,
// Error: Enum member must have initializer.
}
提示
理论上,你可以创建一个既有数值成员又有字符串成员值的枚举。 实际上,这种枚举可能会导致混淆,所以你可能不应该这样做。
常量枚举
因为枚举创建了一个运行时对象,使用它们会产生比使用字面值联合的常见替代策略更多的代码。TypeScript 允许在枚举前加上 const 修饰符来告诉 TypeScript 从编译的 JavaScript 代码中省略它们的对象定义和属性查找。
这个 DisplayHint 枚举用作 displayHint 变量的值:
const enum DisplayHint {
Opaque = 0,
Semitransparent,
Transparent,
}
let displayHint = DisplayHint.Transparent;
输出的编译后 JavaScript 代码将完全省略枚举声明,并在枚举值上使用注释:
let displayHint = 2 /* DisplayHint.Transparent */;
对于仍希望创建枚举对象定义的项目,确实存在一个 preserveConstEnums 编译器选项,该选项将保留枚举声明本身的存在。值仍将直接使用字面量,而不是在枚举对象上访问它们。
前面的代码片段在其编译后的 JavaScript 输出中仍将省略属性查找:
var DisplayHint;
(function (DisplayHint) {
DisplayHint[DisplayHint["Opaque"] = 0] = "Opaque";
DisplayHint[DisplayHint["Semitransparent"] = 1] = "Semitransparent";
DisplayHint[DisplayHint["Transparent"] = 2] = "Transparent";
})(DisplayHint || (DisplayHint = {}));
let displayHint = 2 /* Transparent */;
preserveConstEnums 可以帮助减少生成的 JavaScript 代码的大小,尽管并非所有的 TypeScript 代码转译方式都支持它。参见 第十三章,“配置选项” 获取关于 isolatedModules 编译器选项以及何时可能不支持 const 枚举的更多信息。
命名空间
警告
除非您正在为现有包编写 DefinitelyTyped 类型定义文件,否则不要使用命名空间。命名空间不符合现代 JavaScript 模块语义。它们的自动成员赋值可能使代码难以阅读。我之所以提到它们,是因为您可能会在 .d.ts 文件中遇到它们。
在 ECMAScript 模块得到批准之前,将大量输出代码捆绑到浏览器加载的单个文件中并不罕见。这些巨大的单文件通常创建全局变量来保存项目不同区域重要数值的引用。将这个文件包含在页面中比设置旧的模块加载器(如 RequireJS)更为简单——在许多服务器尚未支持 HTTP/2 下载流时,这样做通常也更为高效。为单文件输出的项目需要一种组织代码部分和全局变量的方式。
TypeScript 语言通过“内部模块”的概念提供了一种解决方案,现在称为命名空间。命名空间 是一个全局可用的对象,其“导出”的内容可作为该对象的成员调用。命名空间使用 namespace 关键字后跟一个 {} 代码块来定义。该命名空间块中的所有内容在函数闭包内部评估。
这个 Randomized 命名空间创建一个 value 变量并在内部使用它:
namespace Randomized {
const value = Math.random();
console.log(`My value is ${value}`);
}
它的输出 JavaScript 创建了一个 Randomized 对象,并在函数内部评估了块的内容,因此 value 变量在命名空间外部不可用:
var Randomized;
(function (Randomized) {
const value = Math.random();
console.log(`My value is ${value}`);
})(Randomized || (Randomized = {}));
警告
命名空间和namespace关键字最初在 TypeScript 中分别称为“模块”和“module”。考虑到现代模块加载器和 ECMAScript 模块的兴起,这是一个可惜的选择。在回顾中,module关键字在非常旧的项目中仍偶尔会出现,但可以——也应该——安全地替换为namespace。
命名空间导出
命名空间的关键特性是它可以通过将内容作为命名空间对象的成员来“导出”。然后,代码的其他部分可以通过名称引用该成员。
在这里,Settings命名空间导出了在命名空间内部和外部使用的describe、name和version值:
namespace Settings {
export const name = "My Application";
export const version = "1.2.3";
export function describe() {
return `${Settings.name} at version ${Settings.version}`;
}
console.log("Initializing", describe());
}
console.log("Initialized", Settings.describe());
输出的 JavaScript 显示,值始终作为Settings的成员(例如,Settings.name)在内部和外部使用:
var Settings;
(function (Settings) {
Settings.name = "My Application";
Settings.version = "1.2.3";
function describe() {
return `${Settings.name} at version ${Settings.version}`;
}
Settings.describe = describe;
console.log("Initializing", describe());
})(Settings || (Settings = {}));
console.log("Initialized", Settings.describe());
通过使用var来输出对象,并将导出的内容引用为这些对象的成员,命名空间的设计在跨多个文件时工作良好。之前的Settings命名空间可以在多个文件中重写:
// settings/constants.ts
namespace Settings {
export const name = "My Application";
export const version = "1.2.3";
}
// settings/describe.ts
namespace Settings {
export function describe() {
return `${Settings.name} at version ${Settings.version}`;
}
console.log("Initializing", describe());
}
// index.ts
console.log("Initialized", Settings.describe());
输出的 JavaScript,拼接在一起,大致如下:
// settings/constants.ts
var Settings;
(function (Settings) {
Settings.name = "My Application";
Settings.version = "1.2.3";
})(Settings || (Settings = {}));
// settings/describe.ts
(function (Settings) {
function describe() {
return `${Settings.name} at version ${Settings.version}`;
}
Settings.describe = describe;
console.log("Initialized", describe());
})(Settings || (Settings = {}));
console.log("Initialized", Settings.describe());
在单文件和多文件声明形式中,运行时输出对象是一个具有三个键的对象。大致上:
const Settings = {
describe: function describe() {
return `${Settings.name} at version ${Settings.version}`;
},
name: "My Application",
version: "1.2.3",
};
使用命名空间的关键区别在于,它可以跨不同文件分割,并且成员仍然可以在命名空间的名称下相互引用。
嵌套命名空间
命名空间可以通过将命名空间从另一个命名空间导出或在名称中放置一个或多个.(点)来“嵌套”到无限级别。
以下两个命名空间声明的行为将是相同的:
namespace Root.Nested {
export const value1 = true;
}
namespace Root {
export namespace Nested {
export const value2 = true;
}
}
它们都编译为结构上相同的代码:
(function (Root) {
let Nested;
(function (Nested) {
Nested.value2 = true;
})(Nested || (Nested = {}));
})(Root || (Root = {}));
嵌套命名空间是强制在使用命名空间组织的大型项目中的各个部分之间更好地划分的一个方便方法。许多开发人员选择使用项目名称的根命名空间——也许在公司的和/或组织的命名空间内部——以及项目的每个主要区域的子命名空间。
类型定义中的命名空间
现在命名空间的唯一优点——也是我选择将其包括在本书中的唯一原因——是它们对于 DefinitelyTyped 类型定义非常有用。许多 JavaScript 库——特别是一些较旧的 Web 应用程序基础库,如 jQuery——是为了在 Web 浏览器中以传统的、非模块化的<script>标签包含而设置的。它们的类型定义需要指示它们创建一个对所有代码可用的全局变量——这种结构正是命名空间所完美捕捉的。
此外,许多支持浏览器的 JavaScript 库既可以在更现代的模块系统中导入,也可以创建一个全局命名空间。TypeScript 允许模块类型定义包括一个export as namespace,后跟全局名称,以指示该模块也在该名称下全局可用。
例如,这个模块的声明文件导出了一个 value 并且在全局可用:
// node_modules/@types/my-example-lib/index.d.ts
export const value: number;
export as namespace libExample;
类型系统将会知道 import("my-example-lib") 和 window.libExample 都将返回一个带有类型为 number 的 value 属性的模块:
// src/index.ts
import * as libExample from "my-example-lib"; // Ok
const value = window.libExample.value; // Ok
更倾向于使用模块而非命名空间
与其使用命名空间,前面示例中的 settings/constants.ts 文件和 settings/describe.ts 文件可以根据现代标准改为使用 ECMAScript 模块:
// settings/constants.ts
export const name = "My Application";
export const version = "1.2.3";
// settings/describe.ts
import { name, version } from "./constants";
export function describe() {
return `${Settings.name} at version ${Settings.version}`;
}
console.log("Initializing", describe());
// index.ts
import { describe } from "./settings/describe";
console.log("Initialized", describe());
在使用命名空间结构化的 TypeScript 代码中,由于命名空间创建的是隐式的文件之间联系,而非 ECMAScript 模块那样显式声明的联系,因此现代构建工具(如 Webpack)很难对其进行树摇(即删除未使用的文件)。通常强烈建议使用 ECMAScript 模块而非 TypeScript 命名空间编写运行时代码。
注意
截至 2022 年,TypeScript 本身是用命名空间编写的,但 TypeScript 团队正在努力转换为模块。谁知道,也许在您阅读本文时,他们已完成这一转换!让我们拭目以待。
仅类型导入和导出
我希望以积极的笔调结束这一章节。最后介绍的一组语法扩展——仅类型导入和导出,非常有用且不会增加生成的 JavaScript 的复杂性。
TypeScript 的编译器将从导入和导出的值中删除仅在类型系统中使用的值,因为它们不在运行时 JavaScript 中使用。
例如,以下的 index.ts 文件创建了一个 action 变量和一个 ActivistArea 类型,然后通过独立的导出声明导出它们。当编译到 index.js 时,TypeScript 的编译器会知道从独立导出声明中移除 ActivistArea:
// index.ts
const action = { area: "people", name: "Bella Abzug", role: "politician" };
type ActivistArea = "nature" | "people";
export { action, ActivistArea };
// index.js
const action = { area: "people", name: "Bella Abzug", role: "politician" };
export { action };
知道如何删除像 ActivistArea 这样的重新导出类型需要了解 TypeScript 类型系统。像 Babel 这样逐个文件操作的转译器无法访问 TypeScript 类型系统,无法知道每个名称是否仅在类型系统中使用。TypeScript 的 isolatedModules 编译选项,在除 TypeScript 外的其他工具中帮助确保代码将转译成功,详见 第十三章,“配置选项”。
TypeScript 允许在单个导入名称或整个 {...} 对象的 export 和 import 声明前面添加 type 修饰符。这样做表示它们仅用于类型系统中。还可以将包的默认导入标记为 type。
在以下代码片段中,当 index.ts 转译为输出的 index.js 时,只有 value 的导入和导出被保留:
// index.ts
import { type TypeOne, value } from "my-example-types";
import type { TypeTwo } from "my-example-types";
import type DefaultType from "my-example-types";
export { type TypeOne, value };
export type { DefaultType, TypeTwo };
// index.js
import { value } from "my-example-types";
export { value };
一些 TypeScript 开发者甚至更喜欢选择使用仅类型导入,以明确哪些导入仅用作类型。如果将导入标记为仅类型,则尝试将其用作运行时值将触发 TypeScript 错误。
ClassOne被正常导入并可在运行时使用,但是ClassTwo不能,因为它作为类型导入:
import { ClassOne, type ClassTwo } from "my-example-types";
new ClassOne(); // Ok
new ClassTwo();
// ~~~~~~~~
// Error: 'ClassTwo' cannot be used as a value
// because it was imported using 'import type'.
而不是向生成的 JavaScript 添加复杂性,仅限于类型的导入和导出能够明确告知 TypeScript 之外的转译器在何时可以移除代码片段。因此,大多数 TypeScript 开发者不会像对待本章所覆盖的先前语法扩展一样,对它们产生厌恶情绪。
总结
在本章中,你使用了 TypeScript 中包含的一些 JavaScript 语法扩展:
-
在类构造函数中声明类参数属性
-
使用装饰器增强类及其字段
-
用枚举表示值的组合
-
使用命名空间在文件间或类型定义中创建分组
-
仅限类型的导入和导出
提示
在你完成阅读本章之后,练习你所学到的内容https://learningtypescript.com/syntax-extensions。
在 TypeScript 中,你如何称呼支持遗留 JavaScript 扩展的成本?
“罪恶税”
第十五章:类型操作
条件语句,映射
拥有强大的类型控制能力
伴随着巨大的混淆而来
TypeScript 在类型系统中为我们提供了强大的类型定义能力。即使是来自第十章,“泛型”的逻辑修饰符,也无法与本章中类型操作的能力相比。完成本章后,您将能够基于其他类型混合、匹配和修改类型,从而为类型系统提供强大的表示方式。
警告
这些花里胡哨的类型技术通常不是您经常使用的技术。您需要理解它们在有用情况下的用法,但请注意:如果过度使用,它们可能会难以阅读。祝您愉快!
映射类型
TypeScript 提供了一种语法,用于基于另一个类型的属性创建一个新类型:换句话说,映射从一个类型到另一个类型。在 TypeScript 中,映射类型是一种接受另一个类型并对该类型的每个属性执行某些操作的类型。
映射类型通过在一组键的每个键下创建一个新属性来创建一个新类型。它们使用类似于索引签名的语法,但是不是使用像[i: string]那样的静态键类型,而是使用与in一起的来自其他类型的计算类型,比如[K in OriginalType]:
type NewType = {
[K in OriginalType]: NewProperty;
};
映射类型的一个常见用例是创建一个对象,其键是现有联合类型中的每个字符串字面量。这个AnimalCounts类型创建了一个新的对象类型,其中键是Animals联合类型中的每个值,而每个值都是number:
type Animals = "alligator" | "baboon" | "cat";
type AnimalCounts = {
[K in Animals]: number;
};
// Equivalent to:
// {
// alligator: number;
// baboon: number;
// cat: number;
// }
基于联合类型现有文字的映射类型是声明大型接口时节省空间的便捷方式。但是映射类型真正发光的时候,是它们能够作用于其他类型,甚至添加或删除成员修饰符时。
从类型映射类型
映射类型通常使用keyof运算符对现有类型进行操作,以抓取该现有类型的键。通过指示一个类型在现有类型的键上进行映射,我们可以从该现有类型映射到一个新类型。
这个AnimalCounts类型最终与之前的AnimalCounts类型相同,通过从AnimalVariants类型映射到一个新的等效类型:
interface AnimalVariants {
alligator: boolean;
baboon: number;
cat: string;
}
type AnimalCounts = {
[K in keyof AnimalVariants]: number;
};
// Equivalent to:
// {
// alligator: number;
// baboon: number;
// cat: number;
// }
在前面的片段中,keyof命名为K的新类型键已知是原始类型的键。这意味着每个映射类型成员值都允许引用原始类型相同键下的成员值。
如果原始对象是SomeName,映射是[K in keyof SomeName],那么映射类型中的每个成员都可以引用等效的SomeName成员值,作为SomeName[K]。
这个NullableBirdVariants类型接受一个原始的BirdVariants类型,并为每个成员添加| null:
interface BirdVariants {
dove: string;
eagle: boolean;
}
type NullableBirdVariants = {
[K in keyof BirdVariants]: BirdVariants[K] | null,
};
// Equivalent to:
// {
// dove: string | null;
// eagle: boolean | null;
// }
与手动复制原始类型的每个字段相比,映射类型允许您一次定义一组成员,并根据需要批量重新创建它们的新版本。
映射类型和签名
在 第七章,“接口” 中,我介绍了 TypeScript 提供的两种将接口成员声明为函数的方式:
-
Method 语法,如
member(): void:声明接口成员是一个函数,预期作为对象的成员进行调用 -
Property 语法,如
member: () => void:声明接口成员等同于一个独立的函数
Mapped types don’t distinguish between method and property syntaxes on object types. Mapped types treat methods as properties on original types.
这种 ResearcherProperties 类型包含了 Researcher 的 property 和 method 成员:
interface Researcher {
researchMethod(): void;
researchProperty: () => string;
}
type JustProperties<T> = {
[K in keyof T]: T[K];
};
type ResearcherProperties = JustProperties<Researcher>;
// Equivalent to:
// {
// researchMethod: () => void;
// researchProperty: () => string;
// }
在大多数实际的 TypeScript 代码中,方法和属性之间的区别并不常见。很少能找到一个将类类型作为输入的映射类型的实际用途。
修改修饰符
映射类型还可以改变原始类型成员的访问控制修饰符——readonly 和 ? 可选性。可以使用与典型接口相同的语法将 readonly 或 ? 放在映射类型的成员上。
下面的 ReadonlyEnvironmentalist 类型创建了一个带有所有成员 readonly 的 Environmentalist 接口的版本,而 OptionalReadonlyConservationist 更进一步,生成另一个版本,并对所有 ReadonlyEnvironmentalist 成员添加了 ?:
interface Environmentalist {
area: string;
name: string;
}
type ReadonlyEnvironmentalist = {
readonly [K in keyof Environmentalist]: Environmentalist[K];
};
// Equivalent to:
// {
// readonly area: string;
// readonly name: string;
// }
type OptionalReadonlyEnvironmentalist = {
[K in keyof ReadonlyEnvironmentalist]?: ReadonlyEnvironmentalist[K];
};
// Equivalent to:
// {
// readonly area?: string;
// readonly name?: string;
// }
注意
OptionalReadonlyEnvironmentalist 类型可以使用 readonly [K in keyof Environmentalist]?: Environmentalist[K] 的方式进行替代。
通过在新类型中修饰符之前添加 - 来删除修饰符。可以分别使用 -readonly 或 -?: 来代替 readonly 或 ?:。
这种 Conservationist 类型包含了一些 ? 可选和/或 readonly 成员,在 WritableConservationist 中这些成员变为可写,并在 RequiredWritableConservationist 中也被要求:
interface Conservationist {
name: string;
catchphrase?: string;
readonly born: number;
readonly died?: number;
}
type WritableConservationist = {
-readonly [K in keyof Conservationist]: Conservationist[K];
};
// Equivalent to:
// {
// name: string;
// catchphrase?: string;
// born: number;
// died?: number;
// }
type RequiredWritableConservationist = {
[K in keyof WritableConservationist]-?: WritableConservationist[K];
};
// Equivalent to:
// {
// name: string;
// catchphrase: string;
// born: number;
// died: number;
// }
注意
另一种写法是使用 -readonly [K in keyof Conservationist]-?: Conservationist[K] 来替代 RequiredWritableConservationist 类型。
通用映射类型
映射类型的全部威力来自与泛型的结合,允许在不同类型之间重复使用单一映射类型。映射类型能够访问其范围内的任何类型名称的 keyof,包括映射类型本身的类型参数。
泛型映射类型经常用于表示数据在应用程序中流动时的变形方式。例如,可能希望应用程序的某个区域能够接收现有类型的值,但不允许修改数据。
这种 MakeReadonly 泛型类型接收任何类型,并创建一个在其所有成员上添加 readonly 修饰符的新版本。
type MakeReadonly<T> = {
readonly [K in keyof T]: T[K];
}
interface Species {
genus: string;
name: string;
}
type ReadonlySpecies = MakeReadonly<Species>;
// Equivalent to:
// {
// readonly genus: string;
// readonly name: string;
// }
另一个开发人员通常需要表示的转换是接受接口的任意数量并返回该接口的完全填充实例的函数。
下面的MakeOptional类型和createGenusData函数允许提供任意数量的GenusData接口,并返回一个填充了默认值的对象:
interface GenusData {
family: string;
name: string;
}
type MakeOptional<T> = {
[K in keyof T]?: T[K];
}
// Equivalent to:
// {
// family?: string;
// name?: string;
// }
/**
* Spreads any {overrides} on top of default values for GenusData.
*/
function createGenusData(overrides?: MakeOptional<GenusData>): GenusData {
return {
family: 'unknown',
name: 'unknown',
...overrides,
}
}
一些由泛型映射类型完成的操作非常有用,TypeScript 提供了它们的实用类型。例如,使用内置的Partial<T>类型可以使所有属性变为可选。您可以在https://www.typescriptlang.org/docs/handbook/utility-types.html上找到这些内置类型的列表。
条件类型
映射现有类型到其他类型是很巧妙的,但是我们尚未将逻辑条件加入类型系统。现在让我们来做这件事。
TypeScript 的类型系统是逻辑编程语言的一个例子。它允许基于逻辑检查先前的类型创建新的结构(类型)。它使用条件类型的概念来实现:一种根据现有类型解析为两种可能类型之一的类型。
条件类型的语法看起来像三元运算符:
LeftType extends RightType ? IfTrue : IfFalse
条件类型中的逻辑检查始终是左侧类型是否扩展或可分配给右侧类型。
下面的CheckStringAgainstNumber条件类型检查string是否扩展到number,换句话说,string类型是否可分配给number类型。它不行,因此结果类型是“如果为假”的情况:false:
// Type: false
type CheckStringAgainstNumber = string extends number ? true : false;
本章的大部分内容将涉及将其他类型系统特性与条件类型结合使用。随着代码片段变得更加复杂,请记住:每个条件类型都仅仅是布尔逻辑的一部分。每个条件类型接受某种类型并产生两种可能的结果之一。
泛型条件类型
条件类型能够检查其范围内的任何类型名称,包括条件类型本身的类型参数。这意味着您可以编写可重用的泛型类型来基于任何其他类型创建新类型。
将之前的CheckStringAgainstNumber类型转换为通用的CheckAgainstNumber类型,得到的类型是基于先前的类型是否可分配给number的true或false。string仍然是 false,而number和0 | 1则都是 true。
type CheckAgainstNumber<T> = T extends number ? true : false;
// Type: false
type CheckString = CheckAgainstNumber<'parakeet'>;
// Type: true
type CheckString = CheckAgainstNumber<1891>;
// Type: true
type CheckString = CheckAgainstNumber<number>;
下面的CallableSetting类型更实用一些。它接受一个泛型T并检查T是否为函数。如果是,则结果类型为T,就像GetNumbersSetting中T为() => number[]一样。否则,结果类型是返回T的函数,就像StringSetting中T为string一样,因此结果类型是() => string:
type CallableSetting<T> =
T extends () => any
? T
: () => T
// Type: () => number[]
type GetNumbersSetting = CallableSetting<() => number[]>;
// Type: () => string
type StringSetting = CallableSetting<string>;
条件类型也能够使用对象成员查找语法访问提供的类型的成员。它们可以在其extends子句中和/或在结果类型中使用这些信息。
JavaScript 库经常使用的一种模式非常适合条件泛型类型,即根据提供给函数的选项对象改变函数的返回类型。
例如,许多数据库函数或等效函数可能使用像throwIfNotFound这样的属性,通过更改函数在值未找到时抛出错误而不是返回undefined。以下的QueryResult类型通过特定情况下选项的throwIfNotFound知道是true,模拟了该行为,结果更窄的是string而不是string | undefined:
interface QueryOptions {
throwIfNotFound: boolean;
}
type QueryResult<Options extends QueryOptions> =
Options["throwIfNotFound"] extends true ? string : string | undefined;
declare function retrieve<Options extends QueryOptions>(
key: string,
options?: Options,
): Promise<QueryResult<Options>>;
// Returned type: string | undefined
await retrieve("Biruté Galdikas");
// Returned type: string | undefined
await retrieve("Jane Goodall", { throwIfNotFound: Math.random() > 0.5 });
// Returned type: string
await retrieve("Dian Fossey", { throwIfNotFound: true });
通过将条件类型与泛型类型参数结合,retrieve函数更精确地告诉类型系统它将如何改变程序的控制流。
类型分布性
条件类型在联合类型上分布,这意味着它们的结果类型将是将该条件类型应用于联合类型中的每个成员(类型在联合类型中)。换句话说,ConditionalType<T | U>与Conditional<T> | Conditional<U>是相同的。
类型分布性是一个解释起来比较啰嗦但对于条件类型在联合类型中的行为非常重要的概念。
考虑以下ArrayifyUnlessString类型,它将其类型参数T转换为一个数组,除非T扩展为string。HalfArrayified等同于string | number[],因为ArrayifyUnlessString<string | number>与ArrayifyUnlessString<string> | ArrayifyUnlessString<number>相同:
type ArrayifyUnlessString<T> = T extends string ? T : T[];
// Type: string | number[]
type HalfArrayified = ArrayifyUnlessString<string | number>;
如果 TypeScript 的条件类型不能在联合类型中分布,HalfArrayified将会是(string | number)[],因为string | number不能赋值给string。换句话说,条件类型将其逻辑应用于联合类型的每个成员,而不是整个联合类型。
推断类型
访问提供类型的成员对作为类型成员存储的信息很有效,但无法捕获其他信息,如函数参数或返回类型。条件类型能够通过在其扩展子句中使用infer关键字来访问其条件的任意部分。在扩展子句中放置infer关键字和一个新类型的名称意味着新类型将在条件类型的真实情况中可用。
这个ArrayItems类型接受一个类型参数T,并检查T是否是某种新的Item类型的数组。如果是,结果类型就是Item;如果不是,就是T:
type ArrayItems<T> =
T extends (infer Item)[]
? Item
: T;
// Type: string
type StringItem = ArrayItems<string>;
// Type: string
type StringArrayItem = ArrayItems<string[]>;
// Type: string[]
type String2DItem = ArrayItems<string[][]>;
推断类型可以用来创建递归条件类型。之前见过的ArrayItems类型可以扩展到递归地检索任意维度数组的项类型:
type ArrayItemsRecursive<T> =
T extends (infer Item)[]
? ArrayItemsRecursive<Item>
: T;
// Type: string
type StringItem = ArrayItemsRecursive<string>;
// Type: string
type StringArrayItem = ArrayItemsRecursive<string[]>;
// Type: string
type String2DItem = ArrayItemsRecursive<string[][]>;
注意,尽管ArrayItems<string[][]>的结果是string[],但ArrayItemsRecursive<string[][]>的结果是string。泛型类型能够递归的能力使它们能够持续应用修改,比如在此处检索数组元素类型。
映射的条件类型
映射类型将对现有类型的每个成员应用更改。条件类型将对单个现有类型应用更改。将它们组合在一起,允许对泛型模板类型的每个成员应用条件逻辑。
此 MakeAllMembersFunctions 类型将类型的每个非函数成员转换为函数:
type MakeAllMembersFunctions<T> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? T[K]
: () => T[K]
};
type MemberFunctions = MakeAllMembersFunctions<{
alreadyFunction: () => string,
notYetFunction: number,
}>;
// Type:
// {
// alreadyFunction: () => string,
// notYetFunction: () => number,
// }
映射条件类型是一种方便的方法,用于使用一些逻辑检查修改现有类型的所有属性。
never
在第四章,“对象”中,我介绍了 never 类型,即底部类型,这意味着它不能有可能的值,也不能被访问。在类型系统以及之前的运行时代码示例中添加 never 类型注释的合适位置可以告诉 TypeScript 更积极地检测到不可达的代码路径。
never 和交集和并集
描述 never 底部类型的另一种方式是它是一个无法存在的类型。这使得 never 在与 & 交集和 | 联合类型一起具有一些有趣的行为:
-
在
&交集类型中的never将交集类型减少到never。 -
在
|联合类型中的never被忽略。
这些 NeverIntersection 和 NeverUnion 类型说明了这些行为:
type NeverIntersection = never & string; // Type: never
type NeverUnion = never | string; // Type: string
特别是在联合类型中被忽略的行为使得 never 对从条件类型和映射类型中过滤值非常有用。
never 和条件类型
泛型条件类型通常使用 never 从联合类型中过滤出类型。因为在联合中忽略 never,所以对于类型联合的泛型条件的结果将只包括那些不是 never 的类型。
此 OnlyStrings 泛型条件类型可过滤掉不是字符串的类型,因此 RedOrBlue 类型可从联合中过滤掉 0 和 null:
type OnlyStrings<T> = T extends string ? T : never;
type RedOrBlue = OnlyStrings<"red" | "blue" | 0 | false>;
// Equivalent to: "red" | "blue"
当为泛型类型制作类型工具时,never 通常与推断的条件类型结合使用。使用 infer 进行类型推断时必须位于条件类型的真实情况下,因此如果假设情况永远不会被使用,那么 never 就是适合放置在那里的类型。
此 FirstParameter 类型接受一个函数类型 T,检查它是否为带有 arg: infer Arg 的函数,并在是的情况下返回该 Arg:
type FirstParameter<T extends (...args: any[]) => any> =
T extends (arg: infer Arg) => any
? Arg
: never;
type GetsString = FirstParameter<
(arg0: string) => void
>; // Type: string
在条件类型的假设情况中使用 never 允许 FirstParameter 提取函数第一个参数的类型。
never 和映射类型
联合类型中的 never 行为也使其对在映射类型中过滤成员非常有用。通过以下三种类型系统特性可以过滤对象的键:
-
在联合类型中忽略
never。 -
映射类型可以映射类型的成员。
-
条件类型可用于在满足条件时将类型转换为
never。
将这三者组合在一起,我们可以创建一个映射类型,将原始类型的每个成员更改为原始键或 never。然后使用 [keyof T] 来获取该类型的成员,从而生成所有这些映射类型结果的联合,过滤掉 never。
下面的OnlyStringProperties类型将每个T[K]成员转换为如果该成员是字符串,则为K键,否则为never:
type OnlyStringProperties<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
interface AllEventData {
participants: string[];
location: string;
name: string;
year: number;
}
type OnlyStringEventData = OnlyStringProperties<AllEventData>;
// Equivalent to: "location" | "name"
另一种解读OnlyStringProperties<T>类型的方法是过滤掉所有非string属性(将它们转换为never),然后返回所有剩余的键([keyof T])。
模板字面类型
我们已经讨论了很多关于条件和/或映射类型的内容。现在让我们转向逻辑较少的类型,专注于字符串一段时间。到目前为止,我提到了两种为字符串值编写类型的策略:
-
原始的
string类型:当值可以是世界上的任何字符串时使用 -
字符串字面类型如
""和"abc":当值只能是这一种类型(或其联合体)时使用
然而,有时您可能希望指示字符串匹配某些字符串模式:部分字符串已知,但部分字符串未知。输入模板字面类型,一种 TypeScript 语法,用于指示字符串类型符合模式。它们看起来像模板字面字符串一样——因此它们的名字——但插入了原始类型或原始类型的联合体。
此模板字面类型指示字符串必须以"Hello"开头,但可以以任意字符串(string)结尾。以"Hello"开头的名称(如"Hello, world!")匹配,但不匹配"World! Hello!"或"hi":
type Greeting = `Hello${string}`;
let matches: Greeting = "Hello, world!"; // Ok
let outOfOrder: Greeting = "World! Hello!";
// ~~~~~~~~~~
// Error: Type '"World! Hello!"' is not assignable to type '`Hello ${string}`'.
let missingAltogether: Greeting = "hi";
// ~~~~~~~~~~~~~~~~~
// Error: Type '"hi"' is not assignable to type '`Hello ${string}`'.
字符串字面类型及其联合体可以在类型插值中代替通用的string原始类型,以限制模板字面类型匹配更窄的字符串模式。模板字面类型非常适合描述必须匹配受限制的允许字符串集合的字符串。
在这里,BrightnessAndColor仅匹配以Brightness开头、以Color结尾且中间有-连字符的字符串:
type Brightness = "dark" | "light";
type Color = "blue" | "red";
type BrightnessAndColor = `${Brightness}-${Color}`;
// Equivalent to: "dark-red" | "light-red" | "dark-blue" | "light-blue"
let colorOk: BrightnessAndColor = "dark-blue"; // Ok
let colorWrongStart: BrightnessAndColor = "medium-blue";
// ~~~~~~~~~~~~~~~
// Error: Type '"medium-blue"' is not assignable to type
// '"dark-blue" | "dark-red" | "light-blue" | "light-red"'.
let colorWrongEnd: BrightnessAndColor = "light-green";
// ~~~~~~~~~~~~~
// Error: Type '"light-green"' is not assignable to type
// '"dark-blue" | "dark-red" | "light-blue" | "light-red"'.
如果没有模板字面类型,我们将不得不费力地写出所有Brightness和Color的四种组合。如果我们向其中任何一个添加更多的字符串字面类型,这将变得很繁琐!
TypeScript 允许模板字面类型包含任何原始类型(除了symbol)或其联合体:string、number、bigint、boolean、null或undefined。
此ExtolNumber类型允许任何以"much "开头、包含看起来像数字的字符串,并以"wow"结尾的字符串:
type ExtolNumber = `much ${number} wow`;
function extol(extolee: ExtolNumber) { /* ... */ }
extol('much 0 wow'); // Ok
extol('much -7 wow'); // Ok
extol('much 9.001 wow'); // Ok
extol('much false wow');
// ~~~~~~~~~~~~~~~~
// Error: Argument of type '"much false wow"' is not
// assignable to parameter of type '`much ${number} wow`'.
内在的字符串操作类型
为了帮助处理字符串类型,TypeScript 提供了一小组内置的通用实用类型,它们接受一个字符串并对该字符串应用某些操作。截至 TypeScript 4.7.2,有四种类型:
-
Uppercase:将字符串字面类型转换为大写。 -
Lowercase:将字符串字面类型转换为小写。 -
Capitalize:将字符串字面类型的首字母转换为大写。 -
Uncapitalize:将字符串字面类型的首字母转换为小写。
这些都可以作为接受字符串的泛型类型使用。例如,使用 Capitalize 来将字符串的第一个字母大写:
type FormalGreeting = Capitalize<"hello.">; // Type: "Hello."
这些内置字符串操作类型在操作对象类型的属性键时非常有用。
模板文字键
模板文字类型介于原始的 string 和字符串文字之间,这意味着它们仍然是字符串。它们可以在任何其他可以使用字符串文字的地方使用。
例如,您可以将它们用作映射类型中的索引签名。此 ExistenceChecks 类型在 DataKey 中的每个字符串都有一个键,使用 check${Capitalize<DataKey>} 进行映射:
type DataKey = "location" | "name" | "year";
type ExistenceChecks = {
[K in `check${Capitalize<DataKey>}`]: () => boolean;
};
// Equivalent to:
// {
// checkLocation: () => boolean;
// checkName: () => boolean;
// checkYear: () => boolean;
// }
function checkExistence(checks: ExistenceChecks) {
checks.checkLocation(); // Type: boolean
checks.checkName(); // Type: boolean
checks.checkWrong();
// ~~~~~~~~~~
// Error: Property 'checkWrong' does not exist on type 'ExistenceChecks'.
}
重映射映射类型键
TypeScript 允许您基于原始成员使用模板文字类型为映射类型的成员创建新的键。在映射类型中使用 as 关键字,后跟模板文字类型作为索引签名,可以将结果类型的键修改为与模板文字类型匹配。这样做允许映射类型为每个映射属性具有不同的键,同时仍然引用原始值。
在这里,DataEntryGetters 是一种映射类型,其键是 getLocation、getName 和 getYear。每个键都映射到一个具有模板文字类型的新键。每个映射值是一个函数,其返回类型是使用原始 K 键作为类型参数的 DataEntry:
interface DataEntry<T> {
key: T;
value: string;
}
type DataKey = "location" | "name" | "year";
type DataEntryGetters = {
[K in DataKey as `get${Capitalize<K>}`]: () => DataEntry<K>;
};
// Equivalent to:
// {
// getLocation: () => DataEntry<"location">;
// getName: () => DataEntry<"name">;
// getYear: () => DataEntry<"year">;
// }
键重映可以与其他类型操作结合使用,以创建基于现有类型形状的映射类型。一个有趣的组合是在现有对象上使用 keyof typeof 来创建该对象类型的映射类型。
此 ConfigGetter 类型基于 config 类型,但每个字段都是返回原始配置的函数,并且键已从原始键修改:
const config = {
location: "unknown",
name: "anonymous",
year: 0,
};
type LazyValues = {
[K in keyof typeof config as `${K}Lazy`]: () => Promise<typeof config[K]>;
};
// Equivalent to:
// {
// location: Promise<string>;
// name: Promise<string>;
// year: Promise<number>;
// }
async function withLazyValues(configGetter: LazyValues) {
await configGetter.locationLazy; // Resultant type: string
await configGetter.missingLazy();
// ~~~~~~~~~~~
// Error: Property 'missingLazy' does not exist on type 'LazyValues'.
};
请注意,在 JavaScript 中,对象键可以是 string 或 Symbol 类型——而 Symbol 键不能用作模板文字类型,因为它们不是原始类型。如果尝试在泛型类型中使用重映模板文字类型键,TypeScript 将会抱怨 symbol 不能用作模板文字类型:
type TurnIntoGettersDirect<T> = {
[K in keyof T as `get${K}`]: () => T[K]
// ~
// Error: Type 'keyof T' is not assignable to type
// 'string | number | bigint | boolean | null | undefined'.
// Type 'string | number | symbol' is not assignable to type
// 'string | number | bigint | boolean | null | undefined'.
// Type 'symbol' is not assignable to type
// 'string | number | bigint | boolean | null | undefined'.
};
要解决这个限制,您可以使用 string & 交集类型来强制只使用可以是字符串的类型。因为 string & symbol 的结果是 never,整个模板字符串将减少到 never,TypeScript 将忽略它:
const someSymbol = Symbol("");
interface HasStringAndSymbol {
StringKey: string;
[someSymbol]: number;
}
type TurnIntoGetters<T> = {
[K in keyof T as `get${string & K}`]: () => T[K]
};
type GettersJustString = TurnIntoGetters<HasStringAndSymbol>;
// Equivalent to:
// {
// getStringKey: () => string;
// }
TypeScript 从联合类型中过滤出 never 类型的行为再次证明其非常有用!
类型操作与复杂性
调试比一开始编写代码要难两倍。因此,如果您尽可能聪明地编写代码,那么您在定义上就不够聪明来调试它。
Brian Kernighan
本章描述的类型操作是当今任何编程语言中最强大、尖端的类型系统特性之一。大多数开发者对它们还不够熟悉,无法调试其复杂用法中的错误。像我在第十二章,“使用 IDE 功能”中介绍的行业标准开发工具通常并不适用于可视化多层次的相互使用的类型操作。
如果你确实需要使用类型操作,请为了任何阅读你代码的开发者(包括未来的你),尽量保持最少的使用。使用可读的名称帮助读者在阅读代码时理解。对于任何你认为未来的读者可能会遇到困难的地方,请留下描述性的注释。
总结
在这一章中,通过在其类型系统中操作类型,你揭示了 TypeScript 的真正力量:
-
使用映射类型将现有类型转换为新类型
-
在类型操作中引入逻辑,使用条件类型
-
学习
never如何与交集、并集、条件类型和映射类型相互作用 -
使用模板字面类型表示字符串类型的模式
-
结合模板字面类型和映射类型来修改类型键
提示
现在你已经完成了本章的阅读,请在https://learningtypescript.com/type-operations上练习你所学到的内容。
当你在类型系统中迷失时,你会使用什么?
一个映射类型!


浙公网安备 33010602011771号