3 月 6 日,TypeScript 发布了 v5.4 版本,该版本带来了以下更新:
- 类型缩小会在闭包中保留
- 引入新的实用程序类型 NoInfer
- 新增Object.groupBy 和 Map.groupBy
- 新的模块解析选项
- 新的模块导入检查机制
- TypeScript 5.5 即将弃用的功能
类型缩小会在闭包中保留
TypeScript 通过类型缩小来优化代码,但在闭包中并不总是保留这些缩小后的类型。从TypeScript 5.4开始,当在非提升函数中使用参数或let变量时,类型检查器会查找最后的赋值点,从而智能地进行类型缩小。然而,如果变量在嵌套函数中被重新分配,即使这种分配不影响其类型,也会使闭包中的类型细化无效。
// TypeScript的类型缩小在闭包中通常不保留
function exampleFunction(input: string | number) {
if (typeof input === "string") {
input = parseInt(input); // 假设想要将字符串转为数字
}
return () => {
// 在这里,TypeScript不知道input是string还是number
// 因为在闭包创建后,input可能已经被修改
console.log(input.toString()); // 错误!'input'可能是number,没有toString方法
};
}
TypeScript 5.4后,当在闭包外部对变量进行最后一次赋值时,类型缩小会在闭包中保留:
function improvedFunction(input: string | number) {
let value;
if (typeof input === "string") {
value = parseInt(input);
} else {
value = input;
}
return () => {
// 在这里,TypeScript知道value是number,因为这是在闭包创建后的最后一次赋值
console.log(value.toString()); // 正确!因为现在我们知道value是number
};
}
引入新的实用程序类型 NoInfer
TypeScript的泛型函数能够根据传入的参数自动推断类型。但在某些情况下,这种自动推断可能不符合预期,导致不合法的函数调用被接受,而合法的调用却被拒绝。为了处理这种情况,开发者通常需要添加额外的类型参数来约束函数的行为,确保类型安全。但这种做法可能会使代码看起来更加复杂,特别是当这些额外的类型参数在函数签名中只使用一次时。
TypeScript 5.4 引入了 NoInfer
考虑以下函数,它接受一个用户ID列表和一个可选的默认用户ID。
function selectUser(userIds: U[], defaultUserId?: U) {
// ...
}
const userIds = ["123", "456", "789"];
selectUser(userIds, "000"); // 错误地被接受,因为"000"不在userIds中
在这个例子中,即使"000"不在userIds数组中,selectUser函数的调用也会被接受,因为TypeScript自动推断默认用户ID可以是任何字符串。
TypeScript 5.4 中:
function selectUser(userIds: U[], defaultUserId?: NoInfer) {
// ...
}
const userIds = ["123", "456", "789"];
selectUser(userIds, "000"); // 正确的错误,因为"000"不在userIds中
通过使用 NoInfer
新增 Object.groupBy 和 Map.groupBy
TypeScript 5.4 引入了两个新方法:Object.groupBy 和 Map.groupBy,它们用于根据特定条件将数组元素分组。
- Object.groupBy 返回一个对象,其中每个键代表一个分组,对应的值是该分组的元素数组。
- Map.groupBy 返回一个`` Map 对象,实现了相同的功能,但允许使用任何类型的键。
使用 Object.groupBy 和 Map.groupBy 可以方便地根据自定义逻辑对数组进行分组,无需手动创建和填充对象或 Map。然而,在使用 Object.groupBy 时,由于对象的属性名必须是有效的标识符,因此可能无法覆盖所有情况。此外,这些方法目前仅在 esnext 目标或特定库设置下可用。
假设有一个学生数组,每个学生都有姓名和成绩。我们想要根据成绩将学生分为“优秀”和“及格”两组。
const students: { name: string, score: number }[] = [
{ name: "Alice", score: 90 },
{ name: "Bob", score: 75 },
{ name: "Charlie", score: 85 },
// ...其他学生
];
const groupedStudents: { excellent: any[], passing: any[] } = {
excellent: [],
passing: []
};
for (const student of students) {
if (student.score >= 80) {
groupedStudents.excellent.push(student);
} else {
groupedStudents.passing.push(student);
}
}
使用 Array.prototype.groupBy 方法,可以更简洁地实现相同的功能。
const students: { name: string, score: number }[] = [
{ name: "Alice", score: 90 },
{ name: "Bob", score: 75 },
{ name: "Charlie", score: 85 },
// ...其他学生
];
const groupedStudents = students.groupBy(student => {
return student.score >= 80 ? "excellent" : "passing";
});
// 使用时可以直接访问分组
console.log(groupedStudents.get("excellent")); // 输出优秀学生数组
console.log(groupedStudents.get("passing")); // 输出及格学生数组
在这个例子中,groupBy 方法根据每个学生的成绩将学生数组分为“优秀”和“及格”两组,并返回一个 Map 对象,其中键是分组名称,值是对应的学生数组。这种方法更加简洁且易于理解。
新的模块解析选项
TypeScript 5.4 引入了一个新的模块解析选项 bundler,它模拟了现代构建工具(如Webpack、Vite 等)确定导入路径的方式。当与 --module esnext 配合使用时,它允许开发者使用标准的 ECMAScript 导入语法,但禁止了 import ... = require(...) 这种混合语法。
同时,TypeScript 5.4 还增加了一个名为 preserve 的模块选项,该选项允许开发者在 TypeScript 中使用 require(),并更准确地模拟了构建工具和其他运行时环境的模块查找行为。当设置 module 为 preserve 时,构建工具会隐式地成为默认的模块解析策略,同时启用 esModuleInterop 和 resolveJsonModule。
假设有一个使用 TypeScript 编写的项目,并且想从一个名为 my-lib 的库中导入两个模块 moduleA 和 moduleB。这个库提供了 ES 模块和 CommonJS 模块两种格式。在 TypeScript 配置中,你可能这样设置:
// tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node"
}
}
然后代码中这样导入:
import * as moduleA from 'my-lib/moduleA';
import * as moduleB = require('my-lib/moduleB');
在这种情况下,TypeScript 可能会为两个导入生成相同的路径,因为它们都使用了 Node.js 的模块解析策略。
在 TypeScript 5.4 中,如果想更精确地控制导入的路径,特别是当库提供了基于导入语法的不同实现时,可以使用 preserve
模块选项和构建工具模块解析策略:
// tsconfig.json
{
"compilerOptions": {
"module": "preserve",
// 隐式设置:
// "moduleResolution": "bundler",
// "esModuleInterop": true,
// "resolveJsonModule": true
}
}
然后,可以这样编写代码:
import * as moduleA from 'my-lib/moduleA'; // 使用 ES 模块导入
const moduleB = require('my-lib/moduleB'); // 使用 CommonJS 模块导入
现在,TypeScript 会根据 my-lib 的 package.json 文件中的 exports 字段来决定使用哪个文件路径。如果库为 ES 模块和 CommonJS 模块提供了不同的文件,TypeScript 将根据导入的语法(import 或 require)选择正确的文件。
这意味着开发者可以更精细地控制模块导入的行为,确保与库的意图一致,尤其是在处理那些提供条件导出的库时。
新的模块导入检查机制
TypeScript 5.4 引入了新的模块导入检查机制,确保导入的属性与全局定义的 ImportAttributes 接口相匹配。这种检查提高了代码的准确性,因为任何不符合该接口的导入属性都会导致编译错误。
在早期的 TypeScript 版本中,开发者可以自由地为 import
语句指定任何导入属性,而不会有严格的类型检查。这可能导致运行时错误,因为导入的属性可能与实际的模块不匹配。
// 假设存在一个全局的模块定义,但没有明确的导入属性类型
import * as myModule from 'my-module' with { custom: 'value' };
在上述代码中,custom 属性是自由定义的,没有与任何全局接口或类型进行匹配,这增加了出错的风险。
在 TypeScript 5.4 及以后的版本中,开发者必须确保导入属性与全局定义的 ImportAttributes 接口相符。这确保了类型安全,并减少了潜在的运行时错误。
// 全局定义的导入属性接口
interface ImportAttributes {
validProperty: string;
}
// 在模块中导入时,必须使用符合 ImportAttributes 接口的属性
import * as myModule from 'my-module' with { validProperty: 'someValue' };
// 下面的导入将引发错误,因为属性名称不匹配
import * as myModule from 'my-module' with { invalidProperty: 'someValue' };
// 错误:属性 'invalidProperty' 不存在于类型 'ImportAttributes' 中
在这个新版本中,如果开发者尝试使用不符合 ImportAttributes 接口的导入属性,TypeScript 编译器将抛出错误,从而避免了潜在的错误。
TypeScript 5.5 即将弃用的功能
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 将是这些已废弃选项和行为按预期运作的最后一个版本。在预计于 2024 年 6 月发布的 TypeScript 5.5 中,这些选项和行为将变成严格的错误,使用它们的代码将需要进行迁移以避免编译错误。因此,建议开发人员尽早迁移其代码库,以避免未来兼容性问题。