新年快乐!
TypeScript 已于 2025.1.29 发布 5.8 beta 版本,你可以在 5.8 Iteration Plan 查看所有被包含的 Issue 与 PR。如果想要抢先体验新特性,执行:
$ npm install typescript@beta
来安装 beta 版本的 TypeScript,或在 VS Code 中安装 JavaScript and TypeScript Nightly ,并选择为项目使用 VS Code 的 TypeScript 版本(cmd + shift + p, 输入 select typescript version),来更新内置的 TypeScript 支持。
本篇是笔者的第 13 篇 TypeScript 更新日志,上一篇是 「TypeScript 5.6 beta 发布:更完善的空值与真值检查、Iterator Helper、使用 --noCheck 跳过类型检查」,你可以在此账号的创作中找到(或在掘金/知乎/Twitter搜索林不渡),接下来笔者也将持续更新 TypeScript 的 DevBlog 相关,感谢你的阅读。
函数类型推导优化
在 TypeScript 中,我们经常遇到函数声明的返回值类型依赖于参数类型的情况,如以下的示例:
enum ValueKind {
Single,
Multiple,
}
async function getValue(
value: ValueKind,
): Promise<string | string[]> {
if (value === ValueKind.Single) {
return '';
}
else {
return [];
}
}
我们希望,getValue 的返回值类型取决于参数 value 的类型。这在逻辑层是很好实现的,但在类型层面则需要一些额外的处理,比如你可能会首先想到重载:
async function getValue(
value: ValueKind.Single,
): Promise<string>;
async function getValue(
value: ValueKind.Multiple,
): Promise<string[]>;
async function getValue(
value: ValueKind,
): Promise<string | string[]> {
if (value === ValueKind.Single) {
return '';
}
else {
return [];
}
}
getValue(ValueKind.Single) // string;
getValue(ValueKind.Multiple) // string[];
然而重载有两个比较显著的问题,首先,它并不会在函数实现内检查每个重载实现的返回值,比如:
if (value === ValueKind.Single) {
- return '';
+ return [];
}
这里的返回值实际上并不符合我们指定的重载声明,但 TypeScript 目前并不会检查其有效性。
另外一个问题则是,在参数类型-返回值类型对应较多的情况下,密密麻麻的重载会导致函数签名难以阅读,同时也不能很好处理参数类型-返回值类型对应关系需要通过进一步计算得到,而不是直接枚举的情况。
另外一个实现这种情况的方式则是使用条件类型:
type ReturnValue<S extends ValueKind> =
S extends ValueKind.Multiple ? string[] :
string;
async function getValue<S extends ValueKind>(
value: S,
): Promise<ReturnValue<S>> {
if (value === ValueKind.Single) {
return ''; // Type 'string' is not assignable to type 'ReturnValue<S>'
}
else {
return []; // Type 'string[]' is not assignable to type 'ReturnValue<S>'
}
}
但这种使用也会带来新的问题,即如上的报错。这是因为 TypeScript 会直接将返回语句的类型,与函数返回值的类型进行比较,即 string vs S extends ValueKind.Multiple ? string[] : string
,而在这个比较过程中,类型参数 S 是还没有被填充的,因此这个类型比较必定不兼容——类似于重载的情况。
你当然可以通过额外的类型断言来修正这一情况,但这样就变成是我们在指导 TypeScript 类型,而不是由它自己来推导类型并确保类型安全了。
很容易想到,既然我们都已经走到能够进行类型收窄的语句内了,如果能对应填充下参数类型,岂不是就解决这个问题了?在 TypeScript 4.8 版本中就支持了这一能力:
type ReturnValue<S extends ValueKind> =
S extends ValueKind.Multiple ? string[] :
S extends ValueKind.Single ? string :
never;
async function getValue<S extends ValueKind>(
value: S,
): Promise<ReturnValue<S>> {
if (value === ValueKind.Single) {
return '';
}
else {
return [];
}
}
要使用这个能力,我们需要更新一下 ReturnValue 工具类型的实现,让我们预期的所有参数类型的匹配结果都显式标注出来(语句块内对条件类型进行类型收窄时,只会在完全命中一个 extends 条件时生效)。
require ESM 支持
说实话我每次看到 CJS 和 ESM 的『你中有我,我中有你』时都感到一丝蛋疼,估计 Ryan 也没想到这么多年过去了,社区还在打补丁,就好像在努力挽回一段破碎的婚姻一样。
此前我们可以在 ESM 中加载 CJS 模块,但反之则行不通,所以当你必须兼容 CJS 时,又遇到只发布了 ESM 产物的 npm 包,那得是多么头大的情况。而 NodeJS 22 版本实验性质地支持了使用 require 加载 ESM 文件(loading-ecmascript-modules-using-require),能够在 CJS 中加载同步(即不包括 top-level await)的 ESM 文件。
TS 5.8 版本现在能够在启用 --module nodenext 配置时支持这一行为,需要注意的是由于这个功能特性可能会反向移植到更早的 NodeJs 版本,比如 Node 20,因此还不会提供具体的如 --module node18 这样精确的版本控制。
NodeJs TS 支持 --erasableSyntaxOnly
NodeJs 22.6.0 版本开始通过实验性的 --experimental-strip-types
选项支持直接执行 TS 文件,同时在 23.6.0 版本开始默认启用了此配置,也就是说,你可以直接 node index.ts 而不需要额外的命令行参数了,对应的,23.6.0 也新增了 --no-experimental-strip-types
配置来阻止这一默认行为。
NodeJs 底层是通过把类型相关的代码替换为空白字符串来实现此能力的,其底层是 Amaro ,Amaro 底层则是 @swc/wasm-typescript,整体使用大致是这样:
const amaro = require('amaro');
const { code } = amaro.transformSync("const foo: string = 'bar';", { mode: "strip-only" });
console.log(code); // "const foo = 'bar';"
对于某些不能直接移除的 TS 特性(比如枚举),NodeJs 23.7.0 也支持了 --experimental-transform-types
配置来做执行前的语法转换(通过设置 transformSync options 的 mode 为
'transform'),比如第一个例子中的代码,如果直接执行会有这么个错误:
node:internal/modules/typescript:67
throw new ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX(error.message);
^
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: x TypeScript enum is not supported in strip-only mode
,-[1:1]
1 | ,-> enum ValueKind {
2 | | Single,
3 | | Multiple,
4 | `-> }
如果你在迁移某些工具链到原生的 NodeJs TS 支持,并且希望使用 'strip-only' 模式,那么就必须检查出代码中无法直接 strip 的部分。因此,TypeScript 5.8 引入了 --erasableSyntaxOnly
配置,能够帮你提前检查出这种代码。
先是为 JS 引入类型注释的 TC39 提案 proposal-type-annotations,接着是 NodeJs 支持直接执行 TS 文件,感觉浏览器直接运行 TS 文件也不远了?
变量初始化检查(5.7 补档)
TS 5.7 版本开始支持了对跨函数上下文的未初始化变量检查,如对于以下的例子:
function foo() {
let result: number
function printResult() {
console.log(result); // ok before 5.7, error since 5.7
}
}
TS 5.7 版本只有这个类型能力增强值得一写,所以我就直接把 TS 5.7 的 Devblog 鸽了 :-)
其它
计算属性类型声明优化
此前 TypeScript 会约束 Class 结构内的计算属性,要求其类型必须是字面量类型或 symbol 类型,如:
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.
}
这是因为在此前,TS 希望在编译时确定这一计算属性的具体类型,而在这个例子里 propName 是 string 类型,如果换成使用 const 声明,将其推导类型收窄为字符串字面量类型,就没有这个错误了。
虽然有这么个错误,但是 TS 仍然能够编译出声明代码:
export declare let propName: string;
export declare class MyClass {
[x: string]: number;
}
使用 const 声明编译出的类型声明则是这样的:
export declare const propName = "theAnswer";
export declare class MyClass {
[propName]: number;
}
看起来好像很合理,但你会发现,为 Class 通过计算属性声明索引类型其实是完全合法的,即以下的代码都是合法且效果一致的:
export declare let propName: string;
export declare class MyClass {
[propName]: number;
}
export declare let propName: string;
export declare class MyClass {
[x: string]: number;
}
那么此前限定计算属性必须为字面量类型或 Symbol 类型的限制,其实就不存在了。因此 TypeScript 5.8 版本移除了这一限制,现在使用 let 声明不会抛出错误,且与 const 编译结果一致:
export let propName = 'theAnswer';
export class MyClass {
[propName] = 42;
}
// let generated
export declare let propName: string;
export declare class MyClass {
[propName]: number;
}
export const propName = 'theAnswer';
export class MyClass {
[propName] = 42;
}
// const generated
export declare const propName = "theAnswer";
export declare class MyClass {
[propName]: number;
}
全文完,我们 TS 5.9 见 :-)