先说明一下,TypeScript 5.4在本文写作时尚未正式发布(预计2024年3月发布),本文基于TypeScript 5.4的beta版和官方发布的新特性预告来写。如果你对TypeScript 5.4感兴趣,可以先从beta版入手,等正式发布后再深入使用。

TypeScript这几年发展很快,每个版本都有新特性和改进。我们项目最近从TypeScript 4.9升级到了5.4 beta,顺便用新特性重构了一些老旧代码,效果很不错。

这篇文章就来分享一下TypeScript 5.4的新特性,以及如何用这些新特性把烂代码重构成优雅代码。

为什么要重构

先说说为什么要重构。

我们的项目是一个中后台管理系统,用TypeScript写的,已经维护了三年多。随着功能不断叠加,代码越来越臃肿,类型定义混乱,any满天飞,很多地方类型检查形同虚设。

具体的问题有几个:

第一,any太多。很多地方为了图省事,直接用any,导致类型检查失效。一个函数的参数是any,返回值是any,里面的操作也都是any,整个函数就跟JavaScript一样,没有类型安全。

第二,类型定义重复。很多相似的类型在不同地方重复定义,比如用户类型,在A文件定义了一个User接口,在B文件又定义了一个User接口,字段还不一样,维护起来很麻烦。

第三,类型断言滥用。很多地方用as做类型断言,把一个类型断言成另一个类型,绕过类型检查。有些断言是合理的,但很多断言是因为类型定义不对,不得不断言。

第四,联合类型使用不规范。很多地方用字符串字面量联合类型,但没有穷尽检查,导致新增一个枚举值的时候,很多地方忘了处理,出现bug。

第五,泛型使用不足。很多可以用泛型的地方,没有用泛型,导致代码重复,类型不安全。比如一个工具函数,本来可以用泛型支持多种类型,结果写了好几个重载,或者直接用any。

TypeScript 5.4的新特性,正好可以解决这些问题。

TypeScript 5.4新特性

先看看TypeScript 5.4有哪些重要的新特性。

第一个是NoInfer工具类型。这是TypeScript 5.4新增的工具类型,用来阻止类型推断。在泛型函数中,有时候你不希望TypeScript从某个参数推断类型,而是希望用另一个参数的类型,这时候就可以用NoInfer。比如:

function createStreetLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) { // ... }

createStreetLight(["red", "yellow", "green"], "red"); // OK createStreetLight(["red", "yellow", "green"], "blue"); // Error

这样defaultColor的类型就不会被推断,而是用colors的类型C,检查更严格。

第二个是Object.groupBy和Map.groupBy。这是JavaScript的新特性,TypeScript 5.4增加了类型支持。可以用Object.groupBy把数组按某个条件分组:

const items = [ { name: "apple", category: "fruit" }, { name: "banana", category: "fruit" }, { name: "carrot", category: "vegetable" }, ];

const grouped = Object.groupBy(items, item => item.category); // grouped: { fruit: {...}[], vegetable: {...}[] }

Map.groupBy类似,但返回的是Map。这两个方法让分组操作更简洁,不用自己写reduce了。

第三个是更精确的类型收窄。TypeScript 5.4对类型收窄做了改进,特别是在闭包和回调函数中。以前在闭包中,变量的类型收窄会失效,TypeScript 5.4修复了这个问题。比如:

function process(data: stringnull) {

setTimeout(() => { console.log(data.length); // 以前可能报错,5.4之后正确收窄为string }, 1000); }

这个改进让闭包中的类型检查更准确,减少了不必要的类型断言。

第四个是satisfies操作符的改进。satisfies是TypeScript 4.9引入的,5.4做了一些改进,支持更多场景,错误提示也更清晰。

第五个是模块解析的改进。TypeScript 5.4对模块解析做了优化,特别是在monorepo和路径别名的场景下,解析速度更快,也更准确。

还有一些小的改进,比如更快的类型检查、更好的错误提示、更多的JSX支持等。

用NoInfer改进泛型函数

NoInfer是TypeScript 5.4我最喜欢的新特性,解决了很多泛型函数的类型推断问题。

以前我们项目里有一个配置函数,大概是这样的:

function createConfig<T extends string>(keys: T[], defaultKey?: T) { // ... }

这个函数的问题是,defaultKey的类型会被单独推断。如果你调用createConfig(["a", "b"], "c"),TypeScript不会报错,因为defaultKey被推断为string,而不是"a" | "b"。这就导致类型检查失效,传错了也不知道。

以前的解决办法是用函数重载,或者把defaultKey的类型写成T,但效果都不好。

有了NoInfer之后,就可以这样写:

function createConfig<T extends string>(keys: T[], defaultKey?: NoInfer<T>) { // ... }

这样defaultKey的类型就不会被单独推断,而是用keys的类型T。如果你传了一个不在keys里的值,TypeScript就会报错。

我们项目里有很多这样的泛型函数,都用NoInfer重构了,类型检查更严格了,也发现了几个之前没发现的bug。

NoInfer还有一个用处,就是在函数重载中。以前有些重载,类型推断会有问题,用NoInfer可以解决。

用Object.groupBy简化分组代码

Object.groupBy和Map.groupBy虽然是JavaScript的新特性,但TypeScript 5.4的类型支持让它更好用。

以前我们项目里有很多分组代码,都是用reduce写的:

function groupByCategory(items: Item[]) { return items.reduce((acc, item) => { if (!acc[item.category]) { acc[item.category] = []; } acc[item.category].push(item); return acc; }, {} as Record<string, Item[]>); }

这样写不仅啰嗦,而且类型断言as Record<string, Item[]>绕过了类型检查,不安全。

有了Object.groupBy之后,就可以这样写:

const grouped = Object.groupBy(items, item => item.category);

一行代码就搞定了,而且类型是自动推断的,不需要类型断言。grouped的类型是Partial<Record<string, Item[]>>,因为不是每个category都一定有值,所以是Partial的,访问的时候要注意判空。

我们项目里所有的分组代码都用Object.groupBy重构了,代码简洁了很多,类型也更安全了。

Map.groupBy类似,但返回的是Map。如果你需要用Map的方法,或者key不是字符串,就可以用Map.groupBy。

需要注意的是,Object.groupBy和Map.groupBy是比较新的JavaScript特性,需要运行环境支持。如果要兼容旧环境,需要用polyfill,或者用TypeScript的downlevelIteration。

用类型收窄减少类型断言

TypeScript 5.4对类型收窄的改进,也帮我们减少了很多类型断言。

以前我们项目里有很多这样的代码:

function fetchData(url: string, callback: (data: Datanull) => void) {

}

function process(url: string) { let result: Data | null = null;

fetchData(url, (data) => { result = data; });

setTimeout(() => { if (result !== null) { // 以前这里result还是Data | null,需要断言 console.log((result as Data).name); } }, 1000); }

以前在setTimeout的回调里,result的类型收窄会失效,因为TypeScript不知道回调什么时候执行,result可能已经被修改了。所以需要用as Data做类型断言,绕过检查。

TypeScript 5.4改进了这种场景下的类型收窄。如果变量在回调之后没有被修改,TypeScript就能正确收窄类型。上面的代码,在5.4之后就不需要类型断言了,result在if块里会被正确收窄为Data。

我们项目里很多这样的类型断言都去掉了,代码更简洁,类型也更安全。

不过要注意,类型收窄的改进是有条件的。如果变量在回调之后可能被修改,TypeScript还是不会收窄。这是合理的,因为确实可能有并发修改的问题。

用判别联合和穷尽检查

判别联合(Discriminated Union)是TypeScript里一个很强大的特性,5.4之后更好用了。

以前我们项目里有很多这样的代码:

type Shape = | { type: 'circle', radius: number } | { type: 'square', size: number } | { type: 'rectangle', width: number, height: number };

function getArea(shape: Shape): number { switch (shape.type) { case 'circle': return Math.PI shape.radius 2; case 'square': return shape.size 2; case 'rectangle': return shape.width shape.height; default: return 0; } }

这样写有个问题,如果以后新增了一种Shape类型,比如triangle,getArea函数不会报错,因为有default分支。这就导致新增类型的时候,很容易忘了处理,出现bug。

更好的写法是用穷尽检查(Exhaustive Check):

function getArea(shape: Shape): number { switch (shape.type) { case 'circle': return Math.PI shape.radius 2; case 'square': return shape.size 2; case 'rectangle': return shape.width shape.height; default: { const exhaustive: never = shape; return exhaustive; } } }

这样如果新增了一种Shape类型,default分支里的shape就不能赋值给never,TypeScript就会报错,提醒你处理新类型。

TypeScript 5.4对这种穷尽检查的错误提示做了改进,提示更清晰,能告诉你具体是哪个类型没有处理。

我们项目里所有的switch语句都加了穷尽检查,以后新增枚举值或者联合类型的时候,就不会漏处理了。这大大减少了bug。

用泛型和工具类型减少重复

TypeScript的泛型和工具类型很强大,用好了能大大减少重复代码。

以前我们项目里有很多这样的代码:

interface User { id: number; name: string; email: string; age: number; }

interface UserCreateDto { name: string; email: string; age: number; }

interface UserUpdateDto { name?: string; email?: string; age?: number; }

interface UserResponse { id: number; name: string; email: string; age: number; createdAt: string; updatedAt: string; }

这些类型都是基于User的,但每个都重复定义了一遍,维护起来很麻烦。如果User加了一个字段,所有相关的类型都要改。

用工具类型可以简化:

interface User { id: number; name: string; email: string; age: number; createdAt: string; updatedAt: string; }

// 创建的时候不需要id、createdAt、updatedAt type UserCreateDto = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;

// 更新的时候所有字段都是可选的,也不需要id、createdAt、updatedAt type UserUpdateDto = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>;

// 响应就是User本身,或者用Pick选择需要的字段 type UserResponse = User;

这样只需要维护一个User接口,其他类型都用工具类型派生出来。如果User加了字段,相关类型自动更新,不用手动改。

常用的工具类型有:

  • Partial<T>:所有字段变成可选
  • Required<T>:所有字段变成必选
  • Readonly<T>:所有字段变成只读
  • Pick<T, K>:选择部分字段
  • Omit<T, K>:排除部分字段
  • Record<K, T>:创建键值对类型
  • ReturnType<T>:获取函数返回值类型
  • Parameters<T>:获取函数参数类型
  • Awaited<T>:获取Promise的resolve类型

TypeScript 5.4又新增了NoInfer,工具类型更丰富了。

我们项目里所有的DTO类型都用工具类型重构了,代码量减少了很多,维护也更方便了。

用类型别名和接口组织代码

除了工具类型,合理组织类型定义也很重要。

以前我们项目里的类型定义很混乱,有的在文件顶部,有的在单独的types文件里,有的甚至直接写在函数参数里。同一个类型,在不同地方有不同的定义,维护起来很麻烦。

重构之后,我们把类型定义统一组织:

第一,领域类型放在domain目录下。比如用户相关的类型放在domain/user.ts,订单相关的类型放在domain/order.ts。这些是核心的领域模型,是其他类型的基础。

第二,DTO类型放在api目录下。比如用户相关的API类型放在api/user.ts,用工具类型从领域类型派生。

第三,组件相关的类型放在组件文件里。如果类型只在一个组件里用,就放在组件文件顶部,不用单独抽出来。

第四,通用的工具类型放在utils/types.ts。比如一些通用的工具类型、泛型工具函数的类型。

这样组织之后,类型定义清晰了很多,找类型也方便了。

还有就是,类型命名要规范。我们的规范是:

  • 领域模型用interface,比如User、Order
  • DTO用type别名,比如UserCreateDto、UserResponse
  • 联合类型用type,比如UserRole = 'admin' | 'user'
  • 函数类型用type,比如EventHandler = (event: Event) => void
  • 类型名用大驼峰,不要用小写下划线

规范的命名,让代码可读性更好。

用ESLint和类型检查保证质量

重构之后,还要用工具保证代码质量,防止烂代码再次出现。

第一个是严格的tsconfig。我们把tsconfig的strict设为true,开启所有严格检查。包括noImplicitAny、strictNullChecks、strictFunctionTypes等。还开启了noUnusedLocals、noUnusedParameters、noImplicitReturns等检查。这样任何类型问题都会被报错,不能蒙混过关。

第二个是ESLint。我们用了@typescript-eslint插件,开启了很多类型相关的规则,比如no-explicit-any(禁止显式any)、no-non-null-assertion(禁止非空断言)、prefer-optional-chain(优先用可选链)、consistent-type-imports(统一类型导入)等。这样代码风格统一,也能防止一些不好的写法。

第三个是Code Review。每次提交代码,都要经过Code Review。重点看类型定义是否合理、有没有any、有没有不必要的类型断言、有没有重复的类型。Code Review能发现很多工具发现不了的问题。

第四个是类型测试。对于一些复杂的泛型和工具类型,我们会写类型测试,用expect-type或者tsd库,验证类型是否符合预期。这样修改类型的时候,不会不小心改坏了。

有了这些工具和流程,代码质量就能持续保持,不会重构完之后又慢慢变回烂代码。

重构的经验

重构了几个月,总结了一些经验。

第一,不要为了用新特性而用新特性。新特性是为了解决问题的,如果旧代码没有问题,就不用改。只有当新特性能明显提升代码质量或者开发效率的时候,才值得重构。

第二,逐步重构,不要一次性全改。我们是一个模块一个模块地重构,改完一个测试一个,确保没有问题再改下一个。一次性全改风险太大,容易出问题。

第三,重构的时候要跑测试。重构最担心的是改出bug,所以要有测试保障。我们给核心逻辑写了单元测试,重构之后跑一遍测试,确保功能正常。

第四,团队要达成共识。重构不是一个人的事,需要整个团队都认同新的写法和规范。我们制定了类型规范,组织了代码评审,确保大家写出来的代码风格一致。

第五,关注TypeScript的更新。TypeScript的迭代很快,每个版本都有新特性和优化。要关注官方的更新日志,了解新特性,及时用到项目里。

写在最后

TypeScript 5.4的新特性,配合合理的类型组织和规范,能让代码质量提升一个档次。NoInfer让泛型推断更准确,Object.groupBy让分组更简洁,类型收窄的改进减少了类型断言,穷尽检查防止漏处理。

从烂代码到优雅代码,不是一蹴而就的,需要持续的重构和优化。TypeScript 5.4给了我们更好的工具,让重构变得更容易。

如果你还在用旧版本的TypeScript,建议升级到5.4,体验一下新特性。如果你已经在用5.4,不妨看看项目里有没有可以用新特性优化的地方,说不定能让代码优雅很多。

希望这篇文章能给你一些启发。有什么问题欢迎交流。