TypeScript 5.0

装饰器

装饰器是一项即将推出的 ECMAScript 特性,它允许我们以可重用的方式自定义类及其成员。

让我们看下面的代码

ts
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ray");
p.greet();

greet 这里非常简单,但让我们想象它要复杂得多——比如它包含一些异步逻辑、递归、有副作用等。无论你想象的是什么样的“泥球”代码,假设你添加了一些 console.log 调用来辅助调试 greet

ts
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
greet() {
console.log("LOG: Entering method.");
console.log(`Hello, my name is ${this.name}.`);
console.log("LOG: Exiting method.")
}
}

这种模式很常见。如果能有一种方法让我们对每个方法都这样做,那该多好啊!

这就是装饰器的用武之地。我们可以编写一个名为 loggedMethod 的函数,如下所示

ts
function loggedMethod(originalMethod: any, _context: any) {
function replacementMethod(this: any, ...args: any[]) {
console.log("LOG: Entering method.")
const result = originalMethod.call(this, ...args);
console.log("LOG: Exiting method.")
return result;
}
return replacementMethod;
}

“这些 any 是怎么回事?这是 anyScript 吗!?”

请耐心一点——我们现在保持简单,以便专注于这个函数正在做什么。请注意,loggedMethod 接收原始方法 (originalMethod) 并返回一个函数,该函数:

  1. 记录一条“Entering…”(进入)消息
  2. this 及其所有参数传递给原始方法
  3. 记录一条“Exiting…”(退出)消息,并且
  4. 返回原始方法的返回值。

现在我们可以使用 loggedMethod装饰方法 greet

ts
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ray");
p.greet();
// Output:
//
// LOG: Entering method.
// Hello, my name is Ray.
// LOG: Exiting method.

我们刚刚在 greet 上方使用了 loggedMethod 作为装饰器——注意我们将其写为 @loggedMethod。当我们这样做时,它会被调用,并传入方法目标和一个上下文对象。因为 loggedMethod 返回了一个新函数,所以该函数替换了 greet 的原始定义。

我们之前没有提到,但 loggedMethod 定义了第二个参数。它被称为“上下文对象”,它包含了一些关于被装饰方法如何声明的有用信息——比如它是否是一个 #private(私有)成员、static(静态)成员,或者方法的名称是什么。让我们重写 loggedMethod 来利用这一点并打印出被装饰方法的名称。

ts
function loggedMethod(originalMethod: any, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
function replacementMethod(this: any, ...args: any[]) {
console.log(`LOG: Entering method '${methodName}'.`)
const result = originalMethod.call(this, ...args);
console.log(`LOG: Exiting method '${methodName}'.`)
return result;
}
return replacementMethod;
}

我们现在正在使用上下文参数——这是 loggedMethod 中第一个类型比 anyany[] 更严格的内容。TypeScript 提供了一个名为 ClassMethodDecoratorContext 的类型,用于建模方法装饰器所接收的上下文对象。

除了元数据之外,方法的上下文对象还有一个非常有用的函数叫 addInitializer。它是一种在构造函数开始时(如果我们处理的是 static,则是类本身初始化时)挂载逻辑的方法。

举个例子——在 JavaScript 中,通常会编写如下模式

ts
class Person {
name: string;
constructor(name: string) {
this.name = name;
this.greet = this.greet.bind(this);
}
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}

或者,greet 可能被声明为一个初始化为箭头函数的属性。

ts
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
greet = () => {
console.log(`Hello, my name is ${this.name}.`);
};
}

这段代码的编写是为了确保如果 greet 作为独立函数调用或作为回调传递时,this 不会重新绑定。

ts
const greet = new Person("Ray").greet;
// We don't want this to fail!
greet();

我们可以编写一个装饰器,利用 addInitializer 为我们自动在构造函数中调用 bind

ts
function bound(originalMethod: any, context: ClassMethodDecoratorContext) {
const methodName = context.name;
if (context.private) {
throw new Error(`'bound' cannot decorate private properties like ${methodName as string}.`);
}
context.addInitializer(function () {
this[methodName] = this[methodName].bind(this);
});
}

bound 没有返回任何内容——所以当它装饰一个方法时,它不会改变原始方法。相反,它会在任何其他字段初始化之前添加逻辑。

ts
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@bound
@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ray");
const greet = p.greet;
// Works!
greet();

注意我们堆叠了两个装饰器——@bound@loggedMethod。这些装饰器以“相反的顺序”运行。也就是说,@loggedMethod 装饰原始方法 greet,而 @bound 装饰 @loggedMethod 的结果。在这个例子中,顺序无关紧要——但如果你的装饰器有副作用或预期特定的顺序,那么顺序就很重要了。

同样值得注意的是,如果你在风格上更喜欢,可以将这些装饰器放在同一行。

ts
@bound @loggedMethod greet() {
console.log(`Hello, my name is ${this.name}.`);
}

可能不明显的一点是,我们甚至可以创建返回装饰器函数的函数。这使得自定义最终装饰器成为可能。如果我们愿意,可以让 loggedMethod 返回一个装饰器,并自定义它记录消息的方式。

ts
function loggedMethod(headMessage = "LOG:") {
return function actualDecorator(originalMethod: any, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
function replacementMethod(this: any, ...args: any[]) {
console.log(`${headMessage} Entering method '${methodName}'.`)
const result = originalMethod.call(this, ...args);
console.log(`${headMessage} Exiting method '${methodName}'.`)
return result;
}
return replacementMethod;
}
}

如果我们这样做,就必须在将其用作装饰器之前调用 loggedMethod。然后我们可以传入任何字符串作为记录到控制台的消息前缀。

ts
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod("⚠️")
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ray");
p.greet();
// Output:
//
// ⚠️ Entering method 'greet'.
// Hello, my name is Ray.
// ⚠️ Exiting method 'greet'.

装饰器不仅可以用在方法上!它们还可以用在属性/字段、getter、setter 和自动访问器 (auto-accessors) 上。甚至类本身也可以被装饰,用于子类化和注册等用途。

要深入了解装饰器,可以阅读 Axel Rauschmayer 的详尽总结

关于所涉及更改的更多信息,你可以 查看原始拉取请求

与实验性旧版装饰器的差异

如果你使用 TypeScript 一段时间了,你可能知道它多年来一直支持“实验性”装饰器。虽然这些实验性装饰器非常有用,但它们模拟的是装饰器提案的一个非常老的版本,并且总是需要一个名为 --experimentalDecorators 的编译器标志。任何在没有此标志的情况下在 TypeScript 中使用装饰器的尝试,过去都会触发错误消息。

--experimentalDecorators 在可预见的未来将继续存在;然而,如果没有该标志,装饰器现在将成为所有新代码的有效语法。除了 --experimentalDecorators 之外,它们的类型检查和编译输出 (emit) 方式将会不同。类型检查规则和编译输出差异很大,因此虽然装饰器可以编写为同时支持新旧装饰器行为,但现有的装饰器函数不太可能做到这一点。

这个新的装饰器提案与 --emitDecoratorMetadata 不兼容,并且不允许装饰参数。未来的 ECMAScript 提案或许能够填补这一空白。

最后一点:除了允许装饰器放置在 export 关键字之前,装饰器提案现在提供了将装饰器放置在 exportexport default 之后的选项。唯一的例外是禁止混合使用这两种样式。

js
// ✅ allowed
@register export default class Foo {
// ...
}
// ✅ also allowed
export default @register class Bar {
// ...
}
// ❌ error - before *and* after is not allowed
@before export @after class Bar {
// ...
}

编写类型良好的装饰器

上面示例中的 loggedMethodbound 装饰器是有意简化,并省略了许多关于类型的细节。

编写装饰器的类型可能相当复杂。例如,上面 loggedMethod 的类型良好版本可能看起来像这样

ts
function loggedMethod<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
const methodName = String(context.name);
function replacementMethod(this: This, ...args: Args): Return {
console.log(`LOG: Entering method '${methodName}'.`)
const result = target.call(this, ...args);
console.log(`LOG: Exiting method '${methodName}'.`)
return result;
}
return replacementMethod;
}

我们必须分别使用类型参数 ThisArgsReturn 来建模原始方法的 this 类型、参数和返回类型。

你的装饰器函数定义得有多复杂,取决于你想要保证什么。请记住,你的装饰器被使用的次数远多于编写的次数,因此类型良好的版本通常是首选——但这显然与可读性之间存在权衡,所以尽量保持简单。

关于编写装饰器的更多文档将在未来提供——但这篇文章应该已经包含了关于装饰器机制的详细信息。

const 类型参数

当推断对象的类型时,TypeScript 通常会选择一个通用的类型。例如,在这种情况下,names 的推断类型是 string[]

ts
type HasNames = { names: readonly string[] };
function getNamesExactly<T extends HasNames>(arg: T): T["names"] {
return arg.names;
}
// Inferred type: string[]
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"]});

通常这样做的目的是为了以后能够进行修改。

然而,根据 getNamesExactly 具体做了什么以及它打算如何使用,通常需要一个更具体的类型。

到目前为止,API 作者通常必须建议在某些地方添加 as const 以达到所需的推断

ts
// The type we wanted:
// readonly ["Alice", "Bob", "Eve"]
// The type we got:
// string[]
const names1 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]});
// Correctly gets what we wanted:
// readonly ["Alice", "Bob", "Eve"]
const names2 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]} as const);

这可能很麻烦且容易被遗忘。在 TypeScript 5.0 中,你现在可以向类型参数声明添加 const 修饰符,从而使 const 风格的推断成为默认值

ts
type HasNames = { names: readonly string[] };
function getNamesExactly<const T extends HasNames>(arg: T): T["names"] {
// ^^^^^
return arg.names;
}
// Inferred type: readonly ["Alice", "Bob", "Eve"]
// Note: Didn't need to write 'as const' here
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });

请注意,const 修饰符不会拒绝可变值,也不需要不可变约束。使用可变类型约束可能会产生令人惊讶的结果。例如

ts
declare function fnBad<const T extends string[]>(args: T): void;
// 'T' is still 'string[]' since 'readonly ["a", "b", "c"]' is not assignable to 'string[]'
fnBad(["a", "b" ,"c"]);

这里,T 的推断候选是 readonly ["a", "b", "c"],而 readonly 数组不能用于需要可变数组的地方。在这种情况下,推断会回退到约束,数组被视为 string[],调用仍然可以成功进行。

该函数的更好定义应该使用 readonly string[]

ts
declare function fnGood<const T extends readonly string[]>(args: T): void;
// T is readonly ["a", "b", "c"]
fnGood(["a", "b" ,"c"]);

同样,请记住 const 修饰符仅影响在调用中编写的对象、数组和原始表达式的推断,因此对于那些不会(或无法)使用 as const 修改的参数,行为不会有任何改变

ts
declare function fnGood<const T extends readonly string[]>(args: T): void;
const arr = ["a", "b" ,"c"];
// 'T' is still 'string[]'-- the 'const' modifier has no effect here
fnGood(arr);

查看拉取请求以及(第一个第二个)激励问题以获取更多详细信息。

支持在 extends 中使用多个配置文件

在管理多个项目时,拥有一个其他 tsconfig.json 文件可以从中继承的“基础”配置文件会很有帮助。这就是 TypeScript 支持 extends 字段以从 compilerOptions 复制字段的原因。

jsonc
// packages/front-end/src/tsconfig.json
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../lib",
// ...
}
}

但是,有些场景下你可能希望从多个配置文件中继承。例如,想象一下使用发布到 npm 的 TypeScript 基础配置文件。如果你希望所有项目也使用 npm 上 @tsconfig/strictest 包中的选项,那么有一个简单的解决方案:让 tsconfig.base.json 继承 @tsconfig/strictest

jsonc
// tsconfig.base.json
{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
// ...
}
}

这在一定程度上有效。如果你的任何项目想使用 @tsconfig/strictest,它们要么必须手动禁用这些选项,要么创建一个不继承 @tsconfig/strictesttsconfig.base.json 的单独版本。

为了提供更大的灵活性,TypeScript 5.0 现在允许 extends 字段接收多个条目。例如,在这个配置文件中

jsonc
{
"extends": ["a", "b", "c"],
"compilerOptions": {
// ...
}
}

这样写有点类似于直接继承 c,其中 c 继承 b,而 b 继承 a。如果任何字段“冲突”,则后面的条目获胜。

所以在下面的示例中,最终的 tsconfig.json 中同时启用了 strictNullChecksnoImplicitAny

jsonc
// tsconfig1.json
{
"compilerOptions": {
"strictNullChecks": true
}
}
// tsconfig2.json
{
"compilerOptions": {
"noImplicitAny": true
}
}
// tsconfig.json
{
"extends": ["./tsconfig1.json", "./tsconfig2.json"],
"files": ["./index.ts"]
}

作为另一个例子,我们可以用以下方式重写我们最初的示例。

jsonc
// packages/front-end/src/tsconfig.json
{
"extends": ["@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json"],
"compilerOptions": {
"outDir": "../lib",
// ...
}
}

有关更多详细信息,请阅读原始拉取请求

所有 enum(枚举)都是联合枚举

当 TypeScript 最初引入枚举时,它们只不过是一组具有相同类型的数值常量。

ts
enum E {
Foo = 10,
Bar = 20,
}

E.FooE.Bar 的唯一特殊之处在于它们可以赋值给任何期望 E 类型的地方。除此之外,它们基本上只是 number(数字)。

ts
function takeValue(e: E) {}
takeValue(E.Foo); // works
takeValue(123); // error!

直到 TypeScript 2.0 引入枚举字面量类型,枚举才变得稍微特别一些。枚举字面量类型给了每个枚举成员它自己的类型,并将枚举本身变成了每个成员类型的联合。它们还允许我们仅引用枚举类型的一个子集,并缩减这些类型。

ts
// Color is like a union of Red | Orange | Yellow | Green | Blue | Violet
enum Color {
Red, Orange, Yellow, Green, Blue, /* Indigo, */ Violet
}
// Each enum member has its own type that we can refer to!
type PrimaryColor = Color.Red | Color.Green | Color.Blue;
function isPrimaryColor(c: Color): c is PrimaryColor {
// Narrowing literal types can catch bugs.
// TypeScript will error here because
// we'll end up comparing 'Color.Red' to 'Color.Green'.
// We meant to use ||, but accidentally wrote &&.
return c === Color.Red && c === Color.Green && c === Color.Blue;
}

给每个枚举成员赋予自己类型的一个问题是,这些类型在某种程度上与成员的实际值相关联。在某些情况下,不可能计算出该值——例如,枚举成员可以通过函数调用来初始化。

ts
enum E {
Blah = Math.random()
}

每当 TypeScript 遇到这些问题时,它都会悄悄地退回到旧的枚举策略。这意味着放弃了联合和字面量类型的所有优势。

TypeScript 5.0 通过为每个计算成员创建唯一类型,成功地使所有枚举都成为联合枚举。这意味着所有枚举现在都可以进行类型缩减,并且它们的成员也可以作为类型被引用。

有关此更改的更多详细信息,你可以在 GitHub 上阅读具体内容

--moduleResolution bundler

TypeScript 4.7 为其 --module--moduleResolution 设置引入了 node16nodenext 选项。这些选项的目的是更好地模拟 Node.js 中 ECMAScript 模块的精确查找规则;然而,这种模式有许多其他工具并不真正强制执行的限制。

例如,在 Node.js 的 ECMAScript 模块中,任何相对导入都需要包含文件扩展名。

js
// entry.mjs
import * as utils from "./utils"; // ❌ wrong - we need to include the file extension.
import * as utils from "./utils.mjs"; // ✅ works

Node.js 和浏览器这样做有一定的原因——它使文件查找更快,并且更适合简单的文件服务器。但对于许多使用 bundler(打包器)等工具的开发者来说,node16/nodenext 设置很麻烦,因为 bundler 没有大多数这些限制。在某些方面,node 解析模式对于使用 bundler 的人来说更好。

但在某些方面,原始的 node 解析模式已经过时了。大多数现代 bundler 使用 Node.js 中 ECMAScript 模块和 CommonJS 查找规则的融合。例如,无扩展名导入就像在 CommonJS 中一样工作得很好,但在浏览包的 export 条件时,它们会像在 ECMAScript 文件中一样更喜欢 import 条件。

为了模拟 bundler 的工作方式,TypeScript 现在引入了一种新策略:--moduleResolution bundler

jsonc
{
"compilerOptions": {
"target": "esnext",
"moduleResolution": "bundler"
}
}

如果你使用的是 Vite、esbuild、swc、Webpack、Parcel 等实现混合查找策略的现代 bundler,那么新的 bundler 选项应该非常适合你。

另一方面,如果你正在编写一个打算发布到 npm 的库,使用 bundler 选项可能会隐藏对使用 bundler 的用户可能产生的问题。因此在这些情况下,使用 node16nodenext 解析选项可能是一个更好的途径。

要阅读关于 --moduleResolution bundler 的更多信息,请查看实现拉取请求

解析自定义标志

JavaScript 工具现在可能模拟“混合”解析规则,就像我们在上面描述的 bundler 模式中那样。因为工具在支持方面可能略有不同,TypeScript 5.0 提供了启用或禁用一些可能与你的配置工作或不工作的功能的方法。

allowImportingTsExtensions

--allowImportingTsExtensions 允许 TypeScript 文件通过 TypeScript 特定的扩展名(如 .ts.mts.tsx)相互导入。

此标志仅在启用了 --noEmit--emitDeclarationOnly 时才允许使用,因为这些导入路径在 JavaScript 输出文件中无法在运行时解析。这里的期望是你的解析器(例如你的 bundler、运行时环境或其他工具)将使这些 .ts 文件之间的导入生效。

resolvePackageJsonExports

--resolvePackageJsonExports 强制 TypeScript 在读取 node_modules 中的包时查阅 package.json 文件的 exports 字段

此选项在 --moduleResolutionnode16nodenextbundler 选项下默认开启。

resolvePackageJsonImports

--resolvePackageJsonImports 强制 TypeScript 在执行以 # 开头的查找时,如果该文件所在的祖先目录包含 package.json,则查阅 package.json 文件的 imports 字段

此选项在 --moduleResolutionnode16nodenextbundler 选项下默认开启。

allowArbitraryExtensions

在 TypeScript 5.0 中,当导入路径以已知的 JavaScript 或 TypeScript 文件扩展名以外的扩展名结尾时,编译器将查找该路径的声明文件,形式为 {文件基本名}.d.{扩展名}.ts。例如,如果你在 bundler 项目中使用 CSS 加载器,你可能想为这些样式表编写(或生成)声明文件

css
/* app.css */
.cookie-banner {
display: none;
}
ts
// app.d.css.ts
declare const css: {
cookieBanner: string;
};
export default css;
ts
// App.tsx
import styles from "./app.css";
styles.cookieBanner; // string

默认情况下,此导入会引发错误,让你知道 TypeScript 不理解此文件类型,并且你的运行时可能不支持导入它。但如果你已经配置了运行时或 bundler 来处理它,你可以使用新的 --allowArbitraryExtensions 编译器选项来抑制此错误。

请注意,在历史上,类似的效果通常可以通过添加名为 app.css.d.ts 而不是 app.d.css.ts 的声明文件来实现——然而,这只是通过 Node 用于 CommonJS 的 require 解析规则来起作用的。严格来说,前者被解释为名为 app.css.js 的 JavaScript 文件的声明文件。因为相对文件导入在 Node 的 ESM 支持中需要包含扩展名,TypeScript 在 --moduleResolution node16nodenext 下的 ESM 文件中会对我们的示例报错。

有关更多信息,请阅读此功能提案及其相应的拉取请求

customConditions

--customConditions 接收一组额外的条件列表,这些条件在 TypeScript 从 package.jsonexportsimports 字段解析时应该成功。这些条件会被添加到解析器默认使用的任何现有条件中。

例如,当此字段在 tsconfig.json 中设置如下时

jsonc
{
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"customConditions": ["my-condition"]
}
}

每当引用 package.json 中的 exportsimports 字段时,TypeScript 都会考虑名为 my-condition 的条件。

因此,当从具有以下 package.json 的包中导入时

jsonc
{
// ...
"exports": {
".": {
"my-condition": "./foo.mjs",
"node": "./bar.mjs",
"import": "./baz.mjs",
"require": "./biz.mjs"
}
}
}

TypeScript 将尝试查找与 foo.mjs 对应的文件。

此字段仅在 --moduleResolutionnode16nodenextbundler 选项下有效

--verbatimModuleSyntax

默认情况下,TypeScript 执行所谓的导入省略 (import elision)。基本上,如果你写类似

ts
import { Car } from "./car";
export function drive(car: Car) {
// ...
}

TypeScript 检测到你仅将导入用于类型,并完全删除了该导入。你的输出 JavaScript 可能看起来像这样

js
export function drive(car) {
// ...
}

大多数情况下这很好,因为如果 Car 不是从 ./car 导出的值,我们将得到运行时错误。

但它确实增加了一层复杂性,以处理某些极端情况。例如,请注意没有像 import "./car"; 这样的语句——导入被完全删除了。这实际上对有或没有副作用的模块产生了影响。

TypeScript 的 JavaScript 编译策略还有另外几层复杂性——导入省略并不总是仅仅由导入的使用方式驱动——它通常也会参考值的声明方式。因此,不清楚类似以下的代码

ts
export { Car } from "./car";

是应该被保留还是被删除。如果 Car 是用 class 之类的东西声明的,那么它可以保留在生成的 JavaScript 文件中。但如果 Car 仅声明为 type 别名或 interface,那么 JavaScript 文件根本不应该导出 Car

虽然 TypeScript 能够基于跨文件的信息做出这些编译决策,但并非每个编译器都能做到。

导入和导出上的 type 修饰符在一定程度上有所帮助。我们可以通过使用 type 修饰符明确导入或导出是否仅用于类型分析,以及是否可以完全从 JavaScript 文件中删除。

ts
// This statement can be dropped entirely in JS output
import type * as car from "./car";
// The named import/export 'Car' can be dropped in JS output
import { type Car } from "./car";
export { type Car } from "./car";

type 修饰符本身并不是特别有用——默认情况下,模块省略仍会删除导入,并且没有任何东西强制你区分 type 导入/导出和普通导入/导出。因此,TypeScript 提供了 --importsNotUsedAsValues 标志来确保你使用 type 修饰符,--preserveValueImports 来防止某些模块省略行为,以及 --isolatedModules 来确保你的 TypeScript 代码可以在不同的编译器中工作。不幸的是,理解这三个标志的细节很困难,而且仍然存在一些具有意外行为的极端情况。

TypeScript 5.0 引入了一个名为 --verbatimModuleSyntax 的新选项来简化这种情况。规则简单得多——任何没有 type 修饰符的导入或导出都会被保留。任何使用 type 修饰符的东西都会被完全删除。

ts
// Erased away entirely.
import type { A } from "a";
// Rewritten to 'import { b } from "bcd";'
import { b, type c, type d } from "bcd";
// Rewritten to 'import {} from "xyz";'
import { type xyz } from "xyz";

有了这个新选项,所见即所得。

不过,这在模块互操作性方面确实有一些含义。在此标志下,ECMAScript 的 importexport 在你的设置或文件扩展名隐含不同的模块系统时,不会被重写为 require 调用。相反,你会得到一个错误。如果你需要编译出使用 requiremodule.exports 的代码,你将必须使用 ES2015 之前的 TypeScript 模块语法

输入 TypeScript 输出 JavaScript
ts
import foo = require("foo");
js
const foo = require("foo");
ts
function foo() {}
function bar() {}
function baz() {}
export = {
foo,
bar,
baz
};
js
function foo() {}
function bar() {}
function baz() {}
module.exports = {
foo,
bar,
baz
};

虽然这是一个限制,但它确实有助于使一些问题更加明显。例如,在 --module node16 下忘记在 package.json 中设置 type 字段是非常常见的。结果,开发人员会在没有意识到的情况下开始编写 CommonJS 模块而不是 ES 模块,从而导致令人惊讶的查找规则和 JavaScript 输出。这个新标志确保你对使用的文件类型是有意为之的,因为语法是有意不同的。

因为 --verbatimModuleSyntax 提供了比 --importsNotUsedAsValues--preserveValueImports 更一致的解决方案,所以这两个现有的标志已被废弃,转而支持新标志。

有关更多详细信息,请阅读 [原始拉取请求](https://github.com/microsoft/TypeScript/pull/52203) 和 其提案问题

支持 export type *

当 TypeScript 3.8 引入仅类型导入时,新语法不允许用于 export * from "module"export * as ns from "module" 重新导出。TypeScript 5.0 增加了对这两种形式的支持

ts
// models/vehicles.ts
export class Spaceship {
// ...
}
// models/index.ts
export type * as vehicles from "./vehicles";
// main.ts
import { vehicles } from "./models";
function takeASpaceship(s: vehicles.Spaceship) {
// ✅ ok - `vehicles` only used in a type position
}
function makeASpaceship() {
return new vehicles.Spaceship();
// ^^^^^^^^
// 'vehicles' cannot be used as a value because it was exported using 'export type'.
}

你可以在这里阅读更多关于实现的细节

JSDoc 中的 @satisfies 支持

TypeScript 4.9 引入了 satisfies 运算符。它确保表达式的类型兼容,而不影响类型本身。例如,让我们看以下代码

ts
interface CompilerOptions {
strict?: boolean;
outDir?: string;
// ...
}
interface ConfigSettings {
compilerOptions?: CompilerOptions;
extends?: string | string[];
// ...
}
let myConfigSettings = {
compilerOptions: {
strict: true,
outDir: "../lib",
// ...
},
extends: [
"@tsconfig/strictest/tsconfig.json",
"../../../tsconfig.base.json"
],
} satisfies ConfigSettings;

这里,TypeScript 知道 myConfigSettings.extends 是以数组形式声明的——因为虽然 satisfies 验证了我们对象的类型,但它并没有生硬地将其更改为 CompilerOptions 并丢失信息。所以如果我们想对 extends 进行映射,这是可以的。

ts
declare function resolveConfig(configPath: string): CompilerOptions;
let inheritedConfigs = myConfigSettings.extends.map(resolveConfig);

这对 TypeScript 用户很有帮助,但许多人使用 TypeScript 通过 JSDoc 注释来对 JavaScript 代码进行类型检查。这就是为什么 TypeScript 5.0 支持一个新的名为 @satisfies 的 JSDoc 标签,它做的事情完全一样。

/** @satisfies */ 可以捕获类型不匹配

js
// @ts-check
/**
* @typedef CompilerOptions
* @prop {boolean} [strict]
* @prop {string} [outDir]
*/
/**
* @satisfies {CompilerOptions}
*/
let myCompilerOptions = {
outdir: "../lib",
// ~~~~~~ oops! we meant outDir
};

但它会保留我们表达式的原始类型,从而允许我们在代码中后续更精确地使用我们的值。

js
// @ts-check
/**
* @typedef CompilerOptions
* @prop {boolean} [strict]
* @prop {string} [outDir]
*/
/**
* @typedef ConfigSettings
* @prop {CompilerOptions} [compilerOptions]
* @prop {string | string[]} [extends]
*/
/**
* @satisfies {ConfigSettings}
*/
let myConfigSettings = {
compilerOptions: {
strict: true,
outDir: "../lib",
},
extends: [
"@tsconfig/strictest/tsconfig.json",
"../../../tsconfig.base.json"
],
};
let inheritedConfigs = myConfigSettings.extends.map(resolveConfig);

/** @satisfies */ 也可以内联使用在任何带括号的表达式上。我们可以这样编写 myCompilerOptions

ts
let myConfigSettings = /** @satisfies {ConfigSettings} */ ({
compilerOptions: {
strict: true,
outDir: "../lib",
},
extends: [
"@tsconfig/strictest/tsconfig.json",
"../../../tsconfig.base.json"
],
});

为什么?嗯,当你深入到其他代码(如函数调用)中时,这通常更有意义。

js
compileCode(/** @satisfies {CompilerOptions} */ ({
// ...
}));

此功能Oleksandr Tarasiuk 提供!

JSDoc 中的 @overload 支持

在 TypeScript 中,你可以为函数指定重载。重载为我们提供了一种说明函数可以用不同的参数调用,并可能返回不同结果的方法。它们可以限制调用者实际使用我们函数的方式,并细化他们将获得的结果。

ts
// Our overloads:
function printValue(str: string): void;
function printValue(num: number, maxFractionDigits?: number): void;
// Our implementation:
function printValue(value: string | number, maximumFractionDigits?: number) {
if (typeof value === "number") {
const formatter = Intl.NumberFormat("en-US", {
maximumFractionDigits,
});
value = formatter.format(value);
}
console.log(value);
}

在这里,我们说 printValue 接收 stringnumber 作为其第一个参数。如果它接收一个 number,它可以接收第二个参数来决定我们可以打印多少个小数位。

TypeScript 5.0 现在允许 JSDoc 使用新的 @overload 标签声明重载。每个带有 @overload 标签的 JSDoc 注释都被视为后续函数声明的一个独立重载。

js
// @ts-check
/**
* @overload
* @param {string} value
* @return {void}
*/
/**
* @overload
* @param {number} value
* @param {number} [maximumFractionDigits]
* @return {void}
*/
/**
* @param {string | number} value
* @param {number} [maximumFractionDigits]
*/
function printValue(value, maximumFractionDigits) {
if (typeof value === "number") {
const formatter = Intl.NumberFormat("en-US", {
maximumFractionDigits,
});
value = formatter.format(value);
}
console.log(value);
}

现在,无论我们是在 TypeScript 还是 JavaScript 文件中编写,TypeScript 都能让我们知道我们是否错误地调用了函数。

ts
// all allowed
printValue("hello!");
printValue(123.45);
printValue(123.45, 2);
printValue("hello!", 123); // error!

这个新标签得以实现要感谢 Tomasz Lenarcik

--build 下传递编译特定标志

TypeScript 现在允许在 --build 模式下传递以下标志

  • --declaration
  • --emitDeclarationOnly
  • --declarationMap
  • --sourceMap
  • --inlineSourceMap

这使得在某些你可能拥有不同开发和生产构建的构建部分进行自定义变得容易多了。

例如,库的开发构建可能不需要生成声明文件,但生产构建则需要。项目可以配置声明输出默认为关闭,并简单地使用以下命令构建

sh
tsc --build -p ./my-project-dir

当你完成内部循环中的迭代后,“生产”构建只需传递 --declaration 标志即可。

sh
tsc --build -p ./my-project-dir --declaration

有关此更改的更多信息,请参阅此处.

编辑器中不区分大小写的导入排序

在 Visual Studio 和 VS Code 等编辑器中,TypeScript 为组织和排序导入和导出提供了支持。然而,通常对于列表何时“排序”存在不同的解释。

例如,下面的导入列表排序了吗?

ts
import {
Toggle,
freeze,
toBoolean,
} from "./utils";

答案可能会令人惊讶地是“视情况而定”。如果我们关心大小写敏感性,那么这个列表显然没有排序。字母 ftT 之前。

但在大多数编程语言中,排序默认为比较字符串的字节值。JavaScript 比较字符串的方式意味着 "Toggle" 总是排在 "freeze" 之前,因为根据 ASCII 字符编码,大写字母排在小写字母之前。所以从这个角度来看,导入列表是排序的。

TypeScript 以前认为导入列表已排序,因为它执行的是基本的区分大小写的排序。对于喜欢区分大小写排序的开发者,或者使用需要默认不区分大小写排序的 ESLint 等工具的开发者来说,这可能是一个令人沮丧的地方。

TypeScript 现在默认检测大小写敏感性。这意味着 TypeScript 和像 ESLint 这样的工具通常不会因为如何最好地排序导入而发生“冲突”。

我们的团队也在试验进一步的排序策略,你可以在这里阅读相关内容。这些选项最终可能会通过编辑器进行配置。目前,它们仍然不稳定且是实验性的,你今天可以通过在 JSON 选项中使用 typescript.unstable 条目来选择加入它们。以下是你可以尝试的所有选项(设置为其默认值)

jsonc
{
"typescript.unstable": {
// Should sorting be case-sensitive? Can be:
// - true
// - false
// - "auto" (auto-detect)
"organizeImportsIgnoreCase": "auto",
// Should sorting be "ordinal" and use code points or consider Unicode rules? Can be:
// - "ordinal"
// - "unicode"
"organizeImportsCollation": "ordinal",
// Under `"organizeImportsCollation": "unicode"`,
// what is the current locale? Can be:
// - [any other locale code]
// - "auto" (use the editor's locale)
"organizeImportsLocale": "en",
// Under `"organizeImportsCollation": "unicode"`,
// should upper-case letters or lower-case letters come first? Can be:
// - false (locale-specific)
// - "upper"
// - "lower"
"organizeImportsCaseFirst": false,
// Under `"organizeImportsCollation": "unicode"`,
// do runs of numbers get compared numerically (i.e. "a1" < "a2" < "a100")? Can be:
// - true
// - false
"organizeImportsNumericCollation": true,
// Under `"organizeImportsCollation": "unicode"`,
// do letters with accent marks/diacritics get sorted distinctly
// from their "base" letter (i.e. is é different from e)? Can be
// - true
// - false
"organizeImportsAccentCollation": true
},
"javascript.unstable": {
// same options valid here...
},
}

你可以阅读关于自动检测和指定不区分大小写的原始工作的更多详细信息,随后是更广泛的选项集

详尽的 switch/case 完成

在编写 switch 语句时,TypeScript 现在可以检测正在检查的值何时具有字面量类型。如果是这样,它将提供一个自动补全,搭建出每个未覆盖的 case

A set of case statements generated through auto-completion based on literal types.

你可以在 GitHub 上查看实现的具体细节

速度、内存和包大小优化

TypeScript 5.0 在我们的代码结构、数据结构和算法实现方面包含了许多强大的更改。这意味着你的整体体验应该更快——不仅是运行 TypeScript,甚至是安装它。

以下是我们相对于 TypeScript 4.9 能够实现的一些在速度和大小方面有趣的成果。

场景 相对于 TS 4.9 的时间或大小
material-ui 构建时间 89%
TypeScript 编译器启动时间 89%
Playwright 构建时间 88%
TypeScript 编译器自构建时间 87%
Outlook Web 构建时间 82%
VS Code 构建时间 80%
typescript npm 包大小 59%

Chart of build/run times and package size of TypeScript 5.0 relative to TypeScript 4.9: material-ui docs build time: 89%; Playwright build time: 88%; tsc startup time: 87%; tsc build time: 87%; Outlook Web build time: 82%; VS Code build time: 80%; typescript Package Size: 59%

如何实现的?有一些值得注意的改进,我们希望在未来提供更多细节。但我们不会让你等待那篇博客文章。

首先,我们最近将 TypeScript 从命名空间迁移到了模块,这使我们能够利用可以执行范围提升 (scope hoisting) 等优化的现代构建工具。使用这些工具、重新审视我们的打包策略并删除一些已废弃的代码,已经从 TypeScript 4.9 的 63.8 MB 包大小中削减了约 26.4 MB。它还通过直接函数调用为我们带来了显着的速度提升。

TypeScript 还增加了编译器内部对象类型的统一性,并且还简化了这些对象类型上存储的数据。这减少了多态和超多态 (megamorphic) 的使用点,同时抵消了统一形状所需的大部分内存消耗。

我们还在将信息序列化为字符串时执行了一些缓存。类型显示(可能作为错误报告、声明输出、代码完成等的一部分发生)最终可能相当昂贵。TypeScript 现在缓存了一些常用机制,以便在这些操作中重复使用。

我们在解析器方面所做的另一个显着更改是利用 var 来偶尔绕过在闭包中使用 letconst 的成本。这提高了一些解析性能。

总的来说,我们预计大多数代码库应该能从 TypeScript 5.0 中看到速度提升,并且我们能够持续复现 10% 到 20% 的性能提升。当然,这将取决于硬件和代码库特征,但我们鼓励你今天就在你的代码库中试用它!

有关更多信息,请参阅我们的一些显著优化

破坏性更改和废弃

运行时要求

TypeScript 现在以 ECMAScript 2018 为目标。对于 Node 用户,这意味着至少需要 Node.js 10 及更高版本。

lib.d.ts 变更

DOM 类型生成方式的更改可能会影响现有代码。值得注意的是,某些属性已从 number 转换为数字字面量类型,并且剪切、复制和粘贴事件处理的属性和方法已在接口之间移动。

API 破坏性更改

在 TypeScript 5.0 中,我们迁移到了模块,删除了一些不必要的接口,并进行了一些正确性改进。有关已更改内容的更多详细信息,请参阅我们的 API 破坏性更改页面。

关系运算符中禁止隐式强制转换

TypeScript 中的某些操作已经会在你编写可能导致隐式字符串转数字强制转换的代码时警告你

ts
function func(ns: number | string) {
return ns * 4; // Error, possible implicit coercion
}

在 5.0 中,这也将应用于关系运算符 ><<=>=

ts
function func(ns: number | string) {
return ns > 4; // Now also an error
}

如果需要,你可以使用 + 显式将操作数强制转换为 number 来允许这样做

ts
function func(ns: number | string) {
return +ns > 4; // OK
}

正确性改进Mateusz Burzyński 提供。

枚举彻底改革

自第一个版本发布以来,TypeScript 在 enum(枚举)方面一直存在一些长期存在的问题。在 5.0 中,我们清理了一些此类问题,并减少了理解你可以声明的各种 enum 类型所需的概念数量。

作为其中的一部分,你可能会看到两个主要的错误。第一个是,将域外字面量赋值给 enum 类型现在会像预期那样报错

ts
enum SomeEvenDigit {
Zero = 0,
Two = 2,
Four = 4
}
// Now correctly an error
let m: SomeEvenDigit = 1;

另一个是,某些类型的间接混合字符串/数字 enum 形式的声明会错误地创建一个全数字的 enum

ts
enum Letters {
A = "a"
}
enum Numbers {
one = 1,
two = Letters.A
}
// Now correctly an error
const t: number = Numbers.two;

你可以在相关更改中查看更多详细信息

--experimentalDecorators 下对构造函数中的参数装饰器进行更准确的类型检查

TypeScript 5.0 使 --experimentalDecorators 下装饰器的类型检查更加准确。当在构造函数参数上使用装饰器时,这一点变得显而易见。

ts
export declare const inject:
(entity: any) =>
(target: object, key: string | symbol, index?: number) => void;
export class Foo {}
export class C {
constructor(@inject(Foo) private x: any) {
}
}

此调用将失败,因为 key 需要 string | symbol,但构造函数参数接收的键为 undefined。正确的修复是更改 injectkey 的类型。如果你使用的库无法升级,一个合理的解决方法是将 inject 包装在一个类型更安全的装饰器函数中,并对 key 使用类型断言。

有关更多详细信息,请查看此问题

废弃和默认值更改

在 TypeScript 5.0 中,我们废弃了以下设置和设置值

  • --target: ES3
  • --out
  • --noImplicitUseStrict
  • --keyofStringsOnly
  • --suppressExcessPropertyErrors
  • --suppressImplicitAnyIndexErrors
  • --noStrictGenericChecks
  • --charset
  • --importsNotUsedAsValues
  • --preserveValueImports
  • 项目引用中的 prepend

这些配置将继续被允许,直到 TypeScript 5.5,届时它们将被完全删除;但是,如果你正在使用这些设置,你将收到警告。在 TypeScript 5.0 以及未来的 5.1、5.2、5.3 和 5.4 版本中,你可以指定 "ignoreDeprecations": "5.0" 来消除这些警告。我们很快还将发布一个 4.9 补丁,允许指定 ignoreDeprecations 以实现更平滑的升级。除了废弃之外,我们还更改了一些设置,以更好地改进 TypeScript 中的跨平台行为。

--newLine(控制 JavaScript 文件中输出的行尾)以前如果没有指定,会根据当前操作系统进行推断。我们认为构建应该尽可能确定,而且 Windows 记事本现在支持 LF(换行)行尾,因此新的默认设置是 LF。旧的操作系统特定的推断行为已不再可用。

--forceConsistentCasingInFileNames(确保项目中对同一文件名的所有引用在大小写上一致)现在默认为 true。这有助于捕获在不区分大小写的文件系统上编写代码时产生的问题。

你可以留下反馈并查看有关 5.0 废弃跟踪问题的更多信息

TypeScript 文档是一个开源项目。通过发送 Pull Request 帮助我们改进这些页面 ❤

此页面的贡献者
ABAndrew Branch (7)
Bblisse (1)
EIEugene Ilyin (1)
Mmarcustyphoon (1)
MMohi (1)
2+

最后更新:2026 年 3 月 27 日