禁止的空值和真值检查
也许你编写了一个正则表达式,却忘记了对它调用 .test(...)
tsif (/0x[0-9a-f]/) {// Oops! This block always runs.// ...}
或者你可能不小心写成了 =>(创建了一个箭头函数)而不是 >=(大于或等于运算符)
tsif (x => 0) {// Oops! This block always runs.// ...}
或者你可能尝试在 ?? 中使用默认值,却搞混了 ?? 和比较运算符(如 <)的优先级
tsfunction isValid(value: string | number, options: any, strictness: "strict" | "loose") {if (strictness === "loose") {value = +value}return value < options.max ?? 100;// Oops! This is parsed as (value < options.max) ?? 100}
或者你可能在复杂的表达式中放错了括号的位置
tsif (isValid(primaryValue, "strict") || isValid(secondaryValue, "strict") ||isValid(primaryValue, "loose" || isValid(secondaryValue, "loose"))) {// ^^^^ 👀 Did we forget a closing ')'?}
这些示例都没有达到作者预期的效果,但它们在 JavaScript 中都是合法的代码。此前,TypeScript 也会默默地接受这些示例。
但经过一番实验,我们发现通过标记上述类似的可疑示例,可以捕获到许许多多的 bug。在 TypeScript 5.6 中,编译器现在可以在语法层面确定真值或空值检查总是以特定方式评估时,抛出错误。因此,在上面的示例中,你将开始看到错误提示。
tsif (/0x[0-9a-f]/) {// ~~~~~~~~~~~~// error: This kind of expression is always truthy.}if (x => 0) {// ~~~~~~// error: This kind of expression is always truthy.}function isValid(value: string | number, options: any, strictness: "strict" | "loose") {if (strictness === "loose") {value = +value}return value < options.max ?? 100;// ~~~~~~~~~~~~~~~~~~~// error: Right operand of ?? is unreachable because the left operand is never nullish.}if (isValid(primaryValue, "strict") || isValid(secondaryValue, "strict") ||isValid(primaryValue, "loose" || isValid(secondaryValue, "loose"))) {// ~~~~~~~// error: This kind of expression is always truthy.}
通过启用 ESLint 的 no-constant-binary-expression 规则也可以实现类似的效果,你可以查看他们在博客文章中取得的一些成果;但 TypeScript 执行的新检查与 ESLint 规则并不完全重叠,而且我们认为将这些检查内置到 TypeScript 本身中也具有很大价值。
请注意,某些表达式即使总是真值或空值,仍然是被允许的。具体来说,true、false、0 和 1 尽管总是真值或假值,但仍被允许,因为像下面这样的代码
tswhile (true) {doStuff();if (something()) {break;}doOtherStuff();}
仍然是惯用且有用的,而像下面这样的代码
tsif (true || inDebuggingOrDevelopmentEnvironment()) {// ...}
在遍历/调试代码时也很有用。
如果你对该功能的实现或它所捕获的 bug 类型感兴趣,可以查看实现此功能的拉取请求。
迭代器辅助方法
JavaScript 有可迭代对象(iterables)的概念(我们可以通过调用 [Symbol.iterator]() 并获得迭代器来遍历这些对象)以及迭代器(iterators)的概念(具有 next() 方法的对象,我们可以调用该方法来在遍历时获取下一个值)。大体上,当你将它们放入 for/of 循环或通过 [...spread] 展开到新数组中时,通常不需要考虑这些。但 TypeScript 确实使用 Iterable 和 Iterator 类型(甚至还有同时作为两者的 IterableIterator!)对这些进行了建模,这些类型描述了 for/of 等构造在其上工作所需的最小成员集合。
Iterable(和 IterableIterator)很好,因为它们可以在 JavaScript 中的各种地方使用——但很多人发现自己怀念 Array 上的方法,比如 map、filter,以及出于某种原因的 reduce。这就是为什么最近 ECMAScript 提出了一项提案,将 Array 中的许多方法(甚至更多)添加到 JavaScript 中产生的多数 IterableIterator 上。
例如,每个生成器现在都会产生一个同时拥有 map 方法和 take 方法的对象。
tsfunction* positiveIntegers() {let i = 1;while (true) {yield i;i++;}}const evenNumbers = positiveIntegers().map(x => x * 2);// Output:// 2// 4// 6// 8// 10for (const value of evenNumbers.take(5)) {console.log(value);}
对于 Map 和 Set 上的 keys()、values() 和 entries() 等方法也是如此。
tsfunction invertKeysAndValues<K, V>(map: Map<K, V>): Map<V, K> {return new Map(map.entries().map(([k, v]) => [v, k]));}
你还可以扩展新的 Iterator 对象
ts/*** Provides an endless stream of `0`s.*/class Zeroes extends Iterator<number> {next() {return { value: 0, done: false } as const;}}const zeroes = new Zeroes();// Transform into an endless stream of `1`s.const ones = zeroes.map(x => x + 1);
并且你可以使用 Iterator.from 将任何现有的 Iterable 或 Iterator 适配到这种新类型中
tsIterator.from(...).filter(someFunction);
现在,我们必须讨论一下命名问题。
前面我们提到 TypeScript 有 Iterable 和 Iterator 的类型;然而,正如我们所提到的,它们的作用类似于“协议”,以确保某些操作能正常工作。这意味着并不是每个在 TypeScript 中声明为 Iterable 或 Iterator 的值都会拥有上述提到的那些方法。
但确实存在一个名为 Iterator 的新的运行时值。你可以将 Iterator 以及 Iterator.prototype 作为 JavaScript 中的实际值进行引用。这有点尴尬,因为 TypeScript 已经定义了自己用于类型检查的 Iterator。因此,由于这种不幸的名称冲突,TypeScript 需要引入一个单独的类型来描述这些原生的/内置的迭代器。
TypeScript 5.6 引入了一个名为 IteratorObject 的新类型。它的定义如下
tsinterface IteratorObject<T, TReturn = unknown, TNext = unknown> extends Iterator<T, TReturn, TNext> {[Symbol.iterator](): IteratorObject<T, TReturn, TNext>;}
许多内置集合和方法都会产生 IteratorObject 的子类型(如 ArrayIterator、SetIterator、MapIterator 等),并且 lib.d.ts 中的核心 JavaScript 和 DOM 类型,以及 @types/node,都已经更新以使用这种新类型。
类似地,为了保持一致性,还有一个 AsyncIteratorObject 类型。AsyncIterator 目前在 JavaScript 中尚未作为带来相同 AsyncIterable 方法的运行时值存在,但它是一项活跃的提案,而这种新类型正是为其做准备。
我们要感谢 Kevin Gibbons,他贡献了这些类型的更改,并且他是该提案的共同作者之一。
严格内置迭代器检查(及 --strictBuiltinIteratorReturn)
当你调用 Iterator<T, TReturn> 上的 next() 方法时,它会返回一个带有 value 和 done 属性的对象。这是通过 IteratorResult 类型来建模的。
tstype IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;interface IteratorYieldResult<TYield> {done?: false;value: TYield;}interface IteratorReturnResult<TReturn> {done: true;value: TReturn;}
这里的命名灵感来自于生成器函数的工作方式。生成器函数可以 yield 值,然后 return 一个最终值——但两者之间的类型可能是不相关的。
tsfunction abc123() {yield "a";yield "b";yield "c";return 123;}const iter = abc123();iter.next(); // { value: "a", done: false }iter.next(); // { value: "b", done: false }iter.next(); // { value: "c", done: false }iter.next(); // { value: 123, done: true }
有了新的 IteratorObject 类型,我们发现允许安全实现 IteratorObject 存在一些困难。同时,在 TReturn 为 any(默认值!)的情况下,IteratorResult 长期存在不安全性。例如,假设我们有一个 IteratorResult<string, any>。如果我们最终获取此类型的 value,我们将得到 string | any,这实际上就是 any。
tsfunction* uppercase(iter: Iterator<string, any>) {while (true) {const { value, done } = iter.next();yield value.toUppercase(); // oops! forgot to check for `done` first and misspelled `toUpperCase`if (done) {return;}}}
在今天修复每一个 Iterator 会很难,因为它会引入很多中断性更改,但我们至少可以用大多数新创建的 IteratorObject 来解决它。
TypeScript 5.6 引入了一个名为 BuiltinIteratorReturn 的新内置类型和一个名为 --strictBuiltinIteratorReturn 的新 --strict 模式标志。每当 IteratorObject 在 lib.d.ts 等地方使用时,它们总是为 TReturn 编写 BuiltinIteratorReturn 类型(尽管你更经常看到的是更具体的 MapIterator、ArrayIterator、SetIterator)。
tsinterface MapIterator<T> extends IteratorObject<T, BuiltinIteratorReturn, unknown> {[Symbol.iterator](): MapIterator<T>;}// ...interface Map<K, V> {// .../*** Returns an iterable of key, value pairs for every entry in the map.*/entries(): MapIterator<[K, V]>;/*** Returns an iterable of keys in the map*/keys(): MapIterator<K>;/*** Returns an iterable of values in the map*/values(): MapIterator<V>;}
默认情况下,BuiltinIteratorReturn 为 any,但当启用了 --strictBuiltinIteratorReturn(可能通过 --strict)时,它为 undefined。在这种新模式下,如果我们使用 BuiltinIteratorReturn,我们之前的示例现在会正确报错
tsfunction* uppercase(iter: Iterator<string, BuiltinIteratorReturn>) {while (true) {const { value, done } = iter.next();yield value.toUppercase();// ~~~~~ ~~~~~~~~~~~// error! ┃ ┃// ┃ ┗━ Property 'toUppercase' does not exist on type 'string'. Did you mean 'toUpperCase'?// ┃// ┗━ 'value' is possibly 'undefined'.if (done) {return;}}}
你通常会看到 BuiltinIteratorReturn 与 IteratorObject 在 lib.d.ts 中配套使用。通常,我们建议在自己的代码中尽可能明确地指定 TReturn。
欲了解更多信息,你可以在此处阅读有关该功能的介绍。
支持任意模块标识符
JavaScript 允许模块将带有无效标识符名称的绑定导出为字符串字面量
tsconst banana = "🍌";export { banana as "🍌" };
同样,它也允许模块获取带有这些任意名称的导入,并将它们绑定到有效的标识符上
tsimport { "🍌" as banana } from "./foo"/*** om nom nom*/function eat(food: string) {console.log("Eating", food);};eat(banana);
这看起来像是一个巧妙的技巧,但它在与其他语言进行互操作(通常通过 JavaScript/WebAssembly 边界)时有其用途,因为其他语言对于什么是有效标识符可能有不同的规则。它对于生成代码的工具也非常有用,比如带有 inject 功能的 esbuild 及其 inject 特性。
TypeScript 5.6 现在允许你在代码中使用这些任意模块标识符!我们要感谢 Evan Wallace,他为 TypeScript 贡献了此项更改!
--noUncheckedSideEffectImports 选项
在 JavaScript 中,可以 import 一个模块而不实际导入其中的任何值。
tsimport "some-module";
这些导入通常被称为副作用导入(side effect imports),因为它们唯一提供的有用行为就是执行某些副作用(例如注册全局变量或向原型添加 polyfill)。
在 TypeScript 中,这种语法有一个非常奇怪的特性:如果 import 可以解析为有效的源文件,则 TypeScript 会加载并检查该文件。另一方面,如果找不到源文件,TypeScript 会静默忽略该 import!
这是一种令人惊讶的行为,但它部分源于对 JavaScript 生态系统中建模模式的需求。例如,这种语法也被用于打包工具中的特殊加载器,以加载 CSS 或其他资产。你的打包工具可能配置得让你通过编写如下代码来包含特定的 .css 文件
tsximport "./button-component.css";export function Button() {// ...}
尽管如此,这掩盖了副作用导入中的潜在拼写错误。这就是为什么 TypeScript 5.6 引入了一个新的编译器选项 --noUncheckedSideEffectImports 来捕获这些情况。启用 --noUncheckedSideEffectImports 后,如果 TypeScript 找不到副作用导入的源文件,现在会报错。
tsimport "oops-this-module-does-not-exist";// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~// error: Cannot find module 'oops-this-module-does-not-exist' or its corresponding type declarations.
启用此选项时,一些工作的代码可能会收到错误,例如上面的 CSS 示例。为了解决这个问题,只想为资产编写副作用 import 的用户可以更好地利用所谓的环境模块声明(ambient module declaration)配合通配符说明符。它会放在全局文件中,看起来像下面这样
ts// ./src/globals.d.ts// Recognize all CSS files as module imports.declare module "*.css" {}
事实上,你的项目中可能已经有了这样的文件!例如,运行 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 的项目引用(project references)概念允许你将代码库组织成多个项目并创建它们之间的依赖关系。在 --build 模式(或简称为 tsc -b)下运行 TypeScript 编译器是跨项目进行实际构建并找出哪些项目和文件需要编译的内置方式。
以前,使用 --build 模式会假定 --noEmitOnError,如果遇到任何错误,会立即停止构建。这意味着如果任何“上游”依赖项有构建错误,“下游”项目就永远无法被检查和构建。理论上,这是一种非常合理的做法——如果一个项目有错误,它对于其依赖项来说不一定处于连贯的状态。
实际上,这种刚性使得升级等操作变得非常痛苦。例如,如果 projectB 依赖于 projectA,那么更熟悉 projectB 的人无法主动升级他们的代码,直到他们的依赖项被升级。他们会被升级 projectA 的工作所阻塞。
从 TypeScript 5.6 开始,即使依赖项中存在中间错误,--build 模式也会继续构建项目。面对中间错误时,它们将被持续报告,输出文件将尽力生成;但是,构建将继续运行直到指定项目完成。
如果你想在第一个出现错误的项目上停止构建,可以使用一个名为 --stopOnBuildErrors 的新标志。这在 CI 环境中运行或在被其他项目大量依赖的项目上进行迭代时非常有用。
请注意,为了实现这一点,TypeScript 现在总是为 --build 调用中的任何项目发出 .tsbuildinfo 文件(即使未指定 --incremental/--composite)。这是为了跟踪 --build 是如何调用的以及将来需要执行哪些工作。
编辑器中的区域优先级诊断
当 TypeScript 的语言服务被要求对一个文件进行诊断(如错误、建议和弃用提示)时,它通常需要检查整个文件。大多数时候这没问题,但在超大文件中,这可能会导致延迟。这很令人沮丧,因为修复拼写错误应该感觉像是一个快速操作,但在足够大的文件中可能需要几秒钟。
为了解决这个问题,TypeScript 5.6 引入了一项称为区域优先级诊断(region-prioritized diagnostics)或区域优先级检查(region-prioritized checking)的新功能。编辑器现在不仅可以请求一组文件的诊断,还可以提供给定文件的相关区域——意图是这通常是用户当前可见的文件区域。TypeScript 语言服务器随后可以选择提供两组诊断:一组用于该区域,一组用于整个文件。这使得在大文件中的编辑感觉快得多,这样你就不必等待那么久让那些红色波浪线消失了。
具体数字方面,在我们对 TypeScript 自身的 checker.ts 进行测试时,完整的语义诊断响应需要 3330 毫秒。相比之下,第一个基于区域的诊断响应仅需 143 毫秒!虽然剩余的整个文件响应需要约 3200 毫秒,但这对于快速编辑来说可能产生巨大的差异。
此功能还包含了大量工作,以使诊断在你的整个体验中报告得更加一致。由于我们的类型检查器利用缓存来避免重复工作,不同位置间相同类型的检查往往可能导致不同的(通常更简短的)错误消息。从技术上讲,惰性的乱序检查可能导致诊断在编辑器中的两个位置之间报告不同——甚至在此功能之前也是如此——但我们不想加剧这个问题。通过最近的工作,我们已经消除了许多此类错误不一致的情况。
目前,此功能在 Visual Studio Code 中适用于 TypeScript 5.6 及更高版本。
欲了解更详细的信息,请查看此处的实现和总结。
细粒度提交字符
TypeScript 的语言服务现在为每个补全项提供其自己的提交字符(commit characters)。提交字符是指当输入时会自动提交当前建议的补全项的特定字符。
这意味着随着时间的推移,你的编辑器现在会在你输入某些字符时更频繁地提交当前建议的补全项。例如,看看下面的代码
tsdeclare let food: {eat(): any;}let f = (foo/**/
如果我们的光标在 /**/ 处,不清楚我们正在编写的代码是 let f = (food.eat()) 还是 let f = (foo, bar) => foo + bar。你可以想象编辑器可能会根据我们接下来输入的字符以不同方式自动补全。例如,如果我们输入句点字符 (.),我们可能希望编辑器使用变量 food 进行补全;但如果我们输入逗号字符 (,),我们可能正在编写箭头函数的参数。
不幸的是,以前 TypeScript 只是向编辑器发出信号,表示当前文本可能定义了新的参数名称,因此没有任何提交字符是安全的。所以即使“很明显”编辑器应该使用单词 food 进行自动补全,输入 . 也不会做任何事情。
TypeScript 现在明确列出了哪些字符对于每个补全项是安全提交的。虽然这不会立即改变你的日常体验,但支持这些提交字符的编辑器应该会随着时间的推移看到行为上的改进。要立即查看这些改进,你现在可以使用 TypeScript 夜间扩展配合 Visual Studio Code Insiders。在上面的代码中输入 . 会正确地使用 food 自动补全。
欲了解更多信息,请参阅添加提交字符的拉取请求,以及我们根据上下文调整提交字符的调整。
自动导入的排除模式
TypeScript 的语言服务现在允许你指定正则表达式模式列表,从而过滤掉来自某些说明符的自动导入建议。例如,如果你想从 lodash 等包中排除所有“深层”导入,你可以在 Visual Studio Code 中配置以下偏好设置
json{"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^lodash/.*$"]}
或者换一种方式,你可能希望禁止从包的入口点导入
json{"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^lodash$"]}
甚至可以通过使用以下设置来避免 node: 导入
json{"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^node:"]}
请注意,如果你想指定 i 或 u 等标志,你需要用斜杠包围你的正则表达式。提供周围的斜杠时,你需要转义内部的其他斜杠。
json{"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^./lib/internal", // no escaping needed"/^.\\/lib\\/internal/", // escaping needed - note the leading and trailing slashes"/^.\\/lib\\/internal/i" // escaping needed - we needed slashes to provide the 'i' regex flag]}
在 Visual Studio Code 中,同样的设置可以通过 javascript.preferences.autoImportSpecifierExcludeRegexes 应用于 JavaScript。
欲了解更多信息,请参见此处的实现。
显著的行为更改
本节重点介绍了一组值得注意的更改,在进行任何升级时都应予以确认和理解。有时它会突出显示弃用、移除和新的限制。它也可能包含功能上有所改进但通过引入新错误而可能影响现有构建的错误修复。
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 那样行为即可。遗憾的是,如果该假设是错误的,它可能会允许不安全的导入
ts// node_modules/dep/index.d.tsexport declare function doSomething(): void;// index.ts// Okay if "dep" is a CommonJS module, but fails if// it's an ECMAScript module - even in bundlers!import dep from "dep";dep.doSomething();
实际上,这种情况并不常见。但在 Node.js 开始支持 ECMAScript 模块后的几年里,ESM 在 npm 上的份额有所增长。幸运的是,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 会收集模块格式信息,并使用它来解决所有 module 模式(除了 amd、umd 和 system)中类似上述示例的歧义。格式特定的文件扩展名(.mts 和 .cts)在任何发现它们的地方都会被尊重,并且无论 module 设置如何,都会咨询 node_modules 依赖项内的 package.json "type" 字段。以前,在技术上可能产生 CommonJS 输出到 .mjs 文件,反之亦然
ts// main.mtsexport default "oops";// $ tsc --module commonjs main.mts// main.mjsObject.defineProperty(exports, "__esModule", { value: true });exports.default = "oops";
现在,.mts 文件永远不会发出 CommonJS 输出,而 .cts 文件永远不会发出 ESM 输出。
请注意,此行为的大部分是在 TypeScript 5.5 的预发布版本中提供的(实现细节在此处),但在 5.6 中,此行为仅扩展到 node_modules 内的文件。
更多详细信息可在此处的更改中获得。
纠正计算属性上的 override 检查
此前,标记为 override 的计算属性无法正确检查基类成员是否存在。同样,如果你使用了 noImplicitOverride,如果你忘记给计算属性添加 override 修饰符,也不会收到错误。
TypeScript 5.6 现在在这两种情况下都能正确检查计算属性。
tsconst foo = Symbol("foo");const bar = Symbol("bar");class Base {[bar]() {}}class Derived extends Base {override [foo]() {}// ~~~~~// error: This member cannot have an 'override' modifier because it is not declared in the base class 'Base'.[bar]() {}// ~~~~~// error under noImplicitOverride: This member must have an 'override' modifier because it overrides a member in the base class 'Base'.}
此修复得益于 Oleksandr Tarasiuk 在此拉取请求中的贡献。