用TypeScript已经有好几年了,从最早的2.x用到现在的5.6+。

说实话,TypeScript是个好东西。它给JavaScript加上了类型系统,能在编译时发现很多错误,大大提高了代码的可维护性和开发效率。特别是对于大型项目,TypeScript几乎是必须的。

但TypeScript也不是完美的。它的类型系统很复杂,有很多容易踩坑的地方。特别是每次升级大版本,都会有一些breaking changes,以及一些新的特性和行为,需要花时间去适应。

最近我们项目从TypeScript 5.4升级到了5.6,升级的过程中,遇到了各种各样的问题。有些问题很简单,改个配置就行;有些问题很隐蔽,花了我好几个晚上才排查清楚。

这篇文章,我想分享一下使用TypeScript 5.6+时遇到的各种坑和解决方案。从类型系统、配置选项到第三方库兼容,聊聊那些让我熬夜排查的问题和最终的解决办法。

如果你也在用TypeScript,或者打算升级到5.6+,希望这篇文章能帮你少踩一些坑,少熬一些夜。

先说明一下,我主要基于TypeScript 5.6来写,不同版本的行为可能会有差异。我遇到的问题,可能和我们项目的具体情况有关,不一定每个人都会遇到。但解决问题的思路,应该是通用的。

坑一:strict模式下的隐式any

第一个坑,是strict模式下的隐式any。

TypeScript 5.6对strict模式做了一些调整,对隐式any的检测更严格了。以前在某些情况下,TypeScript会自动推断为any,不会报错;但在5.6中,这些情况会被检测出来,报"隐式any"的错误。

我们项目升级之后,一下子冒出了几十个隐式any的错误,大部分集中在以下几个地方:

第一,函数参数没有类型注解。比如,回调函数的参数没有写类型,以前TypeScript会根据上下文推断,但在某些复杂的情况下推断不出来,就变成了隐式any。

第二,对象的属性没有类型。比如,用对象字面量创建一个对象,但某些属性的类型无法推断,就变成了隐式any。

第三,泛型没有指定类型参数。比如,调用一个泛型函数,但没有指定类型参数,TypeScript也无法从参数中推断出来,就变成了隐式any。

这些错误,看起来简单,但数量多了,改起来也很头疼。特别是一些老代码,写的时候就没有注意类型,现在要一个个补类型注解,很费时间。

解决方案:

第一,能加类型注解的,就加上类型注解。这是最根本的解决办法,虽然费时间,但能提高代码的类型安全性。

第二,对于确实无法确定类型的,可以显式地标注为any。但要注意,显式any和隐式any不一样,显式any是你主动声明的,TypeScript不会报错。但不建议滥用any,能不用就不用。

第三,对于第三方库的类型问题,可以通过声明文件(.d.ts)来补充类型。如果第三方库没有类型定义,或者类型定义不准确,可以自己写声明文件来补充。

第四,如果实在改不完,可以在tsconfig.json中临时关闭noImplicitAny选项。但这只是权宜之计,不建议长期关闭。最好还是花时间把类型补上,享受strict模式带来的好处。

我们项目的做法是,先把能快速改的改了,剩下的暂时用显式any标注,然后在后续的迭代中逐步替换掉显式any。这样,既保证了升级能顺利进行,又不会留下太多技术债务。

坑二:类型收窄的行为变化

第二个坑,是类型收窄(Type Narrowing)的行为变化。

类型收窄是TypeScript的一个重要特性,它能通过条件判断,把一个宽泛的类型收窄为更具体的类型。比如,你有一个变量是string | number,通过typeof x === 'string'判断之后,在if分支里,x的类型就被收窄为string了。

TypeScript 5.6对类型收窄的算法做了一些优化和调整,这导致某些情况下,类型收窄的行为和以前不一样了。我们项目中,有几处以前能正常工作的类型收窄,在5.6中失效了,报了类型错误。

具体来说,我们遇到的问题主要有:

第一,通过属性判断进行的类型收窄。以前,通过判断对象的某个属性,可以收窄对象的类型。但在5.6中,某些情况下这种收窄不再生效,特别是当属性是可选的或者有复杂类型的时候。

第二,通过函数返回值进行的类型收窄。以前,调用一个返回类型谓词(Type Predicate)的函数,可以收窄变量的类型。但在5.6中,如果函数有副作用,或者参数比较复杂,类型收窄可能会失效。

第三,通过赋值进行的类型收窄。以前,给变量赋值之后,变量的类型会被收窄为赋值的类型。但在5.6中,某些情况下这种收窄不再生效,特别是当变量是let或者var的时候。

这些问题,排查起来比较费劲,因为类型错误的提示可能不够明确,你需要仔细分析代码,才能发现是类型收窄的问题。

解决方案:

第一,用类型断言(as)来手动指定类型。如果TypeScript无法正确收窄类型,你可以用as来告诉TypeScript这个变量的实际类型。但要注意,类型断言是有风险的,如果你断言错了,运行时可能会出问题。所以,只有在你确定类型正确的时候,才用类型断言。

第二,用类型谓词函数来辅助收窄。你可以写一个返回类型谓词的函数,来帮助TypeScript正确收窄类型。比如,function isString(x: unknown): x is string { return typeof x === 'string'; }

第三,重构代码,让类型收窄更清晰。有时候,类型收窄失效是因为代码写得太复杂。把复杂的条件判断拆分成简单的步骤,或者用更清晰的方式来组织代码,类型收窄就能正常工作了。

第四,查看TypeScript的更新日志,了解类型收窄的具体变化。每次版本更新,TypeScript团队都会在更新日志中说明类型系统的变化。仔细看更新日志,能帮你更快地定位和解决问题。

我们项目中,大部分类型收窄的问题,都是通过重构代码来解决的。重构之后,代码更清晰了,类型也更准确了,算是因祸得福。

坑三:satisfies操作符的使用

第三个坑,是satisfies操作符的使用。

satisfies是TypeScript 4.9引入的一个操作符,它可以用来检查一个值是否满足某个类型,同时不改变值的推断类型。这个操作符很有用,能在保证类型安全的同时,保留值的具体类型。

但在实际使用中,satisfies也有一些容易踩坑的地方。特别是在5.6中,satisfies的行为有一些细微的调整,导致我们项目中几处用了satisfies的地方出现了问题。

我们遇到的问题主要有:

第一,satisfies和可选属性的交互。当用satisfies检查一个有可选属性的类型时,如果值中没有包含可选属性,TypeScript的推断可能和预期不一样。以前,可选属性会被推断为undefined;但在5.6中,某些情况下可选属性会被直接忽略,导致后续使用时报错。

第二,satisfies和联合类型的交互。当用satisfies检查一个联合类型时,TypeScript可能会把值的类型收窄为联合类型中的某一个,而不是保留值的具体类型。这会导致后续使用时,某些属性无法访问。

第三,satisfies和泛型的交互。在泛型函数中使用satisfies,可能会导致类型推断失败,或者推断出过于宽泛的类型。

这些问题,刚开始遇到的时候,有点摸不着头脑,因为satisfies的行为看起来很"魔法",不太好理解。

解决方案:

第一,仔细阅读satisfies的文档,理解它的工作原理。satisfies不是简单的类型注解,它有自己的推断规则。理解了这些规则,才能正确使用它。

第二,在复杂的情况下,可以用类型注解代替satisfies。如果你发现satisfies的行为不符合预期,可以直接用类型注解,虽然会丢失一些具体类型信息,但更稳定、更可预测。

第三,用类型别名和工具类型来辅助。有时候,通过定义合适的类型别名,或者用Partial、Required、Pick等工具类型,可以让satisfies的行为更符合预期。

第四,在升级版本之后,检查所有使用了satisfies的地方,确保它们的行为符合预期。satisfies是一个相对较新的特性,每个版本都可能有调整,升级之后要仔细测试。

我们项目中,大部分satisfies的问题,都是通过改用类型注解来解决的。虽然丢失了一些具体类型信息,但代码更稳定了,也更容易理解。

坑四:第三方库的类型兼容

第四个坑,是第三方库的类型兼容问题。

这是升级TypeScript版本时最常见、也最头疼的问题之一。很多第三方库,它们的类型定义是针对特定版本的TypeScript写的。当你升级TypeScript之后,这些类型定义可能就不兼容了,会报各种各样的类型错误。

我们项目升级到5.6之后,有十几个第三方库报了类型错误,主要包括:

第一,类型定义中使用了已经废弃或者改变了行为的TypeScript特性。比如,某些库的类型定义用了旧的泛型语法,或者依赖了已经改变的类型推断行为。

第二,类型定义本身有错误,在旧版本的TypeScript中没有被检测出来,但在新版本中被检测出来了。TypeScript的类型检查越来越严格,以前能通过的类型定义,现在可能通不过了。

第三,类型定义和库的实际行为不一致。库的代码更新了,但类型定义没有跟上,导致类型定义和实际行为不符。

这些问题,处理起来比较麻烦,因为你不能直接改第三方库的代码(除非你fork一份)。

解决方案:

第一,升级第三方库到最新版本。很多库会及时更新类型定义,兼容新版本的TypeScript。升级库的版本,通常能解决大部分类型兼容问题。

第二,如果库还没有更新类型定义,可以临时用// @ts-ignore或者// @ts-expect-error来忽略错误。但这只是权宜之计,而且要注意,@ts-expect-error如果没有错误反而会报错,所以更推荐用@ts-expect-error

第三,用声明文件(.d.ts)来覆盖第三方库的类型定义。你可以写一个自己的声明文件,来修正或者补充第三方库的类型定义。这是比较彻底的解决办法,但需要你对TypeScript的类型系统比较熟悉。

第四,用patch-package等工具,直接修改node_modules中的类型定义,然后生成补丁。这样,你不需要fork整个库,就能修改类型定义。但要注意,每次升级库的时候,都要重新应用补丁。

第五,如果某个库实在不兼容,可以考虑替换成其他兼容的库。虽然这是最后的选择,但有时候也是必要的。

我们项目中,大部分第三方库的类型问题,都是通过升级库的版本来解决的。少数几个还没有更新的库,我们用声明文件来覆盖了类型定义,暂时解决了问题。

坑五:tsconfig配置的变化

第五个坑,是tsconfig.json配置的变化。

每次TypeScript升级,tsconfig的配置选项都会有一些变化。有些选项被废弃了,有些选项的默认值变了,有些新的选项被加入了。如果不了解这些变化,升级之后可能会遇到各种奇怪的问题。

TypeScript 5.6中,tsconfig的变化主要有:

第一,某些编译选项的默认值变了。比如,某些strict相关的选项,默认值变得更严格了。如果你没有显式设置这些选项,升级之后类型检查会变严格,可能会冒出很多错误。

第二,某些选项被废弃了。比如,一些旧的、不推荐的选项,在5.6中被废弃了,使用的时候会有警告,未来版本会被移除。

第三,新增了一些选项。5.6加入了一些新的编译选项,用来控制新的特性和行为。如果你不了解这些新选项,可能会错过一些有用的功能,或者遇到一些意料之外的行为。

第四,配置的继承和覆盖行为有变化。tsconfig的extends和overrides机制,在5.6中有一些细微的调整,可能会影响配置的最终结果。

这些配置变化,看起来简单,但如果不注意,可能会导致编译失败,或者编译出来的代码有问题。

解决方案:

第一,升级之前,仔细阅读TypeScript的更新日志,了解tsconfig的变化。特别是那些默认值变化和废弃的选项,要重点关注。

第二,显式设置所有重要的编译选项,不要依赖默认值。默认值可能会变,但显式设置的选项不会变。把strict、noImplicitAny、strictNullChecks等重要选项都显式设置好,能避免很多因为默认值变化导致的问题。

第三,用tsc --showConfig来查看最终的配置结果。这个命令会打印出TypeScript实际使用的配置,包括继承和覆盖之后的结果。用这个命令,可以检查你的配置是否符合预期。

第四,逐步开启新的严格选项。如果新版本加入了更严格的选项,不要一下子全部打开,可以一个一个地开,每开一个就修复对应的错误。这样,升级的过程会更平滑。

我们项目中,tsconfig的问题主要是默认值变化导致的。我们把所有重要的选项都显式设置了一遍,然后逐步开启了新的严格选项,最终顺利完成了升级。

坑六:装饰器的新行为

第六个坑,是装饰器(Decorators)的新行为。

装饰器是TypeScript中一个很常用的特性,特别是在Angular、NestJS等框架中。但装饰器的标准一直在变化,TypeScript对装饰器的支持也在不断演进。

TypeScript 5.6对装饰器的支持有一些重要的变化,主要是向TC39的装饰器标准靠拢。这导致某些旧的装饰器写法,在5.6中行为不一样了,甚至会报错。

我们项目中用了NestJS,大量使用了装饰器。升级之后,遇到了不少装饰器相关的问题:

第一,参数装饰器的行为变化。旧的参数装饰器,在5.6中的执行顺序和元数据收集方式有变化,导致某些依赖参数装饰器的框架功能失效。

第二,类装饰器的返回值处理变化。旧的类装饰器可以返回一个新的类来替换原类,但在5.6中,这种写法的行为有变化,可能会导致类的行为不符合预期。

第三,装饰器的元数据(emitDecoratorMetadata)行为变化。emitDecoratorMetadata是TypeScript的一个实验性特性,用来在编译时发射类型元数据。在5.6中,这个特性的行为有一些调整,导致某些依赖元数据的框架出问题。

这些问题,排查起来比较困难,因为装饰器的错误通常比较隐晦,报错信息也不够明确。

解决方案:

第一,确认你使用的框架是否兼容TypeScript 5.6。很多框架(比如NestJS、Angular)会及时更新,兼容新版本的TypeScript。升级框架到最新版本,通常能解决大部分装饰器问题。

第二,如果框架还没有完全兼容,可以在tsconfig中保留旧的装饰器行为。TypeScript提供了一些选项,可以让你使用旧的实验性装饰器行为。比如,设置"experimentalDecorators": true"emitDecoratorMetadata": true,可以保留旧的装饰器行为。

第三,逐步迁移到标准装饰器。TC39的装饰器标准已经基本稳定,未来TypeScript会默认使用标准装饰器。可以在新项目中使用标准装饰器,旧项目逐步迁移。

第四,仔细测试装饰器相关的功能。装饰器通常用在框架的核心功能上,一旦出问题,影响很大。升级之后,要仔细测试所有使用了装饰器的地方,确保功能正常。

我们项目中,装饰器的问题主要是通过升级NestJS到最新版本来解决的。升级之后,大部分装饰器的行为都恢复正常了。少数几个有问题的装饰器,我们暂时用旧的实验性选项保留了旧行为,后续再逐步迁移。

坑七:模块解析的变化

第七个坑,是模块解析(Module Resolution)的变化。

模块解析是TypeScript中一个比较复杂的话题,它决定了TypeScript如何找到和加载模块。TypeScript支持多种模块解析策略,比如classic、node、node16、nodenext、bundler等。

TypeScript 5.6对模块解析有一些调整,特别是对node16和nodenext策略的支持更加完善了。这导致某些以前能正常工作的模块导入,在5.6中可能会报错。

我们项目中遇到的模块解析问题主要有:

第一,CommonJS和ES模块的互操作问题。在node16/nodenext模式下,TypeScript对CommonJS和ES模块的互操作有更严格的要求。某些以前能正常导入的CommonJS模块,在5.6中会报类型错误。

第二,导入路径的扩展名问题。在node16/nodenext模式下,导入ES模块的时候,需要加上.js扩展名。以前,很多人习惯不加扩展名,在旧版本中可能能工作,但在5.6中会报错。

第三,package.json的exports字段解析问题。越来越多的包开始使用package.json的exports字段来定义导出入口。TypeScript 5.6对exports字段的解析更加严格,某些配置不正确的包,可能会导致模块解析失败。

这些问题,通常会报"找不到模块"或者"模块没有导出某个成员"的错误,排查起来需要对模块解析机制有一定的了解。

解决方案:

第一,选择合适的模块解析策略。如果你的项目是用Webpack、Vite等打包工具构建的,可以用bundler策略,它比较宽松,适合打包工具的场景。如果你的项目是直接运行在Node.js上的,可以用node16或nodenext策略,它更符合Node.js的模块解析规则。

第二,在node16/nodenext模式下,导入ES模块时加上.js扩展名。这是Node.js ES模块的要求,虽然看起来有点奇怪(TypeScript文件导入.js),但这是标准做法。

第三,检查第三方包的package.json配置。如果某个包的exports字段配置有问题,可以向包的作者反馈,或者用路径映射(paths)来绕过。

第四,用paths选项来配置模块别名。如果某些模块解析有问题,可以在tsconfig的paths中配置别名,手动指定模块的路径。

我们项目中,模块解析的问题主要是通过切换到bundler策略来解决的。因为我们的项目是用Vite构建的,bundler策略更适合,也更宽松,解决了大部分模块解析的问题。

坑八:性能问题

第八个坑,是TypeScript 5.6的性能问题。

TypeScript每次升级,通常都会有性能优化,编译速度会更快。但在某些情况下,新版本可能会出现性能退化,编译速度反而变慢了。

我们项目升级到5.6之后,发现编译速度变慢了一些,特别是类型检查的时间变长了。在CI/CD环境中,编译时间增加了大约20%,影响了构建效率。

经过排查,我们发现性能变慢主要有以下几个原因:

第一,更严格的类型检查。5.6的类型检查更严格了,需要做更多的类型推断和检查,这自然会增加编译时间。

第二,某些新特性的开销。比如,更完善的satisfies支持、更准确的类型收窄,这些新特性都需要额外的计算,会增加编译时间。

第三,项目规模的影响。我们的项目比较大,有几千个TypeScript文件。对于大项目来说,类型检查的时间本来就很长,新版本的额外开销会被放大。

性能问题虽然不会导致编译失败,但会影响开发体验和构建效率,特别是对于大项目来说。

解决方案:

第一,使用项目引用(Project References)。项目引用可以把大项目拆分成多个小项目,每个项目独立编译,能大大提高编译速度。特别是对于大项目来说,项目引用是必须的。

第二,使用增量编译(incremental)。开启incremental选项之后,TypeScript会缓存编译结果,下次编译的时候只重新编译变化的部分,能大大提高编译速度。

第三,使用skipLibCheck选项。开启skipLibCheck之后,TypeScript会跳过对.d.ts声明文件的类型检查,能节省很多时间。声明文件通常是第三方库提供的,一般不需要检查。

第四,优化tsconfig配置。关闭一些不需要的严格选项,或者只在必要的时候开启。比如,noUnusedLocals、noUnusedParameters等选项,会增加编译时间,如果不需要可以关闭。

第五,使用更快的硬件或者远程构建。如果编译时间实在太长,可以考虑升级开发机的配置,或者使用远程构建服务,把编译放到更快的机器上。

我们项目中,通过开启项目引用、增量编译和skipLibCheck,编译速度基本恢复到了升级前的水平。虽然配置花了一些时间,但长期来看是值得的。

升级TypeScript的建议

最后,总结一下升级TypeScript版本的一些建议,希望能帮大家少踩坑。

第一,不要急于升级。新版本刚出来的时候,可能会有一些bug和兼容性问题。可以等一等,等社区反馈得差不多了,第三方库也都兼容了,再升级。

第二,升级之前,仔细阅读更新日志。TypeScript的更新日志写得很详细,包括breaking changes、新特性、行为变化等。仔细阅读更新日志,能提前知道可能会遇到的问题。

第三,在分支上升级,不要直接在主分支上改。新建一个分支,在分支上做升级,遇到问题可以随时放弃,不会影响主分支的开发。

第四,逐步升级,不要跨太多版本。如果你的项目用的是很老的版本,不要一下子升到最新版,可以一个版本一个版本地升。每次升一个版本,解决对应的问题,这样更容易排查。

第五,升级之后,充分测试。TypeScript的变化可能会影响运行时行为,虽然大部分情况下不会,但还是要充分测试,特别是类型系统变化比较大的地方。

第六,保留回滚的能力。如果升级之后遇到了无法解决的问题,要能快速回滚到旧版本。所以,不要在升级的同时做其他大的改动,保持升级分支的纯净。

这些建议,是我们在多次升级TypeScript的过程中总结出来的。遵循这些建议,能让升级的过程更平滑,少踩很多坑。

写在最后

TypeScript 5.6是一个很好的版本,它带来了很多新特性和改进,类型系统更强大,编译速度也有优化。但升级的过程,确实会遇到各种各样的坑,需要花时间和精力去解决。

这篇文章分享的,只是我们项目中遇到的一部分问题。TypeScript的类型系统很复杂,不同的项目可能会遇到不同的问题。但我相信,解决问题的思路和方法是相通的。

如果你也在使用TypeScript,我建议你保持学习,跟上版本的更新。每次升级,虽然会遇到一些坑,但也能学到很多新东西,让你的代码更健壮、更可维护。

同时,也要理性看待TypeScript。它是一个强大的工具,但不是银弹。它能帮你发现很多错误,但不能代替你的思考和测试。合理使用TypeScript,才能发挥它的最大价值。

最后,用一句话来结束这篇文章:"TypeScript的坑,踩过了才知道;但踩过的每一个坑,都会让你对类型系统有更深的理解。"

愿你在TypeScript的使用道路上,少踩坑,多成长。