今天我们很高兴宣布发布 TypeScript 5.6!
如果你对 TypeScript 不太熟悉,它是一种基于 JavaScript 之上的语言,增加了类型语法。类型描述了我们期望的变量、参数和函数的形状。TypeScript 的类型检查器可以帮助在代码运行前捕捉拼写错误、缺少属性、不正确的函数调用等问题。类型还为 TypeScript 的编辑器工具提供支持,例如自动补全、代码导航和重构功能,你可以在 Visual Studio 和 VS Code 等编辑器中看到这些功能。事实上,如果你在这些编辑器中编写 JavaScript,实际上是由 TypeScript 提供支持的!你可以在 TypeScript 官网了解更多信息。
你可以通过以下命令使用 npm 开始使用 TypeScript:
1 | npm install -D typescript |
或者通过 NuGet。
Beta 和 RC 版本后的更新
自 TypeScript 5.6 beta 版以来,我们回退了与 TypeScript 语言服务搜索 tsconfig.json
文件相关的更改。之前,语言服务会一直向上查找所有可能包含文件的 tsconfig.json
项目文件。这可能会导致打开多个引用项目,因此我们回退了该行为,并正在探索在 TypeScript 5.7 中恢复该行为的方法。
此外,自 beta 版以来,一些新类型已被重命名。以前,TypeScript 提供了一个名为 BuiltinIterator
的类型,用于描述由 Iterator.prototype
支持的所有值。它现已重命名为 IteratorObject
,具有不同的类型参数集,并新增了几个子类型,如 ArrayIterator
、MapIterator
等。
新增了一个名为 --stopOnBuildErrors
的标志用于 --build
模式。当项目构建时遇到任何错误,其他项目将不会继续构建。这提供了类似于 TypeScript 5.6 之前版本的行为,因为 TypeScript 5.6 在面对错误时总是继续构建。
还新增了编辑器功能,例如对提交字符的直接支持和自动导入排除模式。
禁止空值和永真检查
或许你写过正则表达式,却忘记调用 .test(...)
:
1 | if (/0x[0-9a-f]/) { |
或者你可能错误地写了 =>
(箭头函数)而不是 >=
(大于等于):
1 | if (x => 0) { |
又或者你试图用 ??
设置默认值,但搞混了 ??
与比较运算符(如 <
)的优先级:
1 | function isValid(value: string | number, options: any, strictness: "strict" | "loose") { |
或者你在复杂表达式中误放了一个括号:
1 | if ( |
这些例子都没有按照作者的预期执行,但它们都是有效的 JavaScript 代码。以前,TypeScript 也会默默地接受这些例子。
但经过一些实验,我们发现许多 bug 可以通过标记这些可疑的例子来捕捉。在 TypeScript 5.6 中,当编译器可以在语法上确定真值或空值检查总会以特定方式求值时,编译器现在会报错。因此,在上面的例子中,你会看到这些错误:
1 | if (/0x[0-9a-f]/) { |
类似的结果可以通过启用 ESLint 的 no-constant-binary-expression
规则来实现,ESLint 在他们的博客文章中也展示了一些结果;但 TypeScript 执行的新检查与 ESLint 规则并不完全重叠,我们也认为将这些检查内置到 TypeScript 本身中是非常有价值的。
需要注意的是,某些表达式即使总是真值或空值,仍然是允许的。具体来说,true
、false
、0
和 1
尽管总是是真值或假值,但它们仍然被允许,因为以下代码是惯用且有用的:
1 | while (true) { |
而类似的代码:
1 | if (true || inDebuggingOrDevelopmentEnvironment()) { |
在迭代/调试代码时也是有用的。
如果你对实现或它捕捉的 bug 感兴趣,可以查看实现此功能的 pull request。
迭代器助手方法
JavaScript 中有一个可迭代对象的概念(通过调用 [Symbol.iterator]()
并获取迭代器的东西)和迭代器(通过调用 next()
方法来获取下一个值的东西)。通常,你不需要在使用 for/of
循环或 [...]
展开新数组时过多考虑这些东西。但 TypeScript 通过类型 Iterable
和 Iterator
对它们进行建模(甚至还有同时扮演两者角色的 IterableIterator
),这些类型描述了使诸如 for/of
之类的构造能够在它们上面工作的最小成员集。
不过,很多人发现自己在数组方法如 map
、filter
甚至 reduce
上有所缺失。因此,ECMAScript 最近提出了一项提案,将这些方法(以及更多)添加到 JavaScript 中大多数的 IterableIterator
上。
例如,每个生成器现在都生成一个带有 map
和 take
方法的对象。
1 | function* positiveIntegers() { |
同样,keys()
、values()
和 entries()
方法也适用于 Maps
和 Sets
。
1 | function invertKeysAndValues<K, V>(map: Map<K, V>): Map<V, K> { |
你还可以扩展新的 Iterator
对象:
1 | /** |
你还可以使用 Iterator.from
将任何现有的 Iterable
或 Iterator
适配为此新类型:
1 | Iterator.from(...).filter(someFunction); |
所有这些新方法只要你运行在较新的 JavaScript 运行时,或者使用 Iterator
对象的 polyfill,就可以使用。
现在,我们必须谈谈命名问题。
前面我们提到 TypeScript 有 Iterable
和 Iterator
类型;然而,正如我们所提到的,它们更像是确保某些操作可行的“协议”。这意味着并不是所有在 TypeScript 中声明为 Iterable
或 Iterator
的值都会有我们上面提到的方法。
但是,仍然有一个新的运行时值叫做 Iterator
。你可以在 JavaScript 中引用 Iterator
以及 Iterator.prototype
作为实际值。这有点尴尬,因为 TypeScript 已经定义了一个名为 Iterator
的东西,纯
粹用于类型检查。因此,由于这个不幸的命名冲突,TypeScript 需要引入一个单独的类型来描述这些原生/内置的可迭代迭代器。
TypeScript 5.6 引入了一种新类型,称为 IteratorObject
,其定义如下:
1 | interface IteratorObject<T, TReturn = unknown, TNext = unknown> extends Iterator<T, TReturn, TNext> { |
许多内置的集合和方法会生成 IteratorObject
的子类型(如 ArrayIterator
、SetIterator
、MapIterator
等),而 lib.d.ts
中的核心 JavaScript 和 DOM 类型,以及 @types/node
,都已经更新为使用此新类型。
同样,AsyncIteratorObject
类型也有类似的对等版本。目前 JavaScript 中还没有 AsyncIterator
作为运行时值,来为 AsyncIterable
带来相同的方法,但这是一个正在积极提案中的功能,而这个新类型为未来做准备。
我们要感谢 Kevin Gibbons,他为这些类型的变更做出了贡献,并且是这个提案的共同作者之一。
严格的内置迭代器检查(及 --strictBuiltinIteratorReturn
)
当你在 Iterator<T, TReturn>
上调用 next()
方法时,它会返回一个包含 value
和 done
属性的对象。这种对象通过 IteratorResult
类型建模:
1 | type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>; |
这里的命名方式受到生成器函数工作方式的启发。生成器函数可以产生值(yield),然后返回一个最终值——但两者之间的类型可以不相关。
1 | function* abc123() { |
通过引入 IteratorObject
类型,我们发现让 IteratorObjects
的实现变得安全存在一些难点。同时,IteratorResult
在 TReturn
为 any
时(默认情况!)长期以来一直存在不安全问题。例如,假设我们有一个 IteratorResult<string, any>
。如果我们尝试获取此类型的值,最终会得到 string | any
,即 any
。
1 | function* uppercase(iter: Iterator<string, any>) { |
今天要修复所有 Iterator
上的这个问题很难,因为会引入大量的破坏性修改,但至少可以修复大多数被创建的 IteratorObjects
。
TypeScript 5.6 引入了一种新的内置类型 BuiltinIteratorReturn
,以及一个新的严格模式标志 --strictBuiltinIteratorReturn
。无论何时在 lib.d.ts
这样的地方使用 IteratorObjects
时,它们都会写成带有 BuiltinIteratorReturn
类型的 TReturn
(不过你更常看到更具体的 MapIterator
、ArrayIterator
、SetIterator
)。
1 | interface MapIterator<T> extends IteratorObject<T, BuiltinIteratorReturn, unknown> { |
默认情况下,BuiltinIteratorReturn
是 any
,但当启用 --strictBuiltinIteratorReturn
(可能通过 --strict
)时,它是 undefined
。在这个新模式下,如果我们使用 BuiltinIteratorReturn
,我们的例子就会正确报错:
1 | function* uppercase(iter: Iterator<string, BuiltinIteratorReturn>) { |
你通常会在 lib.d.ts
中看到 BuiltinIteratorReturn
与 IteratorObject
搭配使用。一般来说,我们建议在代码中尽可能明确指定 TReturn
。
对任意模块标识符的支持
JavaScript 允许模块使用无效的标识符名称作为字符串字面量导出绑定:
1 | const banana = "🍌"; |
同样,它允许模块使用这些任意名称导入,并将它们绑定到有效的标识符:
1 | import { "🍌" as banana } from "./foo" |
这看起来像是一个有趣的小技巧,但在与其他语言的互操作性方面有它的用途(通常通过 JavaScript/WebAssembly 边界),因为其他语言可能对有效标识符有不同的规则。这对生成代码的工具(如 esbuild
的 inject
功能)也很有用。
TypeScript 5.6 现在允许你在代码中使用这些任意的模块标识符!我们要感谢 Evan Wallace,他为 TypeScript 做出了这项变更的贡献!
--noUncheckedSideEffectImports
选项
在 JavaScript 中,可以导入一个模块而不实际导入其中的任何值。
1 | import "some-module"; |
这些导入通常被称为副作用导入,因为它们唯一有用的行为是通过执行某些副作用(例如注册全局变量,或向原型添加 polyfill)。
在 TypeScript 中,这种语法有一个非常奇怪的特性:如果导入可以解析为一个有效的源文件,TypeScript 会加载并检查该文件。另一方面,如果找不到源文件,TypeScript 会默默忽略导入!
这种行为令人惊讶,但部分源于对 JavaScript 生态系统模式的建模。例如,这种语法也被用于捆绑器中的特殊加载器,来加载 CSS 或其他资源。你的捆绑器可能配置为允许你通过类似于以下的方式引入特定的 .css
文件:
1 | import "./button-component.css"; |
然而,这掩盖了副作用导入中的潜在拼写错误。这就是为什么 TypeScript 5.6 引入了一个新的编译器选项 --noUncheckedSideEffectImports
,以捕捉这些情况。当启用 --noUncheckedSideEffectImports
时,如果 TypeScript 找不到副作用导入的源文件,现在会报错。
1 | import "oops-this-module-does-not-exist"; |
启用此选项后,一些工作正常的代码现在可能会报错,例如上面的 CSS 示例。为了绕过这个问题,用户可能更适合为资产编写所谓的环境模块声明,并使用通配符说明符。它可以放在全局文件中,看起来像这样:
1 | // ./src/globals.d.ts |
事实上,你的项目中可能已经有类似的文件!例如,运行 vite init
之类的命令可能会创建类似的 vite-env.d.ts
文件。
虽然此选项默认关闭,但我们鼓励用户尝试启用它!
有关更多信息,你可以查看此功能的实现。
--noCheck
选项
TypeScript 5.6 引入了一个新的编译器选项 --noCheck
,允许你跳过所有输入文件的类型检查。这可以在执行生成输出文件所需的语义分析时避免不必要的类型检查。
一个使用场景是将 JavaScript 文件生成和类型检查分离为两个独立的阶段。例如,你可以在迭代时运行 tsc --noCheck
,然后运行 tsc --noEmit
进行彻底的类型检查。你还可以将这两个任务并行运行,甚至在 --watch
模式下也可以,但需要注意的是,如果确实同时运行这两者,可能需要为 --tsBuildInfoFile
指定一个单独的路径。
--noCheck
也可以用来以类似方式生成声明文件。在一个符合 --isolatedDeclarations
的项目中指定 --noCheck
后,TypeScript 可以快速生成声明文件而不进行类型检查。生成的声明文件将仅依赖快速的语法转换。
请注意,在指定 --noCheck
的情况下,如果项目没有使用 --isolatedDeclarations
,TypeScript 仍可能进行必要的类型检查以生成 .d.ts
文件。因此,--noCheck
在某种意义上有些误导;不过,整个过程会比完整的类型检查更懒惰,只会计算未注释声明的类型。这应该比完全的类型检查快得多。
--noCheck
也可以通过 TypeScript API 作为标准选项使用。内部来说,transpileModule
和 transpileDeclaration
已经使用了 noCheck
来加速处理(至少从 TypeScript 5.5 开始)。现在,任何构建工具都可以利用该标志,并采用各种自定义策略来协调和加速构建。
有关更多信息,请参阅 TypeScript 5.5 中为内部提升 noCheck
所做的工作,以及将其公开于命令行的相关工作。
允许带有中间错误的 --build
TypeScript 的项目引用概念允许你将代码库组织为多个项目,并在它们之间创建依赖关系。在 --build
模式下运行 TypeScript 编译器(或简称 tsc -b
)是跨项目执行该构建并确定哪些项目和文件需要编译的内置方式。
以前,使用 --build
模式会假设 --noEmitOnError
并在遇到任何错误时立即停止构建。这意味着如果某个“上游”项目有构建错误,“下游”项目将永远无法被检查和构建。理论上,这是一种非常合理的方法——如果项目有错误,其依赖项不一定处于一致状态。
但实际上,这种刚性使得诸如升级等工作非常痛苦。例如,如果 projectB
依赖于 projectA
,那么更熟悉 projectB
的人无法主动升级他们的代码,直到依赖项 projectA
先被升级。他们的工作被 projectA
的升级阻碍了。
从 TypeScript 5.6 开始,--build
模式即使在依赖项中有中间错误时,也将继续构建项目。在面对中间错误时,错误将被一致地报告,并将尽力生成输出文件;然而,构建将继续完成指定项目的工作。
如果你希望在第一个有错误的项目上停止构建,可以使用一个新标志 --stopOnBuildErrors
。在 CI 环境中运行时,或者当你正在迭代一个依赖于其他项目的项目时,这可能很有用。
注意,为了实现这一点,TypeScript 现在在任何 --build
调用中始终为每个项目生成一个 .tsbuildinfo
文件(即使没有指定 --incremental
/--composite
)。这是为了跟踪 --build
如何调用的状态以及未来需要执行的工作。
你可以在此处阅读有关该更改的更多信息。
编辑器中的区域优先诊断
当 TypeScript 的语言服务请求某个文件的诊断信息(例如错误、建议和废弃内容)时,通常需要检查整个文件。大多数情况下这没有问题,但在非常大的文件中可能会导致延迟。这会令人沮丧,因为修复一个错字应该是一个快速的操作,但在足够大的文件中可能需要几秒钟。
为了解决这个问题,TypeScript 5.6 引入了一个名为区域优先诊断或区域优先检查的新功能。编辑器现在不仅可以请求一组文件的诊断,还可以提供给定文件的相关区域——通常这是当前用户可见的区域。TypeScript 语言服务器可以选择提供两个诊断集:一个是针对该区域,另一个是针对整个文件。这使得在大文件中的编辑操作显得更加响应迅速,不必等待红色波浪线消失太久。
在我们对 TypeScript 自己的 checker.ts
进行测试时,完整的语义诊断响应花费了 3330 毫秒。相对而言,基于区域的第一个诊断响应仅需 143 毫秒!虽然剩余的整个文件响应大约需要 3200 毫秒,但这对于快速编辑而言可以带来巨大的差异。
该功能还包括相当多的工作,以使诊断报告在整个体验中更加一致。由于我们的类型检查器利用缓存来避免重复工作,同一类型之间的后续检查往往可能会产生不同的(通常更短的)错误消息。技术上,懒惰的无序检查可能会导致诊断在编辑器中两个位置报告不同的信息——即使在此功能之前也是如此——但我们不想加重这个问题。通过最近的工作,我们已经解决了许多这些错误的不一致性。
目前,该功能在 Visual Studio Code 中对 TypeScript 5.6 及更高版本可用。
有关更详细的信息,请查看这里的实施和总结。
精细化提交字符
TypeScript 的语言服务现在为每个完成项提供其自己的提交字符。提交字符是特定字符,当输入时,将自动提交当前建议的完成项。
这意味着,随着时间的推移,当你输入某些字符时,编辑器将更频繁地提交当前建议的完成项。例如,考虑以下代码:
1 | declare let food: { |
如果我们的光标在 /**/
处,尚不清楚我们编写的代码将是 let f = (food.eat())
还是 let f = (foo, bar) => foo + bar
。可以想象,编辑器可能会根据我们接下来输入的字符进行不同的自动完成。例如,如果我们输入句点字符(.),我们可能希望编辑器自动完成变量 food
;但如果我们输入逗号字符(,),我们可能是在写一个箭头函数的参数。
不幸的是,以前 TypeScript 只是向编辑器发出信号,表示当前文本可能定义了一个新的参数名称,因此没有安全的提交字符。因此,即使很“明显”编辑器应该自动完成单词 food
,输入句点也不会有任何反应。
TypeScript 现在明确列出了每个完成项可以安全提交的字符。虽然这不会立即改变你的日常体验,但支持这些提交字符的编辑器应该会随着时间的推移看到行为上的改善。要立即看到这些改善,你现在可以使用 Visual Studio Code Insiders 的 TypeScript 每日扩展。在上述代码中输入句点将正确自动完成为 food
。
有关更多信息,请查看添加提交字符的拉取请求以及我们根据上下文调整提交字符的内容。
自动导入的排除模式
TypeScript 的语言服务现在允许你指定一个正则表达式模式列表,以过滤掉某些说明符的自动导入建议。例如,如果你想排除来自像 lodash 这样的包的所有“深度”导入,可以在 Visual Studio Code 中配置以下偏好设置:
1 | { |
或者反过来,你可能想禁止从包的入口点导入:
1 | { |
你甚至可以通过以下设置避免 node:
导入:
1 | { |
要指定某些正则表达式标志,例如 i
或 u
,需要用斜杠括起来。在提供周围斜杠时,需要转义其他内部斜杠。
1 | { |
相同的设置可以通过 javascript.preferences.autoImportSpecifierExcludeRegexes
在 VS Code 中应用于 JavaScript。
请注意,虽然此选项可能与 typescript.preferences.autoImportFileExcludePatterns
略有重叠,但它们之间存在差异。现有的 autoImportFileExcludePatterns
接受一组排除文件路径的通配符模式。这对于许多想要避免从特定文件和目录中自动导入的场景可能更简单,但并不总是足够。例如,如果你使用的是 @types/node
包,同一文件声明了 fs
和 node:fs
,因此我们无法使用 autoImportExcludePatterns
来过滤掉其中一个。
新的 autoImportSpecifierExcludeRegexes
仅针对模块说明符(我们在导入语句中写的特定字符串),因此我们可以编写模式来排除 fs
或 node:fs
,而不排除其他项。此外,我们可以编写模式,强制自动导入优先使用不同的说明符样式(例如,优先使用 ./foo/bar.js
而不是 #foo/bar.js
)。
有关更多信息,请查看这里的实施。
显著的变化
本节强调了一系列显著的变化,应该在任何升级中被认可和理解。有时,它会突出废弃、移除和新的限制。它还可能包含功能改进的 bug 修复,但这些修复也可能通过引入新错误来影响现有构建。
lib.d.ts
为 DOM 生成的类型可能会影响你代码库的类型检查。有关更多信息,请参阅与此版本 TypeScript 的 DOM 和 lib.d.ts 更新相关的链接问题。
.tsbuildinfo 文件始终被写入
为了使 --build
在依赖项中存在中间错误的情况下继续构建项目,并支持命令行上的 --noCheck
,TypeScript 现在始终为 --build
调用中的任何项目生成一个 .tsbuildinfo 文件。这一行为与 --incremental
是否实际开启无关。有关更多信息,请查看这里。
慎重对待 node_modules 中的文件扩展名和 package.json
在 Node.js 在 v12 中实现 ECMAScript 模块支持之前,TypeScript 从未有过良好的方式来判断它在 node_modules 中找到的 .d.ts 文件是否代表以 CommonJS 或 ECMAScript 模块编写的 JavaScript 文件。当大多数 npm 包都是仅 CommonJS 时,这并没有造成太多问题——如果有疑问,TypeScript 只需假设所有内容都像 CommonJS 一样工作。不幸的是,如果这个假设是错误的,就可能允许不安全的导入:
1 | // node_modules/dep/index.d.ts |
实际上,这种情况并不常见。但自从 Node.js 开始支持 ECMAScript 模块以来,npm 上的 ESM 比例不断增长。幸运的是,Node.js 还引入了一种机制,可以帮助 TypeScript 确定文件是 ECMAScript 模块还是 CommonJS 模块:.mjs 和 .cjs 文件扩展名,以及 package.json 中的 “type” 字段。TypeScript 4.7 增加了对理解这些指示符的支持,并且可以创建 .mts 和 .cts 文件;然而,TypeScript 仅在 --module node16
和 --module nodenext
下读取这些指示符,因此上述不安全导入仍然是使用 --module esnext
和 --moduleResolution bundler
的人的问题。
为了解决这个问题,TypeScript 5.6 收集模块格式信息,并利用它在所有模块模式(除了 amd、umd 和 system)中解决模糊性,例如上面的例子。特定格式的文件扩展名(.mts 和 .cts)在任何发现的地方都会被尊重,package.json 中的 “type” 字段也会在 node_modules 依赖项中被参考,无论模块设置如何。以前,从技术上讲,可以将 CommonJS 输出生成到 .mjs 文件或反之亦然:
1 | // main.mts |
现在,.mts 文件从不生成 CommonJS 输出,.cts 文件从不生成 ESM 输出。
请注意,这种行为在 TypeScript 5.5 的预发行版本中已经提供(实施细节在这里),但在 5.6 中,这种行为仅扩展到 node_modules 内的文件。
有关更改的更多详细信息,请参见这里。
正确的计算属性重写检查
以前,标记为 override 的计算属性未能正确检查基类成员的存在。类似地,如果使用 noImplicitOverride,则如果忘记向计算属性添加 override 修饰符,则不会出现错误。
TypeScript 5.6 现在在这两种情况下都正确检查计算属性。
1 | const foo = Symbol("foo"); |
这个修复得益于 Oleksandr Tarasiuk 在这个拉取请求中的贡献。
下一步是什么?
如果你想了解接下来会发生什么,你还可以查看 TypeScript 5.7 的迭代计划,在那里你将看到优先特性、bug 修复和目标发布日期的列表,便于你进行规划。TypeScript 的夜间版本可以通过 npm 轻松使用,还有一个扩展可以在 Visual Studio Code 中使用这些夜间版本。夜间版本通常不会造成干扰,但它们可以让你很好地了解即将到来的内容,同时帮助 TypeScript 项目及早捕获错误!
否则,我们希望 TypeScript 5.6 能为你带来良好的体验,使你的日常编码更加愉快!
祝编码愉快!
—— Daniel Rosenwasser 和 TypeScript 团队
原文:https://devblogs.microsoft.com/typescript/announcing-typescript-5-6/