宣布 TypeScript 5.4 发布

今天,我们很高兴地宣布 TypeScript 5.4 版本的发布!

如果你对 TypeScript 不太熟悉,它是一种构建在 JavaScript 基础上的语言,通过允许声明和描述类型,提高了代码的可读性和可维护性。在代码中编写类型能够解释代码意图,并且其他工具可以检查我们的代码,捕捉诸如拼写错误、nullundefined 的问题等错误。类型还支持 TypeScript 编辑器工具,如自动补全、代码导航以及在 Visual Studio 和 VS Code 中可能看到的重构功能。实际上,如果你一直在这两个编辑器中编写 JavaScript 代码,那么你一直在使用 TypeScript!

要开始使用 TypeScript,请通过 NuGet 进行安装(NuGet链接)或者通过以下 npm 命令:

npm install -D typescript

以下是 TypeScript 5.4 中的一些新功能:

自 Beta 和 RC 版本以来的新功能有哪些?

自 Beta 版本以来,我们已更新发行说明以记录新的值得注意的行为变更,包括对枚举兼容性的限制、对枚举成员命名的限制以及映射类型行为的改进。

自发布候选版以来,我们已记录了我们的新的子路径导入的自动导入支持

在闭包中保留最后赋值的类型缩小

TypeScript 通常可以基于你可能执行的检查来为变量确定更具体的类型。这个过程称为类型缩小。

function uppercaseStrings(x: string | number) {
    if (typeof x === "string") {
        // TypeScript knows 'x' is a 'string' here.
        return x.toUpperCase();
    }
}

一个常见的痛点是这些缩小的类型在函数闭包中并不总是被保留。

function getUrls(url: string | URL, names: string[]) {
    if (typeof url === "string") {
        url = new URL(url);
    }

    return names.map(name => {
        url.searchParams.set("name", name)
        //  ~~~~~~~~~~~~
        // error!
        // Property 'searchParams' does not exist on type 'string | URL'.

        return url.toString();
    });
}

在这里,TypeScript 认为在回调函数中假定 url 实际上是 URL 对象是“不安全的”,因为它在其他地方被修改;然而,在这个例子中,箭头函数始终在对 url 的赋值之后创建,并且它也是对 url 的最后一次赋值。

TypeScript 5.4 利用这一点,使类型缩小更加智能。当参数和 let 变量在非提升的函数中使用时,类型检查器将寻找最后的赋值点。如果找到了,TypeScript 就可以安全地从包含函数的外部缩小。这意味着上面的例子现在可以正常工作。

请注意,如果变量在嵌套函数中的任何地方被赋值,缩小分析将不会启动。这是因为无法确定嵌套函数是否会在以后被调用。

function printValueLater(value: string | undefined) {
    if (value === undefined) {
        value = "missing!";
    }

    setTimeout(() => {
        // Modifying 'value', even in a way that shouldn't affect
        // its type, will invalidate type refinements in closures.
        value = value;
    }, 500);

    setTimeout(() => {
        console.log(value.toUpperCase());
        //          ~~~~~
        // error! 'value' is possibly 'undefined'.
    }, 1000);
}

这应该使得许多典型的 JavaScript 代码更容易表达。你可以在 GitHub 上阅读更多有关此更改的信息

NoInfer 实用类型

在调用泛型函数时,TypeScript 能够从你传入的参数中推断类型参数。

function doSomething<T>(arg: T) {
  // ...
}


// We can explicitly say that 'T' should be 'string'.
doSomething<string>("hello!");

// We can also just let the type of 'T' get inferred.
doSomething("hello!");

然而,有一个挑战,就是并不总是清楚推断的最佳类型是什么。这可能导致 TypeScript 拒绝有效的调用、接受可疑的调用,或者在捕获错误时提供更糟糕的错误消息。

例如,假设有一个 createStreetLight 函数,它接受一个颜色名称列表,以及一个可选的默认颜色。

function createStreetLight<C extends string>(colors: C[], defaultColor?: C) {
  // ...
}

createStreetLight(["red", "yellow", "green"], "red");

当我们传入一个原始颜色数组中没有的 defaultColor 时会发生什么?在这个函数中,colors 应该是“真相的源泉”,描述了可以传递给 defaultColor 的内容。

// Oops! This undesirable, but is allowed!
createStreetLight(["red", "yellow", "green"], "blue");

在这个调用中,类型推断决定 "blue" 是与 "red""yellow""green" 一样有效的类型。因此,TypeScript 不会拒绝这个调用,而是将 C 的类型推断为"red" | "yellow" | "green" | "blue"。你可以说推断在我们的脸上变成了蓝色!

人们目前处理这个问题的方式之一是添加一个由现有类型参数限定的单独的类型参数。

function createStreetLight<C extends string, D extends C>(colors: C[], defaultColor?: D) {
}

createStreetLight(["red", "yellow", "green"], "blue");
//                                            ~~~~~~
// error!
// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.

这样做是有效的,但有点尴尬,因为 D 可能在 createStreetLight 的签名中没有其他地方使用。虽然在这种情况下不算糟糕,但是在签名中只使用一次类型参数通常是一种代码异味。

因此,TypeScript 5.4 引入了新的 NoInfer<T> 实用类型。在 NoInfer<...> 中包裹一个类型,向 TypeScript 发出信号,不要深入匹配内部类型以找到类型推断的候选项。

使用 NoInfer,我们可以将 createStreetLight 重写为以下形式:

function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) {
  // ...
}

createStreetLight(["red", "yellow", "green"], "blue");
//                                            ~~~~~~
// error!
// Argument of type '"blue"' is not assignable to parameter of type '"red" | "yellow" | "green" | undefined'.

通过排除对 defaultColor 类型进行推断的探索,意味着 "blue" 永远不会成为推断的候选项,类型检查器可以拒绝它。

你可以在 实施拉取请求 中查看具体的更改,以及感谢 Mateusz Burzyński 提供的初始实现

Object.groupByMap.groupBy

TypeScript 5.4 添加了 JavaScript 新的 Object.groupByMap.groupBy 静态方法的声明。

Object.groupBy 接受一个可迭代对象和一个函数,该函数决定每个元素应该被放置在哪个“组”中。该函数需要为每个不同的组创建一个“键”,而 Object.groupBy 使用该键创建一个对象,其中每个键都映射到一个包含原始元素的数组。

因此,下面的 JavaScript 代码:

const array = [0, 1, 2, 3, 4, 5];

const myObj = Object.groupBy(array, (num, index) => {
    return num % 2 === 0 ? "even": "odd";
});

基本上等同于编写以下内容:

const myObj = {
    even: [0, 2, 4],
    odd: [1, 3, 5],
};

Map.groupBy 类似,但生成一个 Map 而不是普通对象。如果你需要 Map 的保证、正在处理期望 Map 的 API,或者需要使用任何一种可用作分组键的键,这可能更可取,而不仅仅是可以用作 JavaScript 属性名的键。

const myObj = Map.groupBy(array, (num, index) => {
  return num % 2 === 0 ? "even" : "odd";
});

就像以前一样,你可以以等效的方式创建 myObj

const myObj = new Map();

myObj.set("even", [0, 2, 4]);
myObj.set("odd", [1, 3, 5]);

请注意,在上述的 Object.groupBy 示例中,生成的对象使用了所有可选属性。

interface EvenOdds {
    even?: number[];
    odd?: number[];
}

const myObj: EvenOdds = Object.groupBy(...);

myObj.even;
//    ~~~~
// Error to access this under 'strictNullChecks'.

这是因为一般来说,无法保证 所有 的键都是由 groupBy 生成的。

还要注意,这些方法只能通过将 target 配置为 esnext 或调整你的 lib 设置来访问。我们预计它们最终将在稳定的 es2024 目标下可用。

我们要感谢 Kevin Gibbons 为这些 groupBy 方法添加声明

--moduleResolution bundler--module preserve 中支持 require() 调用

TypeScript 提供了一个名为 moduleResolution 的选项,称为 bundler,旨在模拟现代打包工具确定导入路径指向哪个文件的方式。该选项的一个限制是它必须与 --module esnext 配对使用,使得无法使用 import ... = require(...) 语法。

// previously errored
import myModule = require("module/path");

如果你计划只写标准的 ECMAScript import,这可能不是什么大不了的事,但是在使用具有条件导出的包时,就会有所不同。

在 TypeScript 5.4 中,当将 module 设置为一个名为 preserve 的新选项时,现在可以使用 require()

--module preserve--moduleResolution bundler 之间,两者更准确地模拟了打包工具和像 Bun 这样的运行时如何允许以及它们将如何执行模块查找。实际上,当使用 --module preserve 时,bundler 选项将隐式设置为 --moduleResolution(以及 --esModuleInterop--resolveJsonModule)。

{
    "compilerOptions": {
        "module": "preserve",
        // ^ also implies:
        // "moduleResolution": "bundler",
        // "esModuleInterop": true,
        // "resolveJsonModule": true,

        // ...
    }
}

--module preserve 下,ECMAScript 的 import 将始终原样发出,而 import ... = require(...) 将被发出为 require() 调用(尽管在实践中,你可能甚至不会使用 TypeScript 进行发出,因为很可能你会使用一个打包工具来处理你的代码)。这适用于包含文件的文件扩展名。因此,下面代码的输出:

import * as foo from "some-package/foo";
import bar = require("some-package/bar");

应该看起来像这样:

import * as foo from "some-package/foo";
var bar = require("some-package/bar");

这还意味着你选择的语法会指导如何匹配条件导出。因此,在上面的例子中,如果 some-packagepackage.json 如下所示:

{
  "name": "some-package",
  "version": "0.0.1",
  "exports": {
    "./foo": {
            "import": "./esm/foo-from-import.mjs",
            "require": "./cjs/foo-from-require.cjs"
    },
    "./bar": {
            "import": "./esm/bar-from-import.mjs",
            "require": "./cjs/bar-from-require.cjs"
    }
  }
}

TypeScript 将这些路径解析为 [...]/some-package/esm/foo-from-import.mjs[...]/some-package/cjs/bar-from-require.cjs

有关更多信息,你可以在此阅读有关这些新设置的信息

检查的导入属性和断言

导入属性和断言现在会根据全局的 ImportAttributes 类型进行检查。这意味着运行时现在可以更准确地描述导入属性。

// In some global file.
interface ImportAttributes {
    type: "json";
}

// In some other module
import * as ns from "foo" with { type: "not-json" };
//                                     ~~~~~~~~~~
// error!
//
// Type '{ type: "not-json"; }' is not assignable to type 'ImportAttributes'.
//  Types of property 'type' are incompatible.
//    Type '"not-json"' is not assignable to type '"json"'.

此更改要感谢 Oleksandr Tarasiuk

添加缺少参数的快速修复

TypeScript 现在提供了一种快速修复,用于向调用带有过多参数的函数添加新参数。

当 someFunction 用比期望的参数多两个参数调用 someHelperFunction 时,提供的快速修复。

快速修复应用后,someHelperFunction 中已添加了缺少的参数。

当在多个现有函数中传递新参数时,这可能很有用,而这在今天可能会很繁琐。

这个快速修复是由 Oleksandr Tarasiuk 提供的。

子路径导入的自动导入支持

在 Node.js 中,package.json 通过一个名为 imports 的字段支持 一种称为“子路径导入”的功能。这是在包内重新映射路径到其他模块路径的一种方式。从概念上讲,这与路径映射相当相似,是某些模块打包工具和加载器支持的功能(TypeScript 通过称为 paths 的功能也支持这个功能)。唯一的区别是子路径导入总是必须以 # 开头。

TypeScript 的自动导入功能以前并未考虑 imports 中的路径,这可能令人沮丧。相反,用户可能必须在他们的 tsconfig.json 中手动定义 paths。然而,多亏了 Emma Hamilton 的贡献,TypeScript 的自动导入现在支持子路径导入

从 TypeScript 5.0 弃用的即将到来的更改

TypeScript 5.0 弃用了以下选项和行为:

  • charset
  • target: ES3
  • importsNotUsedAsValues
  • noImplicitUseStrict
  • noStrictGenericChecks
  • keyofStringsOnly
  • suppressExcessPropertyErrors
  • suppressImplicitAnyIndexErrors
  • out
  • preserveValueImports
  • 项目引用中的 prepend
  • 隐式 OS 特定的 newLine

为了继续使用它们,使用 TypeScript 5.0 和其他更近期版本的开发人员必须指定一个名为 ignoreDeprecations 的新选项,其值为 "5.0"

然而,TypeScript 5.4 将是这些选项继续正常运行的最后一个版本。到 TypeScript 5.5(可能是2024年6月),它们将变成硬错误,需要将使用它们的代码迁移。

有关更多信息,你可以在 GitHub 上阅读有关此计划的信息,其中包含有关如何最好地调整代码库的建议。

值得注意的行为变化

这一部分突出了一组值得注意的更改,应该在升级的过程中予以注意和理解。有时它会强调弃用、删除和新的限制。它还可能包含对功能性改进的错误修复,但这也可能通过引入新的错误来影响现有的构建。

lib.d.ts 的更改

为 DOM 生成的类型可能会影响对代码库的类型检查。有关更多信息,请参见 TypeScript 5.4 的 DOM 更新

更准确的条件类型约束

以下代码不再允许在函数 foo 中的第二个变量声明。

type IsArray<T> = T extends any[] ? true : false;

function foo<U extends object>(x: IsArray<U>) {
    let first: true = x;    // Error
    let second: false = x;  // Error, but previously wasn't
}

以前,当 TypeScript 检查 second 的初始化程序时,它需要确定 IsArray<U> 是否可分配给单元类型 false。虽然 IsArray<U> 在任何明显的方式上都不兼容,但 TypeScript 还会查看该类型的 约束。在条件类型中,比如 T extends Foo ? TrueBranch : FalseBranch,其中 T 是泛型,类型系统会查看 T 的约束,将其替换为 T 本身,并决定是 true 分支还是 false 分支。

但这种行为是不准确的,因为它过于急切。即使 T 的约束不能分配给 Foo,这并不意味着它不会被实例化为某些可以的东西。因此,更正确的行为是在无法证明 T 永远总是 扩展 Foo 的情况下,为条件类型的约束生成联合类型。

TypeScript 5.4 采用了这种更准确的行为。实际上,这意味着你可能会发现一些条件类型实例不再与它们的分支兼容。

你可以在这里阅读有关具体更改的信息

更积极地减少类型变量与原始类型之间的交集

TypeScript 现在更积极地减少与类型变量和原始类型的交集,这取决于类型变量的约束与这些原始类型的重叠方式。

declare function intersect<T, U>(x: T, y: U): T & U;

function foo<T extends "abc" | "def">(x: T, str: string, num: number) {

    // Was 'T & string', now is just 'T'
    let a = intersect(x, str);

    // Was 'T & number', now is just 'never'
    let b = intersect(x, num)

    // Was '(T & "abc") | (T & "def")', now is just 'T'
    let c = Math.random() < 0.5 ?
        intersect(x, "abc") :
        intersect(x, "def");
}

有关更多信息,请参见此更改

改进对带有插值的模板字符串的检查

TypeScript 现在更准确地检查字符串是否可分配到模板字符串类型的占位符槽。

function a<T extends {id: string}>() {
    let x: `-${keyof T & string}`;

    // Used to error, now doesn't.
    x = "-id";
}

这种行为更可取,但可能会导致使用类似条件类型的代码中的断言失败。

有关此更改的详细信息

当类型仅导入与局部值冲突时报错

以前,如果 Something 的导入仅引用了一个类型,并且在 isolatedModules 下,TypeScript 将允许下面的代码。

import { Something } from "./some/path";

let Something = 123;

然而,对于单文件编译器来说,假设是否“安全”去除 import 是不安全的,即使代码在运行时保证失败。在 TypeScript 5.4 中,此代码将触发以下错误:

Import 'Something' conflicts with local value, so must be declared with a type-only import when 'isolatedModules' is enabled.

修复方法应该是进行本地重命名,或者如错误所述,向导入添加 type 修饰符:

import type { Something } from "./some/path";

// or

import { type Something } from "./some/path";

更多关于变更本身的信息

新的枚举可赋值性限制

当两个枚举具有相同的声明名称和枚举成员名称时,它们以前总是被认为是兼容的;然而,在值已知的情况下,TypeScript 会默默地允许它们具有不同的值。

TypeScript 5.4 通过要求在已知值时这些值相同来加强这一限制。

namespace First {
    export enum SomeEnum {
        A = 0,
        B = 1,
    }
}

namespace Second {
    export enum SomeEnum {
        A = 0,
        B = 2,
    }
}

function foo(x: First.SomeEnum, y: Second.SomeEnum) {
    // Both used to be compatible - no longer the case,
    // TypeScript errors with something like:
    //
    //  Each declaration of 'SomeEnum.B' differs in its value, where '1' was expected but '2' was given.
    x = y;
    y = x;
}

此外,对于其中一个枚举成员没有静态已知值的情况,存在新的限制。在这些情况下,另一个枚举必须至少是隐式数值的(例如,它没有静态解析的初始化程序),或者它是显式数值的(这意味着 TypeScript 可以将值解析为某个数值)。从实际操作的角度来看,这意味着字符串枚举成员仅与具有相同值的其他字符串枚举兼容。

namespace First {
    export declare enum SomeEnum {
        A,
        B,
    }
}

namespace Second {
    export declare enum SomeEnum {
        A,
        B = "some known string",
    }
}

function foo(x: First.SomeEnum, y: Second.SomeEnum) {
    // Both used to be compatible - no longer the case,
    // TypeScript errors with something like:
    //
    //  One value of 'SomeEnum.B' is the string '"some known string"', and the other is assumed to be an unknown numeric value.
    x = y;
    y = x;
}

有关更多信息,请查看引入此更改的拉取请求

枚举成员的名称限制

TypeScript 不再允许枚举成员使用名称 Infinity-InfinityNaN

// Errors on all of these:
//
//  An enum member cannot have a numeric name.
enum E {
    Infinity = 0,
    "-Infinity" = 1,
    NaN = 2,
}

在这里查看更多详情

在带有 any 剩余元素的元组上更好地保留映射类型

以前,在元组中应用带有 any 的映射类型会创建一个 any 元素类型。这是不可取的,现在已经修复。

Promise.all(["", ...([] as any)])
    .then((result) => {
        const head = result[0];       // 5.3: any, 5.4: string
        const tail = result.slice(1); // 5.3 any, 5.4: any[]
    });

有关更多信息,请查看修复,以及关于行为更改的后续讨论进一步调整

发射更改

虽然不是破坏性的更改,但开发人员可能已经隐式地依赖于 TypeScript 的 JavaScript 或声明发射输出。以下是值得注意的更改。

接下来是什么?

在接下来的几个月里,我们将致力于 TypeScript 5.5,你可以在 GitHub 上查看我们的迭代计划。我们的目标发布日期是公开的,因此你、你的团队和更广泛的 TypeScript 社区都可以相应地安排计划。你也可以在 npm 上尝试使用夜间版本,或者在 Visual Studio Code 中使用 TypeScript 和 JavaScript 的最新版本

但在那之前,TypeScript 5.4 仍然是最新且最稳定的版本,我们希望它为你编码带来愉悦!

愉快的编码!

– Daniel Rosenwasser 和 TypeScript 团队

2024-03-08

访问量 116

扫码关注公众号“前端微志”

第一时间获取新周刊

预览图片