TypeScript 5.0

装饰器 Decorators

装饰器是即将到来的 ECMAScript 特性,它允许我们定制可重用的类以及类成员。

考虑如下的代码:

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

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

const p = new Person('Ron');
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)作为参数并返回一个函数:

  1. 打印 "Entering…" 消息
  2. this 值以及所有的参数传递给原方法
  3. 打印 "Exiting..." 消息,并且
  4. 返回原方法的返回值。

现在可以使用 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('Ron');
p.greet();

// 输出:
//
//   LOG: Entering method.
//   Hello, my name is Ron.
//   LOG: Exiting method.

我们刚刚在 greet 上使用了 loggedMethod 装饰器 - 注意一下写法 @loggedMethod。 这样做之后,loggedMethod 被调用时会传入被装饰的目标 target 以及一个上下文对象 context object 作为参数。 因为 loggedMethod 返回了一个新函数,因此这个新函数会替换掉 greet 的原始定义。

loggedMethod 的定义中带有第二个参数。 它就是上下文对象 context object,包含了一些有关于装饰器声明细节的有用信息 - 例如是否为 #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;
}

我们使用了上下文参数。 TypeScript 提供了名为 ClassMethodDecoratorContext 的类型用于描述装饰器方法接收的上下文对象。

除了元数据外,上下文对象中还提供了一个有用的函数 addInitializer。 它提供了一种方式来 hook 到构造函数的起始位置。

例如在 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}.`);
  };
}

这类代码的目的是确保 this 值不会被重新绑定,当 greet 被独立地调用或者在用作回调函数时。

const greet = new Person('Ron').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('Ron');
const greet = p.greet;

// Works!
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('Ron');
p.greet();

// Output:
//
//    Entering method 'greet'.
//   Hello, my name is Ron.
//    Exiting method 'greet'.

装饰器不仅可以用在方法上! 它们也可以被用在属性/字段,存取器(getter/setter)以及自动存取器。 甚至,类本身也可以被装饰,用于处理子类化和注册。

想深入了解装饰器,可以阅读 Axel Rauschmayer 的文章

更多详情请参考 PR

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

如果你有一定的 TypeScript 经验,你会发现 TypeScript 多年前就已经支持了“实验性的”装饰器特性。 虽然实验性的装饰器非常地好用,但是它实现的是旧版本的装饰器规范,并且总是需要启用 --experimentalDecorators 编译器选项。 若没有启用它并且使用了装饰器,TypeScript 会报错。

在未来的一段时间内,--experimentalDecorators 依然会存在; 然而,如果不使用该标记,在新代码中装饰器语法也是合法的。 在 --experimentalDecorators 之外,它们的类型检查和代码生成方式也不同。 类型检查和代码生成规则存在巨大差异,以至于虽然装饰器可以被定义为同时支持新、旧装饰器的行为,但任何现有的装饰器函数都不太可能这样做。

新的装饰器提案与 --emitDecoratorMetadata 的实现不兼容,并且不支持在参数上使用装饰器。 未来的 ECMAScript 提案可能会弥补这个差距。

最后要注意的是:除了可以在 export 关键字之前使用装饰器,还可以在 export 或者 export default 之后使用。 但是不允许混合使用两种风格。

//  allowed
@register
export default class Foo {
  // ...
}

//  also allowed
export default
@register
class Bar {
  // ...
}

//  error - before *and* after is not allowed
@before
@after
export class Bar {
  // ...
}

编写强类型的装饰器

上面的例子 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;
}

我们必须分别给原方法的 this、形式参数和返回值添加类型,上面使用了类型参数 ThisArgs 以及 Return。 装饰器函数到底有多复杂取决于你要确保什么。 但要记住,装饰器被使用的次数远多于被编写的次数,因此强类型的版本是通常希望得到的 - 但我们需要在可读性之间做出取舍,因此要尽量保持简洁。

未来会有更多关于如何编写装饰器的文档 - 但是这篇文章详细介绍了装饰器的工作方式。

const 类型参数

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

type HasNames = { readonly names: 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 来达到预期的类型推断目的:

// 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 形式的类型推断成为默认行为:

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 修饰符不会拒绝可修改的值,并且不需要不可变约束。 使用可变类型约束可能会产生令人惊讶的结果。

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[]

declare function fnGood<const T extends readonly string[]>(args: T): void;

// T is 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' is still 'string[]'-- the 'const' modifier has no effect here
fnGood(arr);

更多详情请参考 PRPRPR

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/strictesttsconfig.base.json

为了提高灵活性,TypeScript 5.0 允许 extends 字段指定多个值。 例如,有如下的配置文件:

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

这样写就如同是直接继承 c,而 c 继承于 bb 继承于 a。 如果出现冲突,后来者会被采纳。

在下面的例子中,在最终的 tsconfig.jsonstrictNullChecksnoImplicitAny 会被启用。

// 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"
    // ...
  }
}

更多详情请参考:PR

所有的 enum 均为联合 enum

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

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

E.FooE.Bar 唯一特殊的地方在于它们可以赋值给任何期望类型为 E 的地方。 除此之外,它们基本上等同于 number 类型。

function takeValue(e: E) {}

takeValue(E.Foo); // works
takeValue(123); // error!

直到 TypeScript 2.0 引入了枚举字面量类型,枚举才变得更为特殊。 枚举字面量类型为每个枚举成员提供了其自己的类型,并将枚举本身转换为每个成员类型的联合类型。 它们还允许我们仅引用枚举中的一部分类型,并细化掉那些类型。

// 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;
}

为每个枚举成员提供其自己的类型的一个问题是,这些类型在某种程度上与成员的实际值相关联。 在某些情况下,无法计算该值 - 例如,枚举成员可能由函数调用初始化。

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

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

TypeScript 5.0 通过为每个计算成员创建唯一类型,成功将所有枚举转换为联合枚举。 这意味着现在所有枚举都可以被细化,并且每个枚举成员都有其自己的类型。

更多详情请参考 PR

--moduleResolution bundler

TypeScript 4.7 支持将 --module--moduleResolution 选项设置为 node16nodenext。 这些选项的目的是更好地模拟 Node.js 中 ECMAScript 模块的精确查找规则; 然而,这种模式存在许多其他工具实际上并不强制执行的限制。

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

// entry.mjs
import * as utils from './utils'; //  wrong - we need to include the file extension.

import * as utils from './utils.mjs'; //  works

对于 Node.js 和浏览器来说,这样做有一些原因 - 它可以加快文件查找速度,并且对于简单的文件服务器效果更好。 但是对于许多使用打包工具的开发人员来说,node16 / nodenext 设置很麻烦, 因为打包工具中没有这么多限制。 在某些方面,node 解析模式对于任何使用打包工具的人来说是更好的。

但在某些方面,原始的 node 解析模式已经过时了。 大多数现代打包工具在 Node.js 中使用 ECMAScript 模块和 CommonJS 查找规则的融合。 例如,像在 CommonJS 中一样,无扩展名的导入也可以正常工作,但是在查找包的导出条件时,它们将首选像在 ECMAScript 文件中一样的 import 条件。

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

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

如果你使用如 Vite, esbuild, swc, Webpack, parcel 等现代打包工具,它们实现了混合的查找策略,新的 bundler 选项是更好的选择。

另一方面,如果您正在编写一个要发布到 npm 的代码库,那么使用 bundler 选项可能会隐藏影响未使用打包工具用户的兼容性问题。 因此,在这些情况下,使用 node16nodenext 解析选项可能是更好的选择。

更多详情请参考 PR

定制化解析的标记

JavaScript 工具现在可以模拟“混合”解析规则,就像我们上面描述的 bundler 模式一样。 由于工具的支持可能有所不同,因此 TypeScript 5.0 提供了启用或禁用一些功能的方法,这些功能可能无法与您的配置一起使用。

allowImportingTsExtensions

--allowImportingTsExtensions 允许 TypeScript 文件导入使用了 TypeScript 特定扩展名的文件,例如 .ts, .mts, .tsx

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

resolvePackageJsonExports

--resolvePackageJsonExports 强制 TypeScript 使用 package.json 里的 exports 字段,如果它尝试读取 node_modules 里的某个包。

--moduleResolutionnode16, nodenextbundler 时,该选项的默认值为 true

resolvePackageJsonImports

--resolvePackageJsonImports 强制 TypeScript 使用 package.json 里的 imports 字段,当它查找以 # 开头的文件时,且该文件的父目录中包含 package.json 文件。

--moduleResolutionnode16, nodenextbundler 时,该选项的默认值为 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.js 中 CommonJS 的 require 解析规则下可以工作。 严格来说,前者被解析为名为 app.css.js 的 JavaScript 文件的声明文件。 由于 Node 中的 ESM 需要使用包含扩展名的相对文件导入,因此在 --moduleResolutionnode16nodenext 时,TypeScript 会在示例的 ESM 文件中报错。

更多详情请参考 PR PR

customConditions

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

例如,有如下的配置:

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

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

所以当从具有如下 package.json 的包中导入时:

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

TypeScript 会尝试查找 foo.mjs 文件。

该字段仅在 --moduleResolutionnode16, nodenextbundler 时有效。

--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 是使用类型别名或 interface 声明的,那么在生成的 JavaScript 代码中会被省略。

尽管 TypeScript 可以根据多个文件来综合判断如何生成代码,但不是所有的编译器都能够做到。

导入和导出语句中的 type 修饰符能够起到一点作用。 我们可以使用 type 修饰符明确声明导入和导出是否仅用于类型分析,并且可以在生成的 JavaScript 文件中完全删除。

// 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 修饰符本身并不是特别管用 - 默认情况下,导入省略仍会删除导入语句, 并且不强制要求您区分类型导入和普通导入以及导出。 因此,TypeScript 提供了 --importsNotUsedAsValues 来确保您使用类型修饰符, --preserveValueImports 来防止某些模块消除行为, 以及 --isolatedModules 来确保您的 TypeScript 代码在不同编译器中都能正常运行。 不幸的是,理解这三个标志的细节很困难,并且仍然存在一些意外行为的边缘情况。

TypeScript 5.0 提供了一个新的 --verbatimModuleSyntax 来简化这个情况。 规则很简单 - 所有不带 type 修饰符的导入导出语句会被保留。 任何带有 type 修饰符的导入导出语句会被删除。

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

import foo = require('foo');

// ==>

const foo = require('foo');
function foo() {}
function bar() {}
function baz() {}

export = {
  foo,
  bar,
  baz,
};

// ==>

function foo() {}
function bar() {}
function baz() {}

module.exports = {
  foo,
  bar,
  baz,
};

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

因为 --verbatimModuleSyntax 相比于 --importsNotUsedAsValues--preserveValueImports 提供了更加一致的行为,推荐使用前者,后两个标记将被弃用。

更多详情请参考 PRissue.

支持 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` 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'.
}

更多详情请参考 PR

支持 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 会验证对象的类型。 因此,如果我们想在 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',
  //  ~~~~~~ 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 */ 也可以在行内的括号表达式上使用。 可以像下面这样定义 myConfigSettings

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

为什么?当你更深入地研究其他代码时,比如函数调用,它通常更有意义。

compileCode(
  /** @satisfies {ConfigSettings} */ {
    // ...
  }
);

更多详情请参考 PR。 感谢作者 Oleksandr Tarasiuk

支持 JSDoc 中的 @overload

在 TypeScript 中,你可以为一个函数指定多个重载。 使用重载能够描述一个函数可以使用不同的参数进行调用,也可能会返回不同的结果。 它们可以限制调用方如何调用函数,并细化他们将得到的结果。

// 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 来声明重载。 每一个 JSDoc @overload 标记都表示一个不同的函数重载。

// @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 都能够提示函数调用是否正确。

// all allowed
printValue('hello!');
printValue(123.45);
printValue(123.45, 2);

printValue('hello!', 123); // error!

更多详情请参考 PR,感谢 Tomasz Lenarcik

--build 模式下使用有关文件生成的选项

TypeScript 现在允许在 --build 模式下使用如下选项:

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

这使得在构建过程中定制某些部分变得更加容易,特别是在你可能会有不同的开发和生产构建时。

例如,一个库的开发构建可能不需要生成声明文件,但是生产构建则需要。 一个项目可以将生成声明文件配置为默认关闭,并使用如下方式构建:

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

开发完毕后,在“生产环境”构建时使用 --declaration 选项:

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

更多详情请参考 PR

编辑器导入语句排序时不区分大小写

在 Visual Studio 和 VS Code 等编辑器中,TypeScript 可以帮助组织和排序导入和导出语句。 不过,通常情况下,对于何时将列表“排序”,可能会有不同的解释。

例如,下面的导入列表是否已排序?

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

令人惊讶的是,答案可能是“这要看情况”。 如果我们不考虑大小写敏感性,那么这个列表显然是没有排序的。 字母f排在tT之前。

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

以前,TypeScript 认为导入列表已排序,因为它进行了基本的大小写敏感排序。 这可能让开发人员感到沮丧,因为他们更喜欢不区分大小写的排序方式,或者使用像 ESLint 这样的工具默认需要不区分大小写的排序方式。

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

我们的团队还在尝试更多的排序策略,你可以在这里了解更多。 这些选项可能最终可以由编辑器进行配置。 目前,它们仍然不稳定和实验性的,你可以通过在 JSON 选项中使用 typescript.unstable 条目来选择它们。 下面是你可以尝试的所有选项(设置为它们的默认值):

{
  "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...
  }
}

更多详情请参考 PRPR

穷举式 switch/case 自动补全

在编写 switch 语句时,TypeScript 现在会检测被检查的值是否具有字面量类型。 如果是,它将提供一个补全选项,可以为每个未覆盖的情况构建骨架代码。

更多详情请参考 PR

速度,内存以及代码包尺寸优化

TypeScript 5.0 在我们的代码结构、数据结构和算法实现方面进行了许多强大的变化。 这些变化的意义在于,整个体验都应该更快 —— 不仅仅是运行 TypeScript,甚至包括安装 TypeScript。

以下是我们相对于 TypeScript 4.9 能够获得的一些有趣的速度和大小优势。

ScenarioTime or Size Relative to TS 4.9
material-ui build time90%
TypeScript Compiler startup time89%
Playwright build time88%
TypeScript Compiler self-build time87%
Outlook Web build time82%
VS Code build time80%
typescript npm Package Size59%

img

img

怎么做到的呢?我们将在未来的博客文章中详细介绍一些值得注意的改进。 但我们不会让你等到那篇博客文章。

首先,我们最近将 TypeScript 从命名空间迁移到了模块,这使我们能够利用现代构建工具来执行像作用域提升这样的优化。 使用这些工具,重新审视我们的打包策略,并删除一些已过时的代码,使 TypeScript 4.9 的 63.8 MB 包大小减少了约 26.4 MB。 这也通过直接函数调用为我们带来了显著的加速。 我们在这里撰写了关于我们迁移到模块的详细介绍

TypeScript 还在编译器内部对象类型上增加了更多的一致性,并且也减少了一些这些对象类型上存储的数据。 这减少了多态操作,同时平衡了由于使我们的对象结构更加一致而带来的内存使用增加。

我们还在将信息序列化为字符串时执行了一些缓存。 类型显示,它可能在错误报告、声明生成、代码补全等情况下使用,是非常昂贵的操作。 TypeScript 现在对一些常用的机制进行缓存,以便在这些操作之间重复使用。

我们进行了一个值得注意的改变,改善了我们的解析器,即在某些情况下,利用 var 来避免在闭包中使用 let 和 const 的成本。 这提高了一些解析性能。

总的来说,我们预计大多数代码库应该会从 TypeScript 5.0 中看到速度的提升,并且一直能够保持 10% 到 20% 之间的优势。 当然,这将取决于硬件和代码库的特性,但我们鼓励你今天就在你的代码库上尝试它!

更多详情: