微软在2024年7月26日发布了 TypeScript 5.6 Beta 版本。
可以通过 npm 命令使用该版本:
npm install -D typescript@beta
以下是 TypeScript 5.6 中的一些新特性快速一览!
- 禁止空值和真值检查
- 迭代器辅助方法
- 严格内置迭代器检查(和
--strictBuiltinIteratorReturn) - 支持任意模块标识符
--noUncheckedSideEffectImports选项--noCheck选项- 允许
--build带有中间错误 - 编辑器中的区域优先诊断
- 搜索父级配置文件以确定项目所有权
- 显著的行为变更
lib.d.ts.tsbuildinfo始终被写入- 尊重
node_modules内的文件扩展名和package.json - 在计算属性上正确检查
override
禁止空值和真值检查
你可能写过一个正则表达式却忘了调用 .test(...):
if (/0x[0-9a-f]/) {
// 哎呀!这个代码块总是执行。
// ...
}
或者你可能不小心写成了 =>(创建了一个箭头函数)而不是 >(大于等于运算符):
if (x => 0) {
// 哎呀!这个代码块总是执行。
// ...
}
或者你可能尝试使用 ?? 作为默认值,但搞混了 ?? 和比较运算符 < 的优先级:
function isValid(value: string | number, options: any, strictness: "strict" | "loose") {
if (strictness === "loose") {
value = +value;
}
return value < options.max ?? 100;
// 哎呀!这被解析为 (value < options.max) ?? 100
}
或者你可能在复杂表达式中放错了括号:
if (
isValid(primaryValue, "strict") || isValid(secondaryValue, "strict") ||
isValid(primaryValue, "loose" || isValid(secondaryValue, "loose"))
) {
// ^^^^ 👀 我们是不是忘记了一个闭括号 ')'
}
这些例子都没有达到作者的意图,但它们都是有效的 JavaScript 代码。以前 TypeScript 也默默地接受了这些例子。
但是通过一点实验,我们发现通过标记上述可疑的例子,可以捕获许多 bug。在 TypeScript 5.6 中,编译器现在会在能够从语法上确定真值或空值检查将始终以特定方式评估时报错。
所以在上述例子中,你将开始看到错误:
if (/0x[0-9a-f]/) {
// ~~~~~~~~~~~~~
// 错误:这种表达式总是为真。
}
if (x => 0) {
// ~~~~~~
// 错误:这种表达式总是为真。
}
// ... 其他错误信息
通过启用 ESLint 的 no-constant-binary-expression 规则也可以获得类似的结果,你可以看到他们在博客文章中取得的一些成果;但是 TypeScript 执行的新检查与 ESLint 规则没有完全重叠,我们也相信将这些检查内置于 TypeScript 本身有很大的价值。
请注意,某些表达式即使始终为真或为空值仍然被允许。具体来说,true、false、0 和 1 尽管始终为真或假,仍然被允许,因为像下面的代码:
while (true) {
doStuff();
if (something()) {
break;
}
doOtherStuff();
}
if (true || inDebuggingOrDevelopmentEnvironment()) {
// ...
}
仍然是惯用和有用的,而且在迭代/调试代码时像下面的代码也是有用的。
如果你对实现或它捕获的错误类型感到好奇,请查看实现了这个特性的 pull request。
迭代器辅助方法
JavaScript 有关于 可迭代对象(我们可以通过调用 [Symbol.iterator]() 并获取一个迭代器来迭代的对象)和 迭代器(具有我们可以调用以尝试获取下一个值的 next() 方法的对象)的概念。总的来说,当你将它们放入 for/ of 循环或 [...spread] 到一个新数组时,你通常不需要考虑这些事情。
但是 TypeScript 确实用 Iterable 和 Iterator 类型(甚至是 IterableIterator 它既充当两者!)来建模这些,这些类型描述了你需要的最小成员集,以便像 for/ of 这样的结构能够在它们上面工作。
Iterable(和 IterableIterator)很好,因为它们可以在 JavaScript 的各种地方使用 - 但是很多人发现自己缺少了像 map、filter 这样的 Array 方法,出于某种原因还有 reduce。
这就是为什么最近在 ECMAScript 中提出了一个提案,将 Array 上的许多方法(和更多)添加到 JavaScript 生成的大多数 IterableIterator 上。
例如,现在每个生成器都会生成一个也具有 map 方法和 take 方法的对象。
function* positiveIntegers() {
let i = 1;
while (true) {
yield i;
i++;
}
}
const evenNumbers = positiveIntegers().map(x => x * 2);
// 输出:
// 2
// 4
// 6
// 8
// 10
for (const value of evenNumbers.take(5)) {
console.log(value);
}
对于 Map 和 Set 上的 keys()、values() 和 entries() 方法也是如此。
function invertKeysAndValues<K, V>(map: Map<K, V>): Map<V, K> {
return new Map(
map.entries().map(([k, v]) => [v, k])
);
}
你也可以扩展新的 Iterator 对象:
/**
* 提供一个无尽的 `0` 流。
*/
class Zeroes extends Iterator<number> {
next() {
return { value: 0, done: false } as const;
}
}
const zeroes = new Zeroes();
// 转换为一个无尽的 `1` 流。
const ones = zeroes.map(x => x + 1);
你可以使用 Iterator.from 将任何现有的 Iterable 或 Iterator 适配到这种新类型:
Iterator.from(...).filter(someFunction);
现在,我们必须谈谈命名。
我们之前提到 TypeScript 有 Iterable 和 Iterator 的类型;
然而,正如我们提到的,这些表现得有点像“协议”,以确保某些操作能够工作。
这意味着并非在 TypeScript 中声明为 Iterable 或 Iterator 的每个值都会具有我们上面提到的那些方法。
但仍有一个名为 Iterator 的新运行时值。
你可以在 JavaScript 中引用 Iterator 以及 Iterator.prototype 作为实际的值。
这有点尴尬,因为 TypeScript 已经定义了自己名为 Iterator 的东西,纯粹用于类型检查。
由于这个不幸的名称冲突,TypeScript 需要引入一个单独的类型来描述这些本机/内置的可迭代迭代器。
TypeScript 5.6 引入了一个名为 BuiltinIterator 的新类型。
它定义如下:
interface BuiltinIterator<T, TReturn = any, TNext = any> {
// ...
}
许多内置的集合和方法都会产生这种类型,lib.d.ts 中的核心 JavaScript 和 DOM 类型以及 @types/node 也已经更新为使用这个新类型。
类似地,还有一个 BuiltinAsyncIterator 类型用于一致性。
AsyncIterator 尚未作为 JavaScript 中的运行时值存在,为 AsyncIterable 带来相同的方法,但它是一个积极的提案,这个新类型为此做好了准备。
严格内置迭代器检查(和 --strictBuiltinIteratorReturn)
当你调用 Iterator<T, TReturn> 的 next() 方法时,它会返回一个带有 value 和 done 属性的对象。
这通过 IteratorResult 类型来建模。
type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;
interface IteratorYieldResult<TYield> {
done?: false;
value: TYield;
}
interface IteratorReturnResult<TReturn> {
done: true;
value: TReturn;
}
这里的命名受到生成器函数的工作方式的启发。
生成器函数可以 yield 值,然后 return 最终值 - 但是两者之间的类型可以无关。
function* 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 }
有了新的 BuiltinIterator 类型,我们发现在允许安全实现 BuiltinIterator 时存在一些困难。
同时,IteratorResult 在 TReturn 是 any(默认值!)的情况下存在长期的不安全性。
例如,假设我们有一个 IteratorResult<string, any>。
如果我们最终获取这个类型的 value,我们将得到 string | any,它只是 any。
function* uppercase(iter: Iterator<string, any>) {
while (true) {
const { value, done } = iter.next();
yield value.toUppercase(); // 哎呀!忘记先检查 `done` 并且拼写错误 `toUpperCase`
if (done) {
return;
}
}
}
在不引入大量中断的情况下修复今天的每个 Iterator 是困难的,但我们至少可以通过使用大多数 BuiltinIterator 来解决这个问题。
TypeScript 5.6 引入了一个新的内在类型,名为 BuiltinIteratorReturn 和一个新的 --strict 模式标志 --strictBuiltinIteratorReturn。
每当 BuiltinIterator 在像 lib.d.ts 这样的地方使用时,它们总是用 BuiltinIteratorReturn 类型为 TReturn 编写:
interface Map<K, V> {
// ...
/**
* 返回一个可迭代的键值对,用于映射中的每个条目。
*/
entries(): BuiltinIterator<[K, V], BuiltinIteratorReturn>;
/**
* 返回映射中的键的可迭代对象
*/
keys(): BuiltinIterator<K, BuiltinIteratorReturn>;
/**
* 返回映射中的值的可迭代对象
*/
values(): BuiltinIterator<V, BuiltinIteratorReturn>;
}
默认情况下,BuiltinIteratorReturn 是 any,但当启用 --strictBuiltinIteratorReturn(可能通过 --strict)时,它是 undefined。
在这个新模式下,如果我们使用 BuiltinIteratorReturn,我们现在的示例将正确地报错:
function* uppercase(iter: Iterator<string, BuiltinIteratorReturn>) {
while (true) {
const { value, done } = iter.next();
yield value.toUppercase();
// ~~~~~ ~~~~~~~~~~~~
// 错误!┃ ┃
// ┃ ┗━ 'value' 的类型可能为 'string'。你是指 'toUpperCase' 吗?
// ┃
// ┗━ 'value' 可能是 'undefined'。
if (done) {
return;
}
}
}
你可以在这里阅读更多关于这个特性的信息。
支持任意模块标识符
JavaScript 允许模块以字符串字面量的形式导出无效标识符名称的绑定:
const banana = "🍌";
export { banana as "🍌" };
同样,它允许模块使用这些任意名称进行导入,并将它们绑定到有效的标识符:
import { "🍌" as banana } from "./foo"
/**
* 嗷嗷嗷
*/
function eat(food: string) {
console.log("Eating", food);
};
eat(banana);
这看起来像是一个可爱的聚会技巧(如果你和我们在聚会上一样有趣),但它在与其他语言的互操作性方面也有其用途(通常通过 JavaScript/WebAssembly 边界),因为其他语言可能有不同规则来定义有效标识符。
它对于像 esbuild 这样的代码生成工具也很有用,它具有 inject 特性。
TypeScript 5.6 现在允许你在代码中使用这些任意模块标识符!
我们要感谢 Evan Wallace 对 TypeScript 的这一变更做出的贡献!
--noUncheckedSideEffectImports 选项
在 JavaScript 中,可以 import 一个模块而实际上并不从它那里导入任何值。
import "some-module";
这些导入通常被称为 副作用导入,因为它们可以提供的唯一有用行为是通过执行一些副作用(比如注册一个全局变量,或者向原型添加一个 polyfill)。
在 TypeScript 中,这种语法有一个相当奇怪的怪癖:如果 import 可以解析到一个有效的源文件,那么 TypeScript 会加载并检查该文件。
另一方面,如果没有找到源文件,TypeScript 会默默地忽略 import!
这令人惊讶的行为,但它部分源于 JavaScript 生态系统中的模式建模。
例如,这种语法也与打包器中的特殊加载器一起使用,用于加载 CSS 或其他资产。
你的打包器可能配置成这样,你可以通过写入以下内容来包含特定的 .css 文件:
import "./button-component.css";
export function Button() {
// ...
}
尽管如此,这掩盖了副作用导入上的潜在拼写错误。
这就是为什么 TypeScript 5.6 引入了一个新的编译器选项 --noUncheckedSideEffectImports,以捕获这些情况。
当启用 --noUncheckedSideEffectImports 时,如果 TypeScript 找不到副作用导入的源文件,现在会报错。
import "oops-this-module-does-not-exist";
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 错误:找不到模块 'oops-this-module-does-not-exist' 或其相应的类型声明。
当启用此选项时,一些工作代码现在可能会收到错误,比如上面的 CSS 示例。
要解决这个问题,那些想要为资源编写副作用 import 的用户可能更适合编写所谓的 环境模块声明,使用通配符说明符。
它会放在一个全局文件中,看起来像下面这样:
// ./src/globals.d.ts
// 将所有 CSS 文件识别为模块导入。
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 内部加速所做的工作,以及使其在命令行和 TypeScript 的项目引用概念上公开可用的相关实现。
TypeScript 的项目引用概念允许你将代码库组织成多个项目并在它们之间创建依赖关系。
运行 TypeScript 编译器的 --build 模式(或简称 tsc -b)是实际进行跨项目构建并确定哪些项目和文件需要编译的内置方式。
以前,使用 --build 模式会假定 --noEmitOnError 并立即在遇到任何错误时停止构建。
这意味着如果项目有错误,它对于其依赖项来说不一定是一个连贯的状态。
从理论上讲,这是一种非常合理的方法 - 如果一个项目有错误,它对于其依赖项来说不一定是一个连贯的状态。
实际上,这种僵化使得升级变得痛苦。
例如,如果 projectB 依赖于 projectA,那么更熟悉 projectB 的人不能主动升级他们的代码,直到他们的依赖项升级。
他们被首先升级 projectA 的工作所阻碍。
截至 TypeScript 5.6,--build 模式将继续构建项目,即使在依赖项中存在中间错误。
面对中间错误,它们将被一致地报告,并且将在尽最大努力的基础上生成输出文件;
然而,构建将在指定项目上继续完成。
注意,为了实现这一点,TypeScript 现在在 --build 调用中的任何项目上始终发出 .tsbuildinfo 文件(即使没有指定 --incremental/ --composite)。
这是为了跟踪 --build 的调用状态以及将来需要执行的工作。
你可以在这里阅读有关此更改的更多信息。
编辑器中的区域优先诊断
当 TypeScript 的语言服务被请求一个文件的 诊断(例如错误、建议和弃用)时,通常需要检查 整个文件。
大多数情况下这很好,但在非常大的文件中可能会导致延迟。
这可能令人沮丧,因为修正一个错别字应该是一个快速的操作,但在足够大的文件中可能需要 几秒钟。
为了解决这个问题,TypeScript 5.6 引入了一个名为 区域优先诊断 或 区域优先检查 的新特性。
而不是只请求一组文件的诊断,编辑器现在也可以提供一个给定文件的相关区域 - 意图通常是文件当前对用户可见的区域。
然后 TypeScript 语言服务器可以选择提供两组诊断:一个用于区域,一个用于整个文件。
这使得在大文件中的编辑感觉 快得多,所以你不必等待那么久才能让那些红色的波浪线消失。
对于一些具体数字,在我们在 TypeScript 自己的 checker.ts 上的测试中,一个完整的语义诊断响应需要 3330 毫秒。
相比之下,第一个基于区域的诊断响应只需要 143 毫秒!
虽然剩余的全文件响应大约需要 3200 毫秒,但这对快速编辑可以产生巨大的差异。
这个特性还包括相当多的工作,也要使诊断在你的体验中更一致地报告。
由于我们的类型检查器利用缓存来避免工作,通常在相同类型之间的后续检查可能会有不同的(通常更短的)错误消息。
技术上,懒惰的无序检查可能会导致诊断在编辑器中的两个位置不同地报告 - 即使在这个特性之前 - 但我们不想加剧这个问题。
通过最近的工作,我们已经解决了很多错误不一致性。
目前,这个功能在 Visual Studio Code 中适用于 TypeScript 5.6 及更高版本。
有关更详细的信息,请查看这里实现和编写。
搜索父级配置文件以确定项目所有权
当一个 TypeScript 文件在编辑器中使用 TSServer(如 Visual Studio 或 VS Code)加载时,编辑器将尝试找到 "拥有"该文件的相关 tsconfig.json 文件。
为此,它从正在编辑的文件的目录树向上走,寻找任何名为 tsconfig.json 的文件。
以前,这个搜索会在找到第一个 tsconfig.json 文件时停止。
然而,想象一下像下面的项目结构:
project/
├── src/
│ ├── foo.ts
│ ├── foo-test.ts
│ ├── tsconfig.json
│ └── tsconfig.test.json
└── tsconfig.json
这里的想法是 src/tsconfig.json 是项目的主要配置文件,而 src/tsconfig.test.json 是运行测试的配置文件。
// src/tsconfig.json
{
"compilerOptions": {
{ "outDir": "../dist"}
},
"exclude": ["**/*.test.ts"]
}
// src/tsconfig.test.json
{
"compilerOptions": {
{ "outDir": "../dist/test"}
},
"include": ["**/*.test.ts"],
"references": [
{ "path": "./tsconfig.json" }
]
}
// tsconfig.json
{
// 这是一个 "解决方案风格" 或 "多项目根" tsconfig。
// 它不指定任何文件,只是引用了所有实际的项目。
"files": [],
"references": [
{ "path": "./src/tsconfig.json" },
{ "path": "./src/tsconfig.test.json" },
]
}
这里的问题是,当编辑 foo-test.ts 时,编辑器会找到 project/src/tsconfig.json 作为 "拥有" 配置文件 - 但那不是我们想要的!
如果在此停止遍历,那可能不是所期望的。
以前避免这一点的唯一方法是将 src/tsconfig.json 重命名为 src/tsconfig.src.json,然后所有文件都会命中顶层的 tsconfig.json,它引用了每个可能的项目。
project/
├── src/
│ ├── foo.ts
│ ├── foo-test.ts
│ ├── tsconfig.src.json
│ └── tsconfig.test.json
└── tsconfig.json
而不是强迫开发人员这样做,TypeScript 5.6 现在继续向上遍历目录树,以找到编辑器场景中其他适当的 tsconfig.json 文件。
这可以提供更多的灵活性,用于项目的组织方式以及配置文件的结构方式。
你可以在 GitHub 上获取有关此实现的更多详细信息。
值得注意的行为变更
本节突出显示了一组值得注意的变更,应在任何升级过程中予以注意和理解。
有时它将突出显示弃用、删除和新限制。
它还可能包含功能改进的功能修复,但这些也可能通过引入新错误而影响现有构建。
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。
不幸的是,如果这个假设是错误的,它可能会允许不安全的导入:
// node_modules/dep/index.d.ts
export declare function doSomething(): void;
// index.ts
// 如果 "dep" 是 CommonJS 模块,则没问题,但如果
// 它是一个 ECMAScript 模块 - 即使在打包器中也会失败!
import dep from "dep";
dep.doSomething();
实际上,这种情况并不经常发生。
但是自从 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 遇到 node_modules 目录下的文件时,TypeScript 5.6 读取并存储由文件扩展名和 package.json "type" 编码的模块格式信息 在所有 module 模式下,并使用它来解决所有模式中的歧义(除了 amd、umd 和 system)。
尊重此格式信息的次要效果是,格式特定的 TypeScript 文件扩展名(.mts 和 .cts)或项目中明确设置的 package.json "type" 将 覆盖 如果它设置为 commonjs 或 es2015 通过 esnext 的 --module 选项。
以前,技术上可能将 CommonJS 输出到 .mjs 文件或反之亦然:
// main.mts
export default "oops";
// $ tsc --module commonjs main.mts
// main.mjs
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = "oops";
现在,.mts 文件(或 package.json 中 "type": "module" 范围内的 .ts 文件)永远不会发出 CommonJS 输出,.cts 文件(或 package.json 中 "type": "commonjs" 范围内的 .ts 文件)永远不会发出 ESM 输出。
注意,这种行为在 TypeScript 5.5 的预发布版本中已经提供(实现细节在这里),但在 5.6 中,这种行为仅扩展到 node_modules 内的文件。
有关此更改的更多详细信息,请参见这里。
在计算属性上正确检查 override
以前,标记为 override 的计算属性没有正确检查基类成员的存在。
类似地,如果你使用了 noImplicitOverride,在你 忘记 在计算属性上添加 override 修饰符时,你也不会得到错误。
TypeScript 5.6 现在在这两种情况下都正确检查计算属性。
const foo = Symbol("foo");
const bar = Symbol("bar");
class Base {
[bar]() {}
}
class Derived extends Base {
override [foo]() {}
// ~~~~~~
// 错误:此成员不能有 'override' 修饰符,因为它在基类 'Base' 中未声明。
[bar]() {}
// ~~~~~~
// 错误在 noImplicitOverride 下:此成员必须有 'override' 修饰符,因为它覆盖了基类 'Base' 中的成员。
}