TypeScript 5.0
装饰器
装饰器是即将推出的 ECMAScript 特性,允许我们以可重用的方式定制类及其成员。
让我们考虑以下代码:
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。
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 的函数,如下所示:
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) 并返回一个函数,该函数
- 记录“进入...”消息
- 将
this及其所有参数传递给原始方法 - 记录“退出...”消息,以及
- 返回原始方法返回的任何内容。
现在我们可以使用 loggedMethod 来装饰方法 greet:
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();
// 输出:
//
// LOG: Entering method.
// Hello, my name is Ray.
// LOG: Exiting method.我们刚刚将 loggedMethod 用作 greet 上方的装饰器——注意我们将其写为 @loggedMethod。 当我们这样做时,它被调用了,并传入了方法目标和一个上下文对象。 因为 loggedMethod 返回了一个新函数,该函数替换了 greet 的原始定义。
我们之前没有提到,但 loggedMethod 是用第二个参数定义的。 它被称为“上下文对象”,它包含一些关于被装饰方法如何声明的有用信息——比如它是 #private 成员、static,或者方法的名称是什么。 让我们重写 loggedMethod 以利用这一点,并打印出被装饰的方法的名称。
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 中第一个类型比 any 和 any[] 更严格的东西。 TypeScript 提供了一个名为 ClassMethodDecoratorContext 的类型,它对方法装饰器采用的上下文对象进行建模。
除了元数据之外,方法的上下文对象还有一个有用的函数叫做 addInitializer。 它是一种在构造函数开头(或者如果我们在处理 static,则在类本身的初始化)挂钩的方法。
举个例子——在 JavaScript 中,通常编写如下模式:
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 可能被声明为初始化为箭头函数的属性。
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
greet = () => {
console.log(`Hello, my name is ${this.name}.`);
};
}编写这段代码是为了确保如果 greet 作为独立函数调用或作为回调传递,this 不会被重新绑定。
const greet = new Person("Ray").greet;
// 我们不希望这个失败!
greet();我们可以编写一个装饰器,使用 addInitializer 在构造函数中为我们调用 bind。
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 不返回任何东西——所以当它装饰一个方法时,它保持原始方法不变。 相反,它会在任何其他字段初始化之前添加逻辑。
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;
// 有效!
greet();注意我们堆叠了两个装饰器——@bound 和 @loggedMethod。 这些装饰器以“相反顺序”运行。 也就是说,@loggedMethod 装饰原始方法 greet,而 @bound 装饰 @loggedMethod 的结果。 在这个例子中,这无关紧要——但如果你的装饰器有副作用或期望特定顺序,那就可能重要了。
另外值得注意——如果你喜欢风格,你可以将这些装饰器放在同一行。
@bound @loggedMethod greet() {
console.log(`Hello, my name is ${this.name}.`);
}可能不明显的是,我们甚至可以制作返回装饰器函数的函数。 这使得可以稍微定制最终的装饰器。 如果我们愿意,我们可以让 loggedMethod 返回一个装饰器,并定制它如何记录消息。
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。 然后我们可以传入任何字符串作为记录到控制台的消息前缀。
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();
// 输出:
//
// ⚠️ Entering method 'greet'.
// Hello, my name is Ray.
// ⚠️ Exiting method 'greet'.装饰器不仅可以用于方法! 它们还可以用于属性/字段、getter、setter 和自动访问器。 甚至类本身也可以被装饰,用于子类化和注册等。
要深入了解装饰器,你可以阅读 Axel Rauschmayer 的详细总结。
有关相关更改的更多信息,你可以查看原始拉取请求。
与实验性传统装饰器的差异
如果你使用 TypeScript 已经有一段时间了,你可能知道它多年来一直支持“实验性”装饰器。 虽然这些实验性装饰器非常有用,但它们建模的是装饰器提案的一个更旧的版本,并且始终需要一个称为 --experimentalDecorators 的选择性加入编译器标志。 任何在没有此标志的情况下在 TypeScript 中使用装饰器的尝试过去都会提示错误。
--experimentalDecorators 在可预见的未来将继续存在; 然而,在没有此标志的情况下,装饰器现在对所有新代码都是有效的语法。 在 --experimentalDecorators 之外,它们将以不同的方式进行类型检查和输出。 类型检查规则和输出差异很大,虽然装饰器可以编写为同时支持旧的和新的装饰器行为,但任何现有的装饰器函数不太可能做到这一点。
这个新的装饰器提案与 --emitDecoratorMetadata 不兼容,并且不允许装饰参数。 未来的 ECMAScript 提案也许能够帮助弥合这一差距。
最后一点:除了允许将装饰器放在 export 关键字之前之外,装饰器提案现在还提供了将装饰器放在 export 或 export default 之后的选项。 唯一的例外是不允许混合使用两种风格。
// ✅ 允许
@register export default class Foo {
// ...
}
// ✅ 也允许
export default @register class Bar {
// ...
}
// ❌ 错误 - 不允许之前*和*之后
@before export @after class Bar {
// ...
}编写类型良好的装饰器
上面的 loggedMethod 和 bound 装饰器示例故意简化,省略了很多类型细节。
装饰器的类型化可能相当复杂。 例如,上面 loggedMethod 的类型良好版本可能看起来像这样:
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;
}我们必须使用类型参数 This、Args 和 Return 分别对 this、参数和原始方法的返回类型进行建模。
你的装饰器函数定义的复杂程度取决于你想要保证的内容。 请记住,你的装饰器被使用的次数多于被编写的次数,因此类型良好的版本通常是更可取的——但显然在可读性方面存在权衡,因此尽量保持简单。
将来会有更多关于编写装饰器的文档——但这篇文章应该对装饰器的机制有足够详细的说明。
const 类型参数
当推断对象的类型时,TypeScript 通常会选择一种通用的类型。 例如,在这种情况下,names 的推断类型是 string[]:
type HasNames = { names: readonly string[] };
function getNamesExactly<T extends HasNames>(arg: T): T["names"] {
return arg.names;
}
// 推断类型:string[]
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"]});通常这样做的目的是允许后续的修改。
然而,根据 getNamesExactly 的具体作用以及预期用途,通常可能希望得到更具体的类型。
到目前为止,API 作者通常不得不建议在某些地方添加 as const 以获得期望的推断:
// 我们想要的类型:
// readonly ["Alice", "Bob", "Eve"]
// 我们得到的类型:
// string[]
const names1 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]});
// 正确得到我们想要的:
// readonly ["Alice", "Bob", "Eve"]
const names2 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]} as const);这可能会很繁琐且容易忘记。 在 TypeScript 5.0 中,你现在可以在类型参数声明中添加 const 修饰符,使类似 const 的推断成为默认行为:
type HasNames = { names: readonly string[] };
function getNamesExactly<const T extends HasNames>(arg: T): T["names"] {
// ^^^^^
return arg.names;
}
// 推断类型:readonly ["Alice", "Bob", "Eve"]
// 注意:这里不需要写 'as const'
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });请注意,const 修饰符不会拒绝可变值,也不要求不可变约束。 使用可变类型约束可能会产生令人惊讶的结果。 例如:
declare function fnBad<const T extends string[]>(args: T): void;
// 'T' 仍然是 'string[]',因为 'readonly ["a", "b", "c"]' 不能赋值给 'string[]'
fnBad(["a", "b" ,"c"]);这里,T 的推断候选是 readonly ["a", "b", "c"],而需要可变数组的地方不能使用 readonly 数组。 在这种情况下,推断回退到约束,数组被视为 string[],调用仍然成功。
这个函数的更好定义应该使用 readonly string[]:
declare function fnGood<const T extends readonly string[]>(args: T): void;
// T 是 readonly ["a", "b", "c"]
fnGood(["a", "b" ,"c"]);类似地,请记住 const 修饰符仅影响调用中编写的对象、数组和原始表达式的推断,因此那些不能用(或无法用)as const 修改的参数的行为不会发生任何变化:
declare function fnGood<const T extends readonly string[]>(args: T): void;
const arr = ["a", "b" ,"c"];
// 'T' 仍然是 'string[]'——这里的 'const' 修饰符没有效果
fnGood(arr);请参阅拉取请求以及(第一个和第二个)动机问题以获取更多详细信息。
在 extends 中支持多个配置文件
当管理多个项目时,有一个其他 tsconfig.json 文件可以扩展的“基础”配置文件会很有帮助。 这就是 TypeScript 支持 extends 字段来复制 compilerOptions 中的字段的原因。
// packages/front-end/src/tsconfig.json
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../lib",
// ...
}
}然而,在某些场景下,你可能希望从多个配置文件扩展。 例如,假设你使用发布到 npm 的 TypeScript 基础配置文件。 如果你希望所有项目也使用 npm 上 @tsconfig/strictest 包中的选项,那么有一个简单的解决方案:让 tsconfig.base.json 扩展 @tsconfig/strictest:
// tsconfig.base.json
{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
// ...
}
}这在某种程度上是有效的。 如果你有任何不想使用 @tsconfig/strictest 的项目,它们必须要么手动禁用选项,要么创建一个不扩展 @tsconfig/strictest 的单独版本的 tsconfig.base.json。
为了提供更多的灵活性,TypeScript 5.0 现在允许 extends 字段接受多个条目。 例如,在这个配置文件中:
{
"extends": ["a", "b", "c"],
"compilerOptions": {
// ...
}
}这有点像直接扩展 c,其中 c 扩展 b,b 扩展 a。 如果任何字段“冲突”,则后面的条目获胜。
因此,在下面的例子中,strictNullChecks 和 noImplicitAny 都在最终的 tsconfig.json 中启用。
// tsconfig1.json
{
"compilerOptions": {
"strictNullChecks": true
}
}
// tsconfig2.json
{
"compilerOptions": {
"noImplicitAny": true
}
}
// tsconfig.json
{
"extends": ["./tsconfig1.json", "./tsconfig2.json"],
"files": ["./index.ts"]
}作为另一个例子,我们可以按以下方式重写我们的原始示例。
// packages/front-end/src/tsconfig.json
{
"extends": ["@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json"],
"compilerOptions": {
"outDir": "../lib",
// ...
}
}有关更多详细信息,请阅读原始拉取请求。
所有 enum 都是联合 enum
当 TypeScript 最初引入枚举时,它们不过是一组具有相同类型的数字常量。
enum E {
Foo = 10,
Bar = 20,
}E.Foo 和 E.Bar 的唯一特殊之处在于它们可以赋值给任何期望类型 E 的东西。 除此之外,它们基本上就是 number。
function takeValue(e: E) {}
takeValue(E.Foo); // 有效
takeValue(123); // 错误!直到 TypeScript 2.0 引入了枚举字面量类型,枚举才变得有点特殊。 枚举字面量类型为每个枚举成员赋予了其自己的类型,并将枚举本身变成了每个成员类型的联合。 它们还允许我们仅引用枚举类型的子集,并收窄这些类型。
// Color 类似于 Red | Orange | Yellow | Green | Blue | Violet 的联合
enum Color {
Red, Orange, Yellow, Green, Blue, /* Indigo, */ Violet
}
// 每个枚举成员都有自己的类型,我们可以引用!
type PrimaryColor = Color.Red | Color.Green | Color.Blue;
function isPrimaryColor(c: Color): c is PrimaryColor {
// 收窄字面量类型可以捕获错误。
// TypeScript 会在这里报错,因为
// 我们将比较 'Color.Red' 和 'Color.Green'。
// 我们本意是使用 ||,但不小心写了 &&。
return c === Color.Red && c === Color.Green && c === Color.Blue;
}给每个枚举成员赋予自己的类型的一个问题是,这些类型在某种程度上与成员的实际值相关联。 在某些情况下,无法计算该值——例如,枚举成员可以通过函数调用来初始化。
enum E {
Blah = Math.random()
}每当 TypeScript 遇到这些问题时,它会悄悄地退回到旧的枚举策略。 这意味着放弃联合和字面量类型的所有优势。
TypeScript 5.0 通过为每个计算成员创建唯一类型,成功地将所有枚举变为联合枚举。 这意味着现在所有枚举都可以被收窄,并且它们的成员也可以作为类型被引用。
有关此更改的更多详细信息,你可以在 GitHub 上阅读具体细节。
--moduleResolution bundler
TypeScript 4.7 为其 --module 和 --moduleResolution 设置引入了 node16 和 nodenext 选项。 这些选项的目的是更好地模拟 Node.js 中 ECMAScript 模块的精确查找规则; 然而,此模式有许多其他工具并不真正强制执行限制。
例如,在 Node.js 的 ECMAScript 模块中,任何相对导入都需要包含文件扩展名。
// entry.mjs
import * as utils from "./utils"; // ❌ 错误 - 我们需要包含文件扩展名。
import * as utils from "./utils.mjs"; // ✅ 有效Node.js 和浏览器这样做是有一定原因的——它使文件查找更快,并且对简单的文件服务器更友好。 但对于许多使用打包器等工具的开发人员来说,node16/nodenext 设置很麻烦,因为打包器没有这些限制中的大多数。 在某些方面,node 解析模式对任何使用打包器的人更好。
但在某些方面,原始的 node 解析模式已经过时了。 大多数现代打包器使用 Node.js 中 ECMAScript 模块和 CommonJS 查找规则的融合。 例如,无扩展名导入就像在 CommonJS 中一样正常工作,但在查看包的 export 条件时,它们会像在 ECMAScript 文件中一样优先使用 import 条件。
为了对打包器的工作方式进行建模,TypeScript 现在引入了一种新策略:--moduleResolution bundler。
{
"compilerOptions": {
"target": "esnext",
"moduleResolution": "bundler"
}
}如果你使用的是实现混合查找策略的现代打包器,如 Vite、esbuild、swc、Webpack、Parcel 等,那么新的 bundler 选项应该很适合你。
另一方面,如果你正在编写一个打算发布到 npm 的库,使用 bundler 选项可能会隐藏那些不使用打包器的用户可能出现兼容性问题。 因此,在这些情况下,使用 node16 或 nodenext 解析选项可能是更好的选择。
要阅读更多关于 --moduleResolution bundler 的信息,请查看实现拉取请求。
分辨率自定义标志
JavaScript 工具现在可以模拟“混合”解析规则,就像我们在上面描述的 bundler 模式一样。 由于工具的支持可能略有不同,TypeScript 5.0 提供了启用或禁用某些可能适用于或不适用于你的配置的功能的方法。
allowImportingTsExtensions
--allowImportingTsExtensions 允许 TypeScript 文件使用 TypeScript 特定的扩展名(如 .ts、.mts 或 .tsx)相互导入。
此标志仅在启用 --noEmit 或 --emitDeclarationOnly 时才允许,因为这些导入路径在 JavaScript 输出文件中无法在运行时解析。 这里的期望是你的解析器(例如你的打包器、运行时或其他工具)将使这些 .ts 文件之间的导入工作。
resolvePackageJsonExports
--resolvePackageJsonExports 强制 TypeScript 在从 node_modules 读取包时查阅 package.json 文件的 exports 字段。
在 --moduleResolution 的 node16、nodenext 和 bundler 选项下,此选项默认为 true。
resolvePackageJsonImports
--resolvePackageJsonImports 强制 TypeScript 在执行以 # 开头的查找时,如果文件的祖先目录包含 package.json,则查阅 package.json 文件的 imports 字段。
在 --moduleResolution 的 node16、nodenext 和 bundler 选项下,此选项默认为 true。
allowArbitraryExtensions
在 TypeScript 5.0 中,当导入路径以非已知 JavaScript 或 TypeScript 文件扩展名结尾时,编译器将查找该路径的声明文件,形式为 {文件基名}.d.{扩展名}.ts。 例如,如果你在打包器项目中使用 CSS 加载器,你可能想要为这些样式表编写(或生成)声明文件:
/* app.css */
.cookie-banner {
display: none;
}// app.d.css.ts
declare const css: {
cookieBanner: string;
};
export default css;// App.tsx
import styles from "./app.css";
styles.cookieBanner; // string默认情况下,此导入将引发错误,让你知道 TypeScript 不理解此文件类型,并且你的运行时可能不支持导入它。 但是,如果你已将运行时或打包器配置为处理它,则可以使用新的 --allowArbitraryExtensions 编译器选项抑制错误。
请注意,历史上,通常可以通过添加名为 app.css.d.ts 而不是 app.d.css.ts 的声明文件来实现类似的效果——然而,这只是通过 Node 的 CommonJS require 解析规则实现的。 严格来说,前者被解释为名为 app.css.js 的 JavaScript 文件的声明文件。 由于相对文件导入需要在 Node 的 ESM 支持中包含扩展名,因此在 --moduleResolution node16 或 nodenext 下的 ESM 文件中,TypeScript 会对我们的示例报错。
customConditions
--customConditions 接受一个额外的条件列表,当 TypeScript 从 package.json 的 exports 或 imports 字段解析时,这些条件应该成功。 这些条件会被添加到解析器默认使用的任何现有条件中。
例如,当在 tsconfig.json 中这样设置此字段时:
{
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"customConditions": ["my-condition"]
}
}每当在 package.json 中引用 exports 或 imports 字段时,TypeScript 将考虑名为 my-condition 的条件。
因此,当从具有以下 package.json 的包导入时
{
// ...
"exports": {
".": {
"my-condition": "./foo.mjs",
"node": "./bar.mjs",
"import": "./baz.mjs",
"require": "./biz.mjs"
}
}
}TypeScript 将尝试查找对应于 foo.mjs 的文件。
此字段仅在 --moduleResolution 的 node16、nodenext 和 bundler 选项下有效。
--verbatimModuleSyntax
默认情况下,TypeScript 会执行所谓的导入省略。 基本上,如果你编写类似
import { Car } from "./car";
export function drive(car: Car) {
// ...
}TypeScript 检测到你只在类型中使用导入,并完全删除该导入。 你的输出 JavaScript 可能看起来像这样:
export function drive(car) {
// ...
}大多数情况下这很好,因为如果 Car 不是从 ./car 导出的值,我们会得到运行时错误。
但它确实为某些边缘情况增加了一层复杂性。 例如,注意没有像 import "./car"; 这样的语句——导入被完全删除了。 这对于有副作用或没有副作用的模块确实有影响。
TypeScript 的 JavaScript 输出策略还有另外几层复杂性——导入省略并不总是仅由导入的使用方式驱动——它通常还会查看值是如何声明的。 因此,并不总是清楚像下面这样的代码
export { Car } from "./car";是否应该保留或删除。 如果 Car 是用 class 之类的东西声明的,那么它可以保留在生成的 JavaScript 文件中。 但如果 Car 仅声明为 type 别名或 interface,那么 JavaScript 文件根本不应该导出 Car。
虽然 TypeScript 可能能够根据跨文件的信息做出这些输出决定,但并非每个编译器都能做到。
导入和导出上的 type 修饰符在这些情况下有一点帮助。 我们可以通过使用 type 修饰符明确导入或导出是否仅用于类型分析,并且可以在 JavaScript 文件中完全删除。
// 这个语句可以在 JS 输出中完全删除
import type * as car from "./car";
// 命名导入/导出 'Car' 可以在 JS 输出中删除
import { type Car } from "./car";
export { type Car } from "./car";type 修饰符本身并不十分有用——默认情况下,模块省略仍然会删除导入,并且没有什么强制你区分 type 和普通的导入和导出。 因此 TypeScript 有标志 --importsNotUsedAsValues 来确保你使用 type 修饰符,--preserveValueImports 来阻止某些模块省略行为,以及 --isolatedModules 来确保你的 TypeScript 代码在不同编译器之间工作。 不幸的是,理解这三个标志的细节很难,并且仍然存在一些具有意外行为的边缘情况。
TypeScript 5.0 引入了一个名为 --verbatimModuleSyntax 的新选项来简化情况。 规则更简单——任何没有 type 修饰符的导入或导出都会保留。 任何使用 type 修饰符的内容都会被完全删除。
// 完全擦除。
import type { A } from "a";
// 重写为 'import { b } from "bcd";'
import { b, type c, type d } from "bcd";
// 重写为 'import {} from "xyz";'
import { type xyz } from "xyz";有了这个新选项,所见即所得。
然而,在模块互操作方面,这确实有一些影响。 在此标志下,当你的设置或文件扩展名暗示了不同的模块系统时,ECMAScript import 和 export 将不会被重写为 require 调用。 相反,你会得到一个错误。 如果你需要输出使用 require 和 module.exports 的代码,你必须使用早于 ES2015 的 TypeScript 模块语法:
| 输入 TypeScript | 输出 JavaScript |
|---|---|
ts | js |
ts | js |
虽然这是一个限制,但它确实有助于使一些问题更加明显。 例如,在 --module node16 下,很容易忘记设置 package.json 中的 type 字段。 结果,开发人员会开始编写 CommonJS 模块而不是 ES 模块却没有意识到,导致令人惊讶的查找规则和 JavaScript 输出。 这个新标志确保你明确你正在使用的文件类型,因为语法有意不同。
由于 --verbatimModuleSyntax 比 --importsNotUsedAsValues 和 --preserveValueImports 提供了更一致的故事,这两个现有标志将被弃用,转而支持它。
支持 export type *
当 TypeScript 3.8 引入仅类型导入时,新语法在 export * from "module" 或 export * as ns from "module" 重新导出中是不允许的。TypeScript 5.0 增加了对这两种形式的支持:
// 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` 仅在类型位置使用
}
function makeASpaceship() {
return new vehicles.Spaceship();
// ^^^^^^^^
// 'vehicles' 不能用作值,因为它使用 'export type' 导出。
}你可以在此处阅读更多关于实现的信息。
JSDoc 中的 @satisfies 支持
TypeScript 4.9 引入了 satisfies 操作符。 它确保表达式的类型是兼容的,而不影响类型本身。 例如,让我们看下面的代码:
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,这是可以的。
declare function resolveConfig(configPath: string): CompilerOptions;
let inheritedConfigs = myConfigSettings.extends.map(resolveConfig);这对 TypeScript 用户很有帮助,但很多人使用 TypeScript 通过 JSDoc 注释来类型检查他们的 JavaScript 代码。 这就是为什么 TypeScript 5.0 支持一个新的 JSDoc 标签 @satisfies,它做完全相同的事情。
/** @satisfies */ 可以捕获类型不匹配:
// @ts-check
/**
* @typedef CompilerOptions
* @prop {boolean} [strict]
* @prop {string} [outDir]
*/
/**
* @satisfies {CompilerOptions}
*/
let myCompilerOptions = {
outdir: "../lib",
// ~~~~~~ 哎呀!我们本意是 outDir
};但它将保留我们表达式的原始类型,允许我们在后面的代码中更精确地使用我们的值。
// @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:
let myConfigSettings = /** @satisfies {ConfigSettings} */ ({
compilerOptions: {
strict: true,
outDir: "../lib",
},
extends: [
"@tsconfig/strictest/tsconfig.json",
"../../../tsconfig.base.json"
],
});为什么? 嗯,当你在其他代码深处(如函数调用)时,它通常更有意义。
compileCode(/** @satisfies {CompilerOptions} */ ({
// ...
}));此功能由 Oleksandr Tarasiuk 提供!
JSDoc 中的 @overload 支持
在 TypeScript 中,你可以为函数指定重载。 重载让我们可以说明一个函数可以用不同的参数调用,并可能返回不同的结果。 它们可以限制调用者实际使用我们的函数的方式,并细化他们将获得的结果。
// 我们的重载:
function printValue(str: string): void;
function printValue(num: number, maxFractionDigits?: number): void;
// 我们的实现:
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 接受 string 或 number 作为其第一个参数。 如果它接受 number,它可以接受第二个参数来确定我们可以打印多少位小数。
TypeScript 5.0 现在允许 JSDoc 使用新的 @overload 标签声明重载。 每个带有 @overload 标签的 JSDoc 注释都被视为以下函数声明的不同重载。
// @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 都可以告诉我们是否错误地调用了函数。
// 全部允许
printValue("hello!");
printValue(123.45);
printValue(123.45, 2);
printValue("hello!", 123); // 错误!此新标签已实现,感谢 Tomasz Lenarcik。
在 --build 下传递特定于输出的标志
TypeScript 现在允许在 --build 模式下传递以下标志
--declaration--emitDeclarationOnly--declarationMap--sourceMap--inlineSourceMap
这使得定制构建的某些部分变得更容易,因为你可能有不同的开发和生产构建。
例如,库的开发构建可能不需要生成声明文件,但生产构建需要。 一个项目可以将声明输出配置为默认关闭,只需使用以下命令构建:
tsc --build -p ./my-project-dir一旦你在内部循环中完成迭代,一个“生产”构建就可以直接传递 --declaration 标志。
tsc --build -p ./my-project-dir --declaration编辑器中的不区分大小写导入排序
在 Visual Studio 和 VS Code 等编辑器中,TypeScript 为组织和排序导入和导出提供了支持。 然而,对于列表何时“排序”往往有不同的解释。
例如,下面的导入列表是否已排序?
import {
Toggle,
freeze,
toBoolean,
} from "./utils";答案可能令人惊讶,即“视情况而定”。 如果我们不关心大小写敏感性,那么这个列表显然没有排序。 字母 f 出现在 t 和 T 之前。
但在大多数编程语言中,排序默认是比较字符串的字节值。 JavaScript 比较字符串的方式意味着 "Toggle" 总是出现在 "freeze" 之前,因为根据 ASCII 字符编码,大写字母在小写字母之前。 所以从这个角度来看,导入列表是排序的。
TypeScript 以前认为导入列表是排序的,因为它执行的是基本的区分大小写的排序。 对于喜欢不区分大小写顺序的开发者,或者使用默认要求不区分大小写顺序的工具(如 ESLint)的开发者来说,这可能会令人沮丧。
TypeScript 现在默认检测大小写敏感性。 这意味着 TypeScript 和像 ESLint 这样的工具通常不会就如何最好地排序导入而“争斗”。
我们的团队也一直在试验进一步的排序策略,你可以在此处阅读。 这些选项最终可能由编辑器配置。 目前,它们仍然不稳定且是实验性的,你可以通过在 JSON 选项中使用 typescript.unstable 条目在 VS Code 中选择启用它们。 以下是你所有可以尝试的选项(设置为默认值):
{
"typescript.unstable": {
// 排序是否区分大小写?可以是:
// - true
// - false
// - "auto" (自动检测)
"organizeImportsIgnoreCase": "auto",
// 排序是“序数”并使用代码点还是考虑 Unicode 规则?可以是:
// - "ordinal"
// - "unicode"
"organizeImportsCollation": "ordinal",
// 在 `"organizeImportsCollation": "unicode"` 下,
// 当前区域设置是什么?可以是:
// - [任何其他区域设置代码]
// - "auto" (使用编辑器的区域设置)
"organizeImportsLocale": "en",
// 在 `"organizeImportsCollation": "unicode"` 下,
// 大写字母还是小写字母应该在前?可以是:
// - false (特定于区域设置)
// - "upper"
// - "lower"
"organizeImportsCaseFirst": false,
// 在 `"organizeImportsCollation": "unicode"` 下,
// 数字序列是否进行数值比较(即 "a1" < "a2" < "a100")?可以是:
// - true
// - false
"organizeImportsNumericCollation": true,
// 在 `"organizeImportsCollation": "unicode"` 下,
// 带有重音符号/变音符号的字母是否与其“基本”字母区别排序
// (即 é 是否与 e 不同)?可以是:
// - true
// - false
"organizeImportsAccentCollation": true
},
"javascript.unstable": {
// 这里适用相同的选项...
},
}你可以在自动检测和指定大小写不敏感性的原始工作以及更广泛的选项集中阅读更多详细信息。
详尽的 switch/case 补全
当编写 switch 语句时,TypeScript 现在会检测被检查的值是否具有字面量类型。 如果是,它将提供一个补全,为每个未覆盖的 case 生成骨架。

你可以在 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%

这是怎么做到的? 未来我们会更详细地介绍一些显著的改进。 但我们不会让你等待那篇博客文章。
首先,我们最近将 TypeScript 从命名空间迁移到模块,使我们能够利用现代构建工具执行作用域提升等优化。 使用这些工具,重新审视我们的打包策略,并删除一些弃用的代码,使 TypeScript 的包大小从 4.9 的 63.8 MB 减少了约 26.4 MB。 它还通过直接函数调用带来了显著的加速。
TypeScript 还在编译器内部为对象类型增加了更多的一致性,并且还精简了这些对象类型上存储的一些数据。 这减少了多态和巨态使用点,同时抵消了为统一形状所必需的大部分内存消耗。
我们还在将信息序列化为字符串时进行了一些缓存。 类型显示,作为错误报告、声明输出、代码补全等的一部分,可能相当昂贵。 TypeScript 现在缓存一些常用的机制,以便在这些操作之间重用。
我们做出的另一个显著改进是,利用 var 偶尔绕过在闭包中使用 let 和 const 的开销,从而改进了我们的解析器。 这提高了我们的一些解析性能。
总体而言,我们预计大多数代码库应该会从 TypeScript 5.0 中看到速度提升,并且我们一直能够重现 10% 到 20% 的改进。 当然,这将取决于硬件和代码库特性,但我们鼓励你今天就在你的代码库上尝试一下!
有关更多信息,请查看我们的一些显著优化:
破坏性更改和弃用
运行时要求
TypeScript 现在以 ECMAScript 2018 为目标。 对于 Node 用户,这意味着最低版本要求至少为 Node.js 10 及更高版本。
lib.d.ts 更改
DOM 类型生成方式的变化可能会影响现有代码。 值得注意的是,某些属性已从 number 转换为数字字面量类型,并且剪切、复制和粘贴事件处理的属性和方法已跨接口移动。
API 破坏性更改
在 TypeScript 5.0 中,我们迁移到模块,删除了一些不必要的接口,并进行了一些正确性改进。 有关更改的更多详细信息,请参阅我们的 API 破坏性更改页面。
关系运算符中禁止的隐式强制转换
如果编写可能导致隐式字符串到数字强制转换的代码,TypeScript 中的某些操作已经会警告你:
function func(ns: number | string) {
return ns * 4; // 错误,可能的隐式强制转换
}在 5.0 中,这也将应用于关系运算符 >、<、<= 和 >=:
function func(ns: number | string) {
return ns > 4; // 现在也是一个错误
}如果需要,你可以使用 + 显式将操作数强制转换为 number:
function func(ns: number | string) {
return +ns > 4; // 可以
}此正确性改进由 Mateusz Burzyński 贡献。
枚举彻底改造
自首次发布以来,TypeScript 在 enum 方面一直存在一些长期存在的奇怪行为。 在 5.0 中,我们正在清理其中的一些问题,并减少理解你可以声明的各种 enum 所需的概念数量。
作为其中的一部分,你可能会看到两个主要的新错误。 第一个是,将域外字面量赋值给 enum 类型现在会按预期报错:
enum SomeEvenDigit {
Zero = 0,
Two = 2,
Four = 4
}
// 现在正确报错
let m: SomeEvenDigit = 1;另一个是,声明某些类型的间接混合字符串/数字 enum 形式会错误地创建一个全数字 enum:
enum Letters {
A = "a"
}
enum Numbers {
one = 1,
two = Letters.A
}
// 现在正确报错
const t: number = Numbers.two;你可以在相关更改中查看更多详细信息。
在 --experimentalDecorators 下构造函数中参数装饰器的更准确类型检查
TypeScript 5.0 使 --experimentalDecorators 下的装饰器类型检查更加准确。 一个明显的地方是在构造函数参数上使用装饰器时。
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。 正确的解决方法是更改 inject 中 key 的类型。 如果你使用的库无法升级,一个合理的解决方法是,将 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。 旧的特定于操作系统的推断行为不再可用。
--forceConsistentCasingInFileNames 确保项目中同一文件名的所有引用大小写一致,现在默认为 true。 这有助于捕获在不区分大小写的文件系统上编写的代码的大小写差异问题。
你可以在5.0 弃用的跟踪问题上留下反馈并查看更多信息。