头图

TypeScript 5.8 Beta 新特性解析

原文链接:https://devblogs.microsoft.com/typescript/announcing-typescript-5-8-beta/
作者:Daniel Rosenwasser
译者:倔强青铜三

前言

大家好,我是倔强青铜三。作为一名热情的软件工程师,我热衷于分享和传播IT技术,致力于通过我的知识和技能推动技术交流与创新。欢迎关注我的微信公众号:倔强青铜三。如果你觉得这篇文章对你有帮助,别忘了点赞、收藏和关注哦,一键三连支持我吧!

TypeScript 5.8 Beta版本已经发布,带来了许多令人兴奋的新特性和改进。本文将详细介绍这些新特性,帮助你更好地了解和使用TypeScript 5.8。

条件类型和索引访问类型的检查返回值

假设有一个API,允许用户选择多个选项:

/**
 * @param prompt 提示文本
 * @param selectionKind 用户可以选择单个或多个选项
 * @param items 提供给用户的选择项
 **/
async function showQuickPick(
    prompt: string,
    selectionKind: SelectionKind,
    items: readonly string[],
): Promise<string | string[]> {
    // ...
}

enum SelectionKind {
    Single,
    Multiple,
}

showQuickPick函数的意图是根据selectionKind参数决定返回单个字符串还是字符串数组。然而,当前的类型签名并没有明确这一点,它只是返回string | string[]。这导致调用者需要手动检查返回值类型。

例如,以下代码中,我们期望shoppingList是一个字符串数组,但实际上它的类型是string | string[],导致调用join方法时报错:

let shoppingList = await showQuickPick(
    "Which fruits do you want to purchase?",
    SelectionKind.Multiple,
    ["apples", "oranges", "bananas", "durian"],
);

console.log(`Alright, going out to buy some ${shoppingList.join(", ")}`);
// error!
// Property 'join' does not exist on type 'string | string[]'.

为了更精确地定义返回类型,我们可以使用条件类型:

type QuickPickReturn<S extends SelectionKind> =
    S extends SelectionKind.Multiple ? string[] : string;

async function showQuickPick<S extends SelectionKind>(
    prompt: string,
    selectionKind: S,
    items: readonly string[],
): Promise<QuickPickReturn<S>> {
    // ...
}

这样,调用者可以正确地推断返回类型:

// `SelectionKind.Multiple` 返回 `string[]` - 正确 ✅
let shoppingList: string[] = await showQuickPick(
    "Which fruits do you want to purchase?",
    SelectionKind.Multiple,
    ["apples", "oranges", "bananas", "durian"],
);

// `SelectionKind.Single` 返回 `string` - 正确 ✅
let dinner: string = await showQuickPick(
    "What's for dinner tonight?",
    SelectionKind.Single,
    ["sushi", "pasta", "tacos", "ugh I'm too hungry to think, whatever you want"],
);

但当我们尝试实现showQuickPick时,TypeScript会报错:

async function showQuickPick<S extends SelectionKind>(
    prompt: string,
    selectionKind: S,
    items: readonly string[],
): Promise<QuickPickReturn<S>> {
    if (items.length < 1) {
        throw new Error("At least one item must be provided.");
    }

    let buttons = items.map(item => ({
        selected: false,
        text: item,
    }));

    if (selectionKind === SelectionKind.Single) {
        buttons[0].selected = true;
    }

    const selectedItems = buttons
        .filter(button => button.selected)
        .map(button => button.text);

    if (selectionKind === SelectionKind.Single) {
        return selectedItems[0];
    } else {
        return selectedItems;
    }
}

TypeScript会报错:

Type 'string[]' is not assignable to type 'QuickPickReturn<S>'.
Type 'string' is not assignable to type 'QuickPickReturn<S>'.

在TypeScript 5.8之前,我们需要使用类型断言来解决这个问题,但这会绕过TypeScript的类型检查,可能会导致错误。例如:

if (selectionKind === SelectionKind.Single) {
    return selectedItems[0] as QuickPickReturn<S>;
} else {
    return selectedItems as QuickPickReturn<S>;
}

TypeScript 5.8引入了一种新的检查机制,允许在返回语句中对条件类型进行有限的检查。当函数的返回类型是一个泛型条件类型时,TypeScript会使用控制流分析来推断泛型参数的类型,并将其应用于条件类型。

为了使这种检查生效,我们需要明确地定义条件类型的所有分支:

type QuickPickReturn<S extends SelectionKind> =
    S extends SelectionKind.Multiple ? string[] :
    S extends SelectionKind.Single ? string :
    never;

这样,TypeScript可以正确地推断返回类型,并在调用者代码中提供类型安全性。如果我们在if分支中交换了返回值,TypeScript会报错:

if (selectionKind === SelectionKind.Single) {
    return selectedItems;
    // error! Type 'string[]' is not assignable to type 'string'.
} else {
    return selectedItems[0];
    // error! Type 'string[]' is not assignable to type 'string'.
}

此外,TypeScript 5.8还支持在索引访问类型中使用类似的检查机制。例如,我们可以使用一个类型映射来定义返回类型:

interface QuickPickReturn {
    [SelectionKind.Single]: string;
    [SelectionKind.Multiple]: string[];
}

async function showQuickPick<S extends SelectionKind>(
    prompt: string,
    selectionKind: S,
    items: readonly string[],
): Promise<QuickPickReturn[S]> {
    // ...
}

这种方式在许多情况下更加简洁易用。

限制

这种检查机制有一些限制。它只在以下情况下生效:

  • 返回类型是一个泛型条件类型或索引访问类型。
  • 至少有两个分支,其中一个分支的返回类型是never
  • 泛型参数的约束是一个联合类型。

例如,以下代码可以触发这种检查:

function f<T extends A | B>(x: T):
    T extends A ? string :
    T extends B ? number :
    never

如果使用了嵌套的类型参数,TypeScript可能不会应用这种检查。例如,如果我们将参数封装在一个选项对象中,TypeScript不会应用这种检查:

interface QuickPickOptions<S> {
    prompt: string,
    selectionKind: S,
    items: readonly string[]
}

async function showQuickPick<S extends SelectionKind>(
    options: QuickPickOptions<S>
): Promise<QuickPickReturn[S]> {
    // narrowing won't work correctly here...
}

但可以通过编写一个条件类型来检查内部内容来解决这个问题。

--module nodenext下支持require() ECMAScript模块

多年来,Node.js支持了ECMAScript模块(ESM)和CommonJS模块的互操作性。然而,这种互操作性存在一些限制:

  • ESM文件可以import CommonJS文件。
  • CommonJS文件不能require() ESM文件。

这意味着,如果库作者希望提供ESM支持,他们需要选择以下三种方案之一:

  1. 破坏与CommonJS用户的兼容性。
  2. 双向发布库(为ESM和CommonJS分别提供入口点)。
  3. 继续使用CommonJS。

双向发布虽然听起来是一个折中的方案,但它的过程复杂且容易出错,同时会显著增加包的代码量。

Node.js 22放宽了这些限制,允许CommonJS模块通过require()调用ESM文件(但不支持包含顶层await的ESM文件)。这为库作者提供了一个重大机会,使他们可以在不双向发布库的情况下提供ESM支持。

TypeScript 5.8在--module nodenext标志下支持这种行为。启用--module nodenext后,TypeScript将不会对require()调用ESM文件报错。

由于此功能可能会被回溯到较旧版本的Node.js,因此目前没有稳定的--module nodeXXXX选项启用此行为。然而,我们预计未来版本的TypeScript可能会在node20下稳定此功能。在此期间,我们建议使用Node.js 22及更高版本的用户使用--module nodenext,而库作者和使用较旧版本Node.js的用户应继续使用--module node16(或升级到--module node18)。

--module node18

TypeScript 5.8引入了一个稳定的--module node18标志。对于那些固定使用Node.js 18的用户,这个标志提供了一个稳定的参考点,不会引入--module nodenext中的一些行为。具体来说:

  • node18下,require()调用ESM文件是不允许的,但在nodenext下是允许的。
  • node18下,允许使用导入断言(已废弃,推荐使用导入属性),但在nodenext下是不允许的。

更多详细信息可以参考--module node18拉取请求和对--module nodenext更改

--erasableSyntaxOnly选项

最近,Node.js 23.6取消了实验性支持直接运行TypeScript文件的标志。然而,在这种模式下,只支持某些构造。Node.js引入了一个名为--experimental-strip-types的模式,要求任何TypeScript特有的语法不能具有运行时语义。换句话说,必须能够轻松地“擦除”或“剥离”文件中的任何TypeScript特有的语法,留下一个有效的JavaScript文件。

这意味着以下构造是不支持的:

  • enum声明
  • 具有运行时代码的namespacemodule
  • 类中的参数属性
  • import别名

类似的工具(如ts-blank-space或Amaro)也有相同的限制。这些工具会在遇到不符合要求的代码时提供有用的错误消息,但你仍然需要实际运行代码才能发现它不工作。

因此,TypeScript 5.8引入了--erasableSyntaxOnly标志。启用此标志后,TypeScript只会允许使用可以被擦除的构造,并在遇到不能擦除的构造时报错:

class C {
    constructor(public x: number) { }
    //          ~~~~~~~~~~~~~~~~
    // error! This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
}

更多详细信息可以参考实现

--libReplacement标志

在TypeScript 4.5中,我们引入了用自定义文件替换默认lib文件的可能性。这是基于从名为@typescript/lib-*的包中解析库文件的可能性。例如,你可以通过以下package.jsondom库锁定到特定版本的@types/web包:

{
    "devDependencies": {
       "@typescript/lib-dom": "npm:@types/web@0.0.199"
     }
}

安装后,名为@typescript/lib-dom的包应该存在,TypeScript在dom被你的设置隐含时总会查找它。

这是一个强大的功能,但它也带来了一些额外的工作。即使你没有使用这个功能,TypeScript也会始终执行这个查找,并且需要监视node_modules的变化,以防一个lib替换包开始存在。

TypeScript 5.8引入了--libReplacement标志,允许你禁用这种行为。如果你没有使用--libReplacement,你可以通过--libReplacement false禁用它。在未来,--libReplacement false可能会成为默认值,因此如果你目前依赖这个行为,你应该考虑通过--libReplacement true显式启用它。

更多详细信息可以参考更改

在声明文件中保留计算属性名称

为了使类中的计算属性在声明文件中具有更可预测的输出,TypeScript 5.8将在类中一致地保留实体名称(如bareVariablesdotted.names.that.look.like.this)作为计算属性名称。

例如,考虑以下代码:

export let propName = "theAnswer";

export class MyClass {
    [propName] = 42;
    // error!
    // A computed property name in a class property declaration must have a simple literal type or a 'unique symbol' type.
}

在TypeScript 5.8之前,生成声明文件时会报错,并且生成的声明文件会包含一个索引签名:

export declare let propName: string;
export declare class MyClass {
    [x: string]: number;
}

在TypeScript 5.8中,上述代码现在被允许,生成的声明文件将与你编写的代码一致:

export declare let propName: string;
export declare class MyClass {
    [propName]: number;
}

请注意,这不会在类上创建静态命名的属性。你仍然会得到一个类似于[x: string]: number的索引签名,因此对于这种用例,你需要使用unique symbol或字面量类型。

请注意,编写这种代码在--isolatedDeclarations标志下一直是错误的,但预计由于这一变化,计算属性名称将通常被允许在声明文件中使用。

请注意,有可能(尽管不太可能)TypeScript 5.8编译的文件生成的声明文件在TypeScript 5.7或更早版本中不向后兼容。

更多详细信息可以参考实现PR

程序加载和更新的优化

TypeScript 5.8引入了许多优化,可以改善构建程序的时间,以及在--watch模式或编辑器场景中基于文件更改更新程序的时间。

首先,TypeScript现在避免了在路径规范化中涉及的数组分配。通常,路径规范化会涉及将路径的每一部分分割成一个字符串数组,根据相对段规范化结果路径,然后使用规范分隔符将它们重新连接起来。对于包含许多文件的项目,这是一个显著且重复的工作量。TypeScript现在避免分配数组,而是直接在原始路径的索引上操作。

此外,当更改不会改变项目的根本结构时,TypeScript现在避免重新验证提供给它的选项(例如tsconfig.json的内容)。这意味着,例如,简单编辑可能不需要检查项目的输出路径是否与输入路径冲突。相反,可以使用上次检查的结果。这应该使大型项目的编辑感觉更响应。

值得注意的行为变更

本节强调了一些值得注意的变更,这些变更应在升级时被识别和理解。有时它会突出显示弃用、移除和新限制。它也可以包含功能改进的错误修复,但这些修复也会影响现有构建,引入新错误。

lib.d.ts

DOM生成的类型可能会对代码库的类型检查产生影响。有关更多信息,请参阅与DOM和lib.d.ts更新相关的问题

--module nodenext下对导入断言的限制

导入断言是ECMAScript的一个提议,用于确保导入的某些属性(例如“这个模块是JSON,不是可执行JavaScript代码”)。它们被重新设计为一个名为导入属性的提议。作为过渡的一部分,它们从使用assert关键字改为使用with关键字。

// 一个导入断言 ❌ - 不兼容大多数运行时的未来版本。
import data from "./data.json" assert { type: "json" };

// 一个导入属性 ✅ - 导入JSON文件的推荐方式。
import data from "./data.json" with { type: "json" };

Node.js 22不再接受使用assert语法的导入断言。因此,当在TypeScript 5.8中启用--module nodenext时,TypeScript会在遇到导入断言时报错:

import data from "./data.json" assert { type: "json" };
//                             ~~~~~~
// error! Import assertions have been replaced by import attributes. Use 'with' instead of 'assert'

更多详细信息可以参考更改

接下来会发生什么?

目前,TypeScript 5.8已经“功能稳定”。TypeScript 5.8的重点将是修复错误、打磨细节以及某些低风险的编辑器功能。我们将在接下来的几周内发布候选版本,随后很快就会发布稳定版本。如果你对规划版本发布感兴趣,请务必关注我们的迭代计划,其中包含目标发布时间等更多信息。

请注意:虽然测试版是尝试TypeScript下一个版本的好方法,但你也可以尝试夜间版本,以获取TypeScript 5.8的最新版本,直到我们发布候选版本。我们的夜间版本经过了充分测试,甚至可以仅在你的编辑器中使用。

因此,请今天尝试测试版或夜间版本,并告诉我们你的想法!

祝编程愉快!

——Daniel Rosenwasser和TypeScript团队

最后感谢阅读!欢迎关注我,微信公众号倔强青铜三。欢迎点赞收藏关注,一键三连!!!

倔强青铜三
41 声望0 粉丝