TypeScript 5.0 正式发布及使用指南详解

JavaScript/前端
321
0
0
2023-04-17
标签   TypeScript
目录
  • 正文
  • 全新装饰器
  • const 类型参数
  • extends 支持多配置文件
  • 所有枚举都是联合枚举
  • --moduleResolution
  • 自定义解析标志
  • allowImportingTsExtensions
  • resolvePackageJsonExports
  • resolvePackageJsonImports
  • allowArbitraryExtensions
  • customConditions
  • --verbatimModuleSyntax
  • 支持 export type *
  • JSDoc 支持 @satisfies
  • JSDoc 支持 @overload
  • 编辑器中不区分大小写的导入排序
  • 完善 switch/case
  • 速度、内存和包大小优化
  • 其他重大更改和弃用
  • 运行时要求
  • lib.d.ts 变化
  • API 重大变更
  • 关系运算符中的禁止隐式强制
  • 弃用和默认更改

正文


2023 年 3 月 17 日,TypeScript 5.0 正式发布!此版本带来了许多新功能,旨在使 TypeScript 更小、更简单、更快。TypeScript 5.0 实现了新的装饰器标准、更好地支持 Node 和打构建工具中的 ESM 项目的功能、库作者控制泛型推导的新方法、扩展了 JSDoc 功能、简化了配置,并进行了许多其他改进。

可以通过以下 npm 命令开始使用 TypeScript 5.0:

npm install -D typescript

以下是 TypeScript 5.0 的主要更新:

  • 全新装饰器
  • const 类型参数
  • extends 支持多配置文件
  • 所有枚举都是联合枚举
  • --moduleResolutionbundler
  • 自定义解析标志
  • --verbatimModuleSyntax
  • 支持 export type *
  • JSDoc 支持 @satisfies
  • JSDoc 支持 @overload
  • 编辑器中不区分大小写的导入排序
  • 完善 switch/case
  • 优化速度、内存和包大小
  • 其他重大更改和弃用

全新装饰器

装饰器是即将推出的 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,可以暂时忽略,这样可以让例子尽可能得简单。

这里,loggedMethod 需要传入一个参数(originalMethod) 并返回一个函数。执行过程如下:

  • 打印:LOG: Entering method.
  • 将 this 及其所有参数传递给原始方法
  • 打印:LOG: Exiting method.
  • 返回原始方法的执行结果

现在我们就可以使用 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.

这里我们在 greet 上面使用了 loggedMethod 作为装饰器——注意这里的写法:@loggedMethod。这样,它会被原始方法和 context 对象调用。因为 loggedMethod 返回了一个新函数,该函数替换了 greet 的原始定义。

loggedMethod 的第二个参数被称为“ context 对象”,它包含一些关于如何声明装饰方法的有用信息——比如它是 #private 成员还是静态成员,或者方法的名称是什么。 下面来重写 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;
}

TypeScript 提供了一个名为 ClassMethodDecoratorContext 的类型,它对方法装饰器采用的 context 对象进行建模。除了元数据之外,方法的 context 对象还有一个有用的函数:addInitializer。 这是一种挂接到构造函数开头的方法(如果使用静态方法,则挂接到类本身的初始化)。

举个例子,在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作为独立函数调用或作为回调函数传递时不会重新绑定。

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'.

装饰器可不仅仅用于方法,还可以用于属性/字段、gettersetter和自动访问器。甚至类本身也可以装饰成子类化和注册。

上面的loggedMethodbound装饰器示例写的很简单,并省略了大量关于类型的细节。实际上,编写装饰器可能相当复杂。例如,上面的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;
}

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

具体定义装饰器函数的复杂程度取决于想要保证什么。需要记住,装饰器的使用次数将超过它们的编写次数,所以类型良好的版本通常是更好的——但显然与可读性有一个权衡,所以请尽量保持简单。

const 类型参数

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

type HasNames = { readonly names: string[] };
function getNamesExactly<T extends HasNames>(arg: T): T["names"] {
    return arg.names;
}
// names 的推断类型为 string[]
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"]});

通常这样做的目的是实现突变。然而,根据getnames确切的作用以及它的使用方式,通常情况下需要更具体的类型。到目前为止,通常不得不在某些地方添加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修饰符只影响在调用中编写的对象、数组和基本类型表达式的推断,所以不会(或不能)用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",
        // ...
    }
}

但是,在某些情况下,可能希望从多个配置文件进行扩展。例如,想象一下使用一个TypeScript 基本配置文件到 npm。如果想让所有的项目也使用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。 如果任何字段“冲突”,则后一个项生效。

所以在下面的例子中,strictNullChecksnoImplicitAny 都会在最终的 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",
        // ...
    }
}

所有枚举都是联合枚举

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

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

E.Foo 和 E.Bar 唯一的特别之处在于它们可以分配给任何期望类型 E 的东西。除此之外,它们只是数字。

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 {
    // 缩小字面量类型可以捕获bug
		// TypeScript在这里会报错,因为
		// 最终会比较 Color.Red 和 Color.Green。
		// 本想使用||,但不小心写了&&
    return c === Color.Red && c === Color.Green && c === Color.Blue;
}

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

enum E {
    Blah = Math.random()
}

每当TypeScript遇到这些问题时,它都会悄无声息地退出并使用旧的枚举策略。这意味着要放弃并集和字面量类型的所有优点。

TypeScript 5.0 通过为每个计算成员创建唯一的类型,设法将所有枚举转换为联合枚举。这意味着现在可以缩小所有枚举的范围,并将其成员作为类型引用。

--moduleResolution

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 查找规则的融合。

为了模拟打包工具是如何工作的,TypeScript 5.0 引入了一个新策略:--moduleResolution bundler

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

如果正在使用现代打包工具,如 Vite、esbuild、swc、Webpack、Parcel 或其他实现混合查找策略的打包工具,那么新的 bundler 选项应该非常适合你。

另一方面,如果正在编写一个打算在 npm 上发布的库,使用bundler选项可以隐藏不使用bundler的用户可能出现的兼容性问题。因此,在这些情况下,使用node16nodenext解析选项可能是更好的方法。

自定义解析标志

JavaScript 工具现在可以模拟“混合”解析规则,就像上面描述的打包工具模式一样。 由于工具的支持可能略有不同,TypeScript 5.0 提供了启用或禁用一些功能的方法。

allowImportingTsExtensions

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

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

resolvePackageJsonExports

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

resolvePackageJsonImports

--resolvePackageJsonImports 强制 TypeScript 在从其祖先目录包含 package.json 的文件执行以 # 开头的查找时查询 package.json 文件的 imports 字段。

--moduleResolutionnode16nodenextbundler 选项下,此选项默认为 true。

allowArbitraryExtensions

在 TypeScript 5.0 中,当导入路径以不是已知 JavaScript 或 TypeScript 文件扩展名的扩展名结尾时,编译器将以 {file basename}.d.{extension} 的形式查找该路径的声明文件。例如,如果在打包项目中使用 CSS loader,可能希望为这些样式表编写(或生成)声明文件:

/* 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 支持中包含扩展名,所以在我们的例子中,TypeScript 会在 --moduleResolution node16 或 nodenext 下的 ESM 文件中出错。

customConditions

--customConditions 获取当 TypeScript 从 package.json 的 [exports] 或 (nodejs.org/api/package…) 或 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对应的文件。这个字段只有在 node16、nodenext 和--modulerresolution为 bundler 时才有效。

--verbatimModuleSyntax

默认情况下,TypeScript 会执行一些称为导入省略的操作。如果这样写:

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

TypeScript 检测到只对类型使用导入并完全删除导入。输出 JavaScript 可能是这样的:

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

大多数时候这很好,因为如果 Car 不是从 ./car 导出的值,将得到一个运行时错误。但对于某些边界情况,它确实增加了一层复杂性。例如,没有像 import "./car" 这样的语句,即完全放弃了 import,这实际上对有无副作用的模块产生影响。

TypeScript 的 JavaScript emit 策略也有另外几层复杂性——省略导入并不总是由如何使用 import 驱动的,它通常还会参考值的声明方式。所以并不总是很清楚是否像下面这样的代码:

export { Car } from "./car";

如果 Car 是用类之类的东西声明的,那么它可以保存在生成的 JavaScript 文件中。 但是,如果 Car 仅声明为类型别名或接口,则 JavaScript 文件不应导出 Car。

虽然 TypeScript 可能能够根据来自跨文件的信息做出这些发出决策,但并非每个编译器都可以。

imports 和 exports 的类型修饰符在这些情况下会有帮助。我们可以明确指定importexport仅用于类型分析,并且可以在JavaScript文件中使用类型修饰符完全删除。

// 这条语句可以在JS输出中完全删除
import type * as car from "./car";
// 在JS输出中可以删除命名的import/export Car
import { type Car } from "./car";
export { type Car } from "./car";

类型修饰符本身并不是很有用——默认情况下,模块省略仍然会删除导入,并且没有强制区分类型和普通导入和导出。 因此 TypeScript 有标志 --importsNotUsedAsValues 以确保使用 type 修饰符,--preserveValueImports 以防止某些模块省略行为,以及 --isolatedModules 以确保 TypeScript 代码适用于不同的编译器。 不幸的是,很难理解这 3 个标志的细节,并且仍然存在一些具有意外行为的边界情况。

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 导入和导出不会被重写为 require 调用。相反,会得到一个错误。 如果需要生成使用 requiremodule.exports 的代码,则必须使用早于 ES2015 的 TypeScript 模块语法:

虽然这是一个限制,但它确实有助于使一些问题更加明显。 例如,忘记在 --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) {
  //  ✅
}
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 知道 myCompilerOptions.extends 是用数组声明的,因为虽然 satisfies 验证了对象的类型,但它并没有直接将其更改为 CompilerOptions 而丢失信息。所以如果想映射到 extends 上,是可以的。

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

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

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

// @ts-check
/**
 * @typedef CompilerOptions
 * @prop {boolean} [strict]
 * @prop {string} [outDir]
 */
/**
 * @satisfies {CompilerOptions}
 */
let myCompilerOptions = {
    outdir: "../lib",
//  ~~~~~~ oops! we meant 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} */ ({
    // ...
}));

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 将字符串或数字作为第一个参数。如果它需要一个数字,它可以使用第二个参数来确定可以打印多少个小数位。

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); // ❌

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

在 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": {
        // 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 现在会检测被检查的值何时具有字面量类型。以提供更便利的代码快捷输入:

速度、内存和包大小优化

TypeScript 5.0 在代码结构、数据结构和算法实现中包含许多强大的变化。这些都意味着整个体验应该更快——不仅仅是运行 TypeScript,甚至安装它。

以下是相对于 TypeScript 4.9 在速度和大小方面的优势:

场景

时间或大小相对于 TS 4.9

material-ui 构建时间

90%

TypeScript 编译器启动时间

89%

Playwright 构建时间

88%

TypeScript 编译器自构建时间

87%

Outlook Web 构建时间

82%

VS Code 构建时间

80%

TypeScript npm 包大小

59%

图表形式:

TypeScript 包大小变化:

那为什么会有如此大的提升呢?部分优化细节如下:

首先,将 TypeScript 从命名空间迁移到模块,这样就能够利用现代构建工具来执行优化。重新审视了打包策略并删除一些已弃用的代码,已将 TypeScript 4.9 的 63.8 MB 包大小减少了约 26.4 MB。还通过直接函数调用带来了显著的速度提升。

在将信息序列化为字符串时,执行了一些缓存。 类型显示可能作为错误报告、声明触发、代码补全等的一部分发生,最终可能会相当昂贵。TypeScript 现在缓存了一些常用的机制以在这些操作中重用。

总的来说,预计大多数代码库应该会看到 TypeScript 5.0 的速度提升,并且始终能够重现 10% 到 20% 之间的提升。当然,这将取决于硬件和代码库特性。

其他重大更改和弃用

运行时要求

TypeScript 现在的 target 是 ECMAScript 2018。TypeScript 软件包还将预期的最低引擎版本设置为 12.20。对于 Node.js 用户来说,这意味着 TypeScript 5.0 需要至少Node.js 12.20 或更高版本才能运行。

lib.d.ts 变化

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

API 重大变更

在 TypeScript 5.0 中, 转向了模块,删除了一些不必要的接口,并进行了一些正确性改进。

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

如果编写的代码可能导致隐式字符串到数字的强制转换,TypeScript 中的某些操作现在会进行警告:

function func(ns: number | string) {
  return ns * 4; // 错误,可能存在隐式强制转换
}

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

function func(ns: number | string) {
  return ns > 4;
}

如果需要这样做,可以使用+显式地将操作数转换为数字:

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

弃用和默认更改

在 TypeScript 5.0 中,弃用了以下设置和设置值:

  • --target: ES3
  • --out
  • --noImplicitUseStrict
  • --keyofStringsOnly
  • --suppressExcessPropertyErrors
  • --suppressImplicitAnyIndexErrors
  • --noStrictGenericChecks
  • --charset
  • --importsNotUsedAsValues
  • --preserveValueImports

在 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。 这有助于捕获在不区分大小写的文件系统上编写的代码的差异问题。

参考资料