最近把项目的TypeScript版本从4.9升级到了5.4,踩了不少坑。
TypeScript 5.4带来了很多新特性,比如更智能的类型收窄、NoInfer工具类型、Object.groupBy和Map.groupBy、装饰器的正式支持等等。这些新特性很好用,但升级的过程中也遇到了不少类型错误和行为变化。
这篇文章就来总结一下我在TypeScript 5.4+中遇到的各种坑,以及对应的解决方案和实战经验。希望能给正在升级或者使用TypeScript 5.4+的朋友一些参考。
升级前的准备
先说升级前的准备工作,这一步很重要,没做好的话升级之后会一脸懵。
第一,先看官方的升级指南。TypeScript每个版本发布的时候,官方都会发布一个升级指南(Breaking Changes),列出这个版本中不兼容的变化。5.4的升级指南在TypeScript的官方Wiki上,建议升级前仔细读一遍,知道哪些地方可能会出问题。
第二,确保你的依赖库都支持TypeScript 5.4。很多第三方库的类型定义是和特定TypeScript版本绑定的,如果库比较老,可能不兼容新版本。升级之前,先把主要的依赖(React、Vue、Node.js类型等)升级到最新版本,或者确认它们支持5.4。
第三,开启strict模式。如果你的项目还没有开启strict模式,建议在升级之前先开启。strict模式包含了noImplicitAny、strictNullChecks、strictFunctionTypes等一系列严格的类型检查。虽然开启之后会出现很多类型错误,但这些错误本来就应该修复,而且新版本的很多新特性都是在strict模式下才能发挥最大作用。
第四,做好版本控制。升级之前先commit或者建一个分支,这样如果升级出问题,可以随时回退。不要直接在主分支上升级,万一升级失败影响了正常开发。
做好这些准备之后,就可以开始升级了。
坑一:类型收窄变得更严格了
TypeScript 5.4对类型收窄做了很多改进,让类型推断更智能了。但这也意味着,以前一些能通过的代码,现在可能会报类型错误。
最常见的一个问题是在闭包中的类型收窄。在5.4之前,TypeScript对闭包中的类型收窄比较保守,有时候会把已经收窄的类型又放宽回去。5.4改进了这一点,在更多的情况下能正确地收窄类型。
比如这段代码:
function example(arr: string[] | null) {
if (arr === null) return;
setTimeout(() => {
console.log(arr.length); // 5.4之前可能报错,5.4之后能正确推断arr是string[]
}, 1000);
}在5.4之前,因为setTimeout的回调是一个闭包,TypeScript不确定arr在回调执行的时候会不会被改变,所以会把arr的类型收窄取消,报错说arr可能是null。5.4改进了这一点,如果arr没有被重新赋值,就能正确地在闭包中保持收窄后的类型。
这本来是一个改进,但如果你的代码以前是通过类型断言(as)或者非空断言(!)来绕过这个问题的,现在可能会出现冗余断言的警告,或者断言的类型和实际类型不匹配的错误。
解决方案:检查代码中不必要的类型断言,能去掉的就去掉。如果确实需要断言,确保断言的类型是正确的。
还有一个相关的变化是,5.4对数组的类型收窄也更严格了。比如:
function getFirst(arr: string[]): string | undefined {
if (arr.length === 0) return undefined;
return arr[0]; // 5.4之前是string,5.4之后是string | undefined
}在5.4之前,TypeScript会认为arr[0]一定是string(因为已经检查了数组非空),但实际上数组的索引访问总是可能返回undefined(如果索引越界)。5.4修正了这个行为,arr[0]的类型是string | undefined。
这个变化会导致很多以前能通过的代码现在报错,特别是访问数组元素之后直接调用方法的情况。
解决方案:要么用arr[0]!非空断言(如果你确定元素存在),要么加一个undefined检查。我更推荐后者,更安全。
坑二:NoInfer工具类型的使用
TypeScript 5.4新增了一个内置工具类型NoInfer,用来阻止TypeScript在泛型参数中进行类型推断。
这个工具类型的使用场景是这样的:当你有一个泛型函数,有多个参数使用了同一个泛型参数,你希望TypeScript只从其中一个参数推断类型,而不是从所有参数推断。
比如:
function createFruit<T extends string>(name: T, aliases: NoInfer<T>[]) {
return { name, aliases };
}
// 以前:T会被推断为"apple" | "banana",因为aliases参数也参与了推断
// 现在:T只从name参数推断为"apple",aliases的类型是"apple"[]
createFruit("apple", ["banana"]); // 5.4之后会报错,因为"banana"不是"apple"在5.4之前,没有NoInfer的时候,TypeScript会从所有参数推断T的类型,导致T被推断为联合类型,有时候不是我们想要的。有了NoInfer之后,可以精确控制从哪个参数推断类型。
但这个新特性也带来了一个坑:很多第三方库的类型定义开始使用NoInfer,如果你用的TypeScript版本低于5.4,就会报"找不到名称NoInfer"的错误。
解决方案:升级到TypeScript 5.4以上。如果暂时不能升级,可以在项目中自己定义一个NoInfer类型:
type NoInfer<T> = [T][T extends any ? 0 : never];这个定义和官方的NoInfer效果一样,可以在旧版本中使用。
坑三:装饰器的正式支持
TypeScript 5.4正式支持了ECMAScript装饰器标准(TC39 Stage 3)。这和以前TypeScript自己实现的实验性装饰器(experimentalDecorators)是不一样的。
这是一个很大的变化,因为很多框架(比如Angular、NestJS、TypeORM)都在使用装饰器。如果你的项目用了装饰器,升级的时候要特别注意。
主要的区别有几个。
第一,语法和行为不同。标准装饰器的语法和实验性装饰器类似,但行为有一些差异。比如标准装饰器的执行顺序、参数类型、返回值处理都和实验性装饰器不一样。
第二,参数装饰器在标准装饰器中还没有支持。实验性装饰器支持参数装饰器(比如NestJS中的@Param()、@Body()),但标准装饰器目前还不支持参数装饰器,这个特性还在TC39的讨论中。
第三,元数据反射(emitDecoratorMetadata)不支持标准装饰器。实验性装饰器可以通过emitDecoratorMetadata选项发射类型元数据,很多框架(比如TypeORM、class-validator)依赖这个特性。但标准装饰器不支持这个选项。
这就导致了一个问题:如果你用的框架还没有迁移到标准装饰器,你就不能关闭experimentalDecorators,也就不能使用标准装饰器。而TypeScript 5.4虽然支持标准装饰器,但默认还是使用实验性装饰器(如果开启了experimentalDecorators的话)。
我的建议是:
- 如果你的项目用了依赖实验性装饰器的框架(NestJS、TypeORM、Angular等),暂时不要迁移到标准装饰器,继续使用experimentalDecorators。
- 如果你在写新的代码,而且框架已经支持标准装饰器,可以考虑使用标准装饰器。
- 不要在同一个项目中混用实验性装饰器和标准装饰器,会出问题。
TypeScript团队说,未来会逐步从实验性装饰器过渡到标准装饰器,但这个过程需要时间,框架的迁移也需要时间。目前来说,大多数项目还是继续用实验性装饰器比较稳妥。
坑四:模块解析的变化
TypeScript 5.4对模块解析(moduleResolution)做了一些改进,特别是bundler模式。
在5.0的时候,TypeScript引入了"bundler"模块解析模式,专门针对使用打包工具(Vite、Webpack、esbuild等)的项目。5.4对bundler模式做了一些改进,让它更智能。
但这也带来了一些问题。比如,在bundler模式下,TypeScript对package.json中的exports字段的解析更严格了。如果一个库的exports字段配置不正确,以前可能能通过,现在会报错。
还有一个变化是,5.4在bundler模式下,对导入路径的扩展名检查更严格了。以前导入的时候可以省略扩展名,现在某些情况下需要加上扩展名(特别是导入.json、.css等非JS文件的时候)。
如果你用的是Vite或者其他现代打包工具,建议把moduleResolution设置为"bundler",这是最适合的模式。但要注意检查第三方库的类型定义是否兼容。
还有一个相关的变化是,5.4新增了--moduleDetection选项,用来控制TypeScript如何检测一个文件是不是模块。以前TypeScript是根据文件中有没有import/export语句来判断的,现在可以通过这个选项更精确地控制。
如果你的项目中有一些文件没有import/export,但你希望它们被当作模块处理(而不是全局脚本),可以设置"moduleDetection": "force"。这在使用一些全局类型声明的时候很有用。
坑五:Object.groupBy和Map.groupBy
TypeScript 5.4新增了对Object.groupBy和Map.groupBy的类型支持。这两个方法是ES2024的新特性,用来对数组进行分组。
用法很简单:
const people = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
{ name: "Charlie", age: 25 },
];
const grouped = Object.groupBy(people, (person) => person.age);
// grouped的类型是Partial<Record<number, { name: string; age: number }[]>>这个方法很好用,但有一个坑:它需要你的lib设置包含"es2024"或者"esnext"。如果你的lib设置的是"es2022"或者更低,TypeScript会报"找不到属性groupBy"的错误。
解决方案:在tsconfig.json中把lib设置为包含"es2024":
{
"compilerOptions": {
"lib": ["es2024", "dom"]
}
}但要注意,Object.groupBy和Map.groupBy是运行时特性,不只是类型。如果你的运行环境(浏览器、Node.js版本)不支持这两个方法,即使TypeScript编译通过了,运行时也会报错。
所以在使用之前,要确认你的运行环境支持。Node.js 21以上、Chrome 117以上、Firefox 119以上都支持。如果需要支持旧环境,可以用polyfill或者自己实现一个分组函数。
坑六:泛型函数的类型推断变化
TypeScript 5.4对泛型函数的类型推断做了一些改进,特别是在涉及条件类型和映射类型的时候。
一个常见的坑是,以前能正确推断的泛型函数,现在推断的结果不一样了,导致类型错误。
比如:
type Result<T> = T extends string ? { type: "string"; value: T } : { type: "other"; value: T };
function process<T>(input: T): Result<T> {
// ...
}
const result = process("hello");
// 5.4之前:result的类型可能是{ type: "string"; value: string }
// 5.4之后:result的类型可能是Result<string>,没有被展开在5.4之前,TypeScript有时候会把条件类型展开,直接给出最终的类型。5.4之后,在某些情况下会保留条件类型的形式,不展开。这可能会导致一些依赖类型展开的代码出错。
解决方案:如果需要展开类型,可以用一个辅助类型来强制展开:
type Expand<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
type ExpandedResult<T> = Expand<Result<T>>;或者在使用的时候做一次类型断言。
还有一个变化是,5.4对泛型参数的默认值处理更严格了。如果泛型参数有默认值,但实际传入的类型和默认值不兼容,以前可能会静默使用默认值,现在会报错。
坑七:严格的null检查和可选链
TypeScript 5.4对strictNullChecks做了一些改进,特别是在可选链(?.)和空值合并(??)的处理上。
一个常见的坑是,可选链之后的类型收窄更严格了。比如:
interface User {
profile?: {
name?: string;
};
}
function getUserName(user: User): string {
const name = user.profile?.name;
if (name) {
return name; // 5.4之前是string,5.4之后是string | undefined
}
return "default";
}在5.4之前,if (name)会把name的类型收窄为string(因为name是truthy的)。但5.4在某些情况下,对可选链产生的类型收窄更保守,可能不会完全收窄。
这个问题通常出现在更复杂的情况下,比如可选链和函数调用结合:
const value = obj.getValue?.();
if (value) {
// value的类型可能还是T | undefined
}解决方案:如果遇到这种情况,可以用非空断言name!,或者用更明确的类型守卫。也可以把可选链的结果赋给一个变量,然后对变量进行检查,这样类型收窄更可靠。
坑八:tsconfig的变化
TypeScript 5.4对tsconfig.json的配置也做了一些调整。
一个重要的变化是,5.4推荐使用"module": "preserve"配合"moduleResolution": "bundler"。这个组合是为使用打包工具的项目设计的,能让TypeScript的模块行为和打包工具保持一致。
如果你的项目用的是Vite、Webpack等打包工具,建议把tsconfig改成:
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"lib": ["ES2024", "DOM", "DOM.Iterable"]
}
}还有一个变化是,5.4新增了"verbatimModuleSyntax"选项的改进。这个选项要求你在导入类型的时候必须用import type,在导出类型的时候必须用export type。这有助于避免一些运行时的问题,也让类型导入更清晰。
如果你的项目还没有开启verbatimModuleSyntax,建议开启。开启之后,TypeScript会强制区分类型导入和值导入,这在使用bundler模式的时候特别重要。
还有一个小变化是,5.4对paths配置的解析更严格了。如果你的paths配置有问题(比如指向了不存在的文件,或者baseUrl设置不正确),以前可能能通过,现在会报错。
一些实战经验
最后分享一些在TypeScript 5.4+中开发的实战经验。
第一,善用新的类型工具。5.4新增的NoInfer,以及之前版本的 satisfies 操作符、模板字面量类型、条件类型推断等,都是非常强大的类型工具。花时间学习这些工具,能让你写出更安全、更优雅的类型代码。
第二,不要过度使用any。很多人遇到类型错误就用any来绕过,这会让TypeScript失去意义。遇到类型错误的时候,先花时间理解为什么错,然后用正确的方式修复。实在搞不定的,可以用unknown代替any,至少unknown是类型安全的。
第三,善用类型断言,但不要滥用。类型断言(as)是一个强大的工具,当你比TypeScript更了解类型的时候可以用。但不要到处用as,这会让代码的类型安全性下降。每一个as都应该有理由,最好加注释说明为什么需要断言。
第四,善用satisfies操作符。satisfies是TypeScript 4.9引入的,用来检查一个值是否满足某个类型,同时不改变值的推断类型。这个操作符在很多场景下比类型断言更好用,比如配置对象、常量定义等。
第五,定期运行tsc --noEmit检查类型。不要只依赖编辑器的类型提示,定期在命令行运行tsc --noEmit,能发现一些编辑器漏掉的类型错误。建议把这个命令加入CI流程,确保提交的代码没有类型错误。
第六,关注TypeScript的版本更新。TypeScript的更新很频繁,每个版本都有新特性和改进。关注更新日志,了解新特性,及时升级版本。但升级的时候要注意测试,确保没有引入新的问题。
写在最后
TypeScript 5.4是一个很不错的版本,带来了很多实用的新特性和改进。但升级的过程中,确实会遇到不少坑,特别是类型收窄更严格、装饰器的变化、模块解析的调整这些方面。
这篇文章总结了我在升级和使用TypeScript 5.4+过程中遇到的主要问题和解决方案,希望能帮到大家。当然,每个项目的情况不一样,遇到的问题也可能不同,关键是理解TypeScript的类型系统,遇到问题的时候能分析原因、找到解决方案。
TypeScript发展到今天,已经成为前端开发的标配。掌握好TypeScript,不仅能写出更安全、更可维护的代码,也能提升开发效率。希望我们都能在TypeScript的路上越走越远,写出更好的代码。
如果你在使用TypeScript 5.4+的过程中也遇到了什么坑,或者有什么好的经验,欢迎交流分享。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录