TypeScript 5.5 实用变化:类型推断越来越懂业务代码
TypeScript 的版本更新很容易被低估。很多人只关心有没有新语法,但真正影响日常开发体验的,往往是类型推断变聪明了一点、错误提示更准确了一点、某些以前需要手写类型守卫的地方现在能自动理解。
TypeScript 5.5 给我的一个明显感受是:它越来越能理解业务代码里常见的判断逻辑。尤其是数组过滤、类型收窄这类场景,写起来比以前顺了不少。
这里不追求罗列所有新特性,而是从项目里最常见的几个痛点讲起。
数组过滤后的类型问题
以前写 TypeScript,经常会遇到这样的代码:
1type User = { 2 id: string 3 name: string 4} 5 6const users: Array<User | null> = [ 7 { id: '1', name: 'Alice' }, 8 null, 9 { id: '2', name: 'Bob' }, 10] 11 12const validUsers = users.filter((user) => user !== null)
直觉上,validUsers 应该是 User[]。但在 5.5 之前,这段代码推出来的类型还是 (User | null)[],回调里那个 user !== null 的判断完全被忽略了。结果就是后面写 validUsers[0].name 直接报错,说对象可能为 null。
我第一次踩这个坑是在一个列表渲染的场景,数据源里混了一些后端没清干净的脏数据,过滤完直接 .map 渲染,结果红线满屏。当时还以为是自己哪里写错了,盯着看了半天才反应过来是 filter 的返回类型没收窄。
于是项目里会出现工具函数:
1function isNonNullable<T>(value: T): value is NonNullable<T> { 2 return value !== null && value !== undefined 3} 4 5const validUsers = users.filter(isNonNullable)
这种写法很稳,但对新手不友好。很多业务开发只是想过滤空值,却被迫学习类型谓词。我见过有人因为不知道 is 这个语法,干脆在过滤后面又补了一行 as User[],把问题从“类型不准”变成了“类型在骗人”。
TypeScript 5.5 对这类推断做了改进:只要 filter 的回调里写的是一个布尔表达式,并且这个表达式本身能起到类型收窄的作用(比如 x !== null、typeof x === 'string'、x.ok === true),编译器现在会自动把它当成类型谓词来推断返回类型。所以开头那段不加任何工具函数的代码,在 5.5 里 validUsers 直接就是 User[]。
这背后的机制是编译器分析回调的控制流,看返回值是否恰好对应某个收窄后的子类型。它不是万能的,回调里有副作用、有多个 return 分支、或者用了变量中转,它就推不出来了:
1// 这种它认得 2users.filter((u) => u !== null) 3 4// 这种它放弃了,还是 (User | null)[] 5users.filter((u) => { 6 const ok = u !== null 7 doSomething() 8 return ok 9})
我的体会是,它的意义不是让类型体操更强,而是减少业务代码里那些“为了哄类型系统而写的代码”。日常 80% 的过滤场景现在都能白嫖,剩下 20% 复杂的再老老实实写 is。
这里多说一句它的判定规则,因为踩过一次很隐蔽的坑。5.5 推断谓词的前提是:回调的返回值类型,恰好是参数类型的一个“真子集”,并且每一条可达的返回路径都能对应到收窄结果。官方在实现里大致是这么判的——把回调当成一个返回 boolean 的函数,分析返回 true 时参数被收窄成什么、返回 false 时又是什么,两边能凑成一个干净的 x is S 才认。一旦你的回调返回的是 boolean 以外的“真值/假值”,它立刻退化。我当时写的是这种:
1type Item = { id: string; tag?: string } 2 3// 想过滤掉没有 tag 的,结果推断没生效 4const tagged = items.filter((i) => i.tag)
i.tag 的类型是 string | undefined,回调返回的是这个值本身而不是布尔,控制流分析没法把它归约成一个谓词,于是 tagged 还是 Item[],tagged[0].tag 依旧是 string | undefined。改成显式比较就好了:
1// 这样 5.5 能推出 tagged: (Item & { tag: string })[] 2const tagged = items.filter((i) => i.tag !== undefined)
那次是 review 别人代码时发现的,对方在 tagged.map(i => i.tag.length) 上加了 ! 非空断言把红线压下去了。表面看没问题,但 i.tag 本来就该被收窄,加 ! 等于把编译器本来能帮你做的事手动关掉了——一旦上游逻辑变化,这个 ! 就成了静默的隐患。所以遇到“filter 之后还要 ! 或 as”,我现在第一反应是去检查回调写得够不够“布尔”,而不是急着断言。
类型守卫仍然重要
推断变聪明,不代表类型守卫不需要了。
当判断逻辑比较复杂时,显式类型守卫仍然更清楚:
1type ApiSuccess<T> = { 2 ok: true 3 data: T 4} 5 6type ApiFailure = { 7 ok: false 8 error: { 9 message: string 10 code?: string 11 } 12} 13 14type ApiResult<T> = ApiSuccess<T> | ApiFailure 15 16function isSuccess<T>(result: ApiResult<T>): result is ApiSuccess<T> { 17 return result.ok 18}
使用时:
1const results = await Promise.allSettled([ 2 fetchUser(), 3 fetchPosts(), 4]) 5 6const fulfilled = results.filter( 7 (result): result is PromiseFulfilledResult<unknown> => 8 result.status === 'fulfilled' 9)
这里显式写类型谓词更合适,因为它表达的是业务结构,不只是简单的空值过滤。而且 Promise.allSettled 的结果是 PromiseSettledResult,靠 status === 'fulfilled' 收窄到 PromiseFulfilledResult 这一步,即便 5.5 的推断帮你做了,我也更愿意写明,因为读代码的人一眼就知道这里在筛成功项。
还有个我踩过的坑:自动推断出来的谓词会把你的判断当成“充分必要”的。如果你的回调写得不够严谨,类型反而被收窄过头。比如:
1function isAdmin(u: User): u is User & { role: 'admin' } { 2 return u.role === 'admin' 3}
这种带交叉类型的收窄,编译器自动推断时给不出这么精确的结果,必须自己写谓词把目标类型钉死。所以越是要表达“收窄到一个更具体的形状”,越得显式声明。
我的习惯是:简单的空值、基础类型判断交给推断;跨模块复用、收窄到具体子类型的业务判断写成类型守卫,函数名本身就是文档。
类型收窄要贴近运行时
TypeScript 类型不能替代运行时校验。尤其是接口返回、URL 参数、本地存储、消息事件,这些数据都来自外部,不能只靠 as。
危险写法:
1const user = await response.json() as User 2console.log(user.name.toUpperCase())
这只是告诉 TypeScript “相信我”,但运行时并没有任何保证。后端某天把 name 改成了 null,或者字段名从 name 改成 userName,编译期一片绿,线上直接 Cannot read properties of null。这类问题的典型触发场景是后端灰度发布:接口字段改了,前端 as 一路放行,编译期完全看不出异常,只有监控里的 JS 报错峰值能反映出来。对接口返回的 as 保持警惕,是避免这类问题最直接的办法。
这里别把时间线写错:in 操作符对 unknown 的这类收窄不是 5.5 才来的,TypeScript 4.9 就已经补过“未列出属性”的 narrowing。5.5 真正让我少写样板代码的是前面提到的 filter 谓词推断,以及更稳的控制流分析。所以下面这个 parseUser 例子,更适合放在“外部数据必须做运行时校验”的语境里,而不是当成 5.5 的新特性。
更稳的方式是做基础校验:
1type User = { 2 id: string 3 name: string 4} 5 6function parseUser(value: unknown): User { 7 if ( 8 typeof value === 'object' && 9 value !== null && 10 'id' in value && 11 'name' in value && 12 typeof value.id === 'string' && 13 typeof value.name === 'string' 14 ) { 15 return value 16 } 17 18 throw { 19 code: 'INVALID_USER', 20 message: 'Invalid user response', 21 } 22}
然后:
1const body = await response.json() 2const user = parseUser(body)
类型系统负责开发期约束,运行时校验负责挡住来路不明的数据。两者不能互相替代。
还有一类更隐蔽的问题,跟 as 没直接关系,而是和“类型擦除”这个底层事实有关。一个上报函数签名是 report(event: EventName, payload: Record<string, string>),编译期一切正常,但运行时某个事件的 value 字段可能时有时无、类型还忽数字忽字符串。根源在于:TypeScript 的类型在编译后是被完全擦除的,运行时根本不存在 Record<string, string> 这个约束。如果上游有个地方从 URLSearchParams 拿值本以为是 string,但中间过了一道 JSON.parse 又被并进来一些 number,类型标注写着 string,运行时塞进去的是 number,编译器看不见也拦不住。
这件事让我重新理解了一句老话:TypeScript 的类型只在编译期存在,它不是运行时的护栏。凡是数据“跨过了编译器看不见的边界”——网络、JSON.parse、postMessage、localStorage、eval 进来的东西——类型标注就只是一句注释。我后来在这些入口统一加了一层 z.coerce.string() 之类的强制转换,把“我以为是 string”变成“运行时保证是 string”,问题才彻底消失。
手写校验在字段少的时候够用,字段一多就难维护了。我们后来在核心接口上引入了 zod,把类型和校验合二为一:
1import { z } from 'zod' 2 3const UserSchema = z.object({ 4 id: z.string(), 5 name: z.string(), 6}) 7 8type User = z.infer<typeof UserSchema> 9 10const user = UserSchema.parse(await response.json())
z.infer 直接从 schema 反推出类型,再也不用类型定义和校验逻辑各写一遍、改一个忘一个。不过 zod 不是免费的——它有运行时开销,schema 复杂时打包体积也会涨。我的取舍是:核心链路、表单提交、支付相关的接口上 zod;纯展示、不影响主流程的接口,手写一个 typeof 判断就够了,没必要全站铺开。
satisfies 比 as 更适合配置
项目里经常有配置对象:
1type RouteConfig = { 2 path: string 3 title: string 4 auth?: boolean 5} 6 7const routes = [ 8 { path: '/', title: '首页' }, 9 { path: '/admin', title: '管理后台', auth: true }, 10] satisfies RouteConfig[]
satisfies 的好处是:它检查对象满足指定类型,同时保留更具体的字面量信息。
如果写成:
1const routes = [ 2 { path: '/', title: '首页' }, 3] as RouteConfig[]
as 更像是断言,容易掩盖错误。它最大的问题是双向都不查:你把对象 as 成 RouteConfig[],少写个 title 它不报错,多写个不存在的字段它也不报错,等于把类型检查彻底关掉。satisfies 反过来——它检查得比普通赋值还严,少字段、多字段、类型不匹配都会报,但又不会把你声明的字面量类型抹平成 string。
举个具体差别。用 satisfies 时 routes[0].path 的类型是字面量 '/',你后面能拿它去做联合类型的索引;要是写成 : RouteConfig[] 显式标注,path 就被拓宽成 string 了,字面量信息全丢。这个区别在做“根据路由表自动生成路径联合类型”这种事情时特别关键。
我在配置、路由表、菜单、权限点、埋点事件名里很喜欢用 satisfies。这些地方既需要结构约束,又希望保留具体值,方便后续推导联合类型。
1const events = { 2 login: 'user_login', 3 logout: 'user_logout', 4 submitPost: 'post_submit', 5} as const satisfies Record<string, string> 6 7type EventName = keyof typeof events
这里 as const 和 satisfies 的顺序也有讲究,得是 as const satisfies,先冻成字面量再做约束。反过来写或者只写 satisfies 不加 as const,EventName 推出来的就是 string 而不是 'login' | 'logout' | 'submitPost',埋点上报时想用联合类型卡住拼写错误的目的就达不到了。我们埋点事件名以前是散落各处的字符串,改一个漏一个,统一成这种结构后,传错事件名编译期直接报红,省了不少对数据的扯皮。
为什么顺序非得是这样,值得拆开看一下,因为这俩做的事根本不在一个层面。as const 是一个“类型断言”,它影响的是字面量被推断成什么——{ login: 'user_login' } 默认会把值的类型拓宽成 string,as const 让它保持成只读的字面量 'user_login'。而 satisfies 是一个“约束检查”,它要拿当前表达式的类型去比对目标类型,比对完之后原样返回这个表达式的类型,不做任何拓宽或改写。所以 as const satisfies Record<string, string> 的执行顺序是:先把对象冻成 { readonly login: 'user_login'; ... },再拿这个精确类型去验证它确实是 Record<string, string>,验证通过,类型保持精确。
如果只写 satisfies 不加 as const,对象在比对前就已经被拓宽成 { login: string; ... } 了,satisfies 拿到的就是拓宽后的类型,自然推不出联合。下面这张对照表是我贴在团队 wiki 上的,每次有人搞混就甩过去:
1const a = { x: 'foo' } // { x: string } 2const b = { x: 'foo' } as const // { readonly x: 'foo' } 3const c = { x: 'foo' } satisfies Record<string, string> // { x: string } 4const d = { x: 'foo' } as const satisfies Record<string, string> 5 // { readonly x: 'foo' } 6 7type Cx = (typeof c)['x'] // string 8type Dx = (typeof d)['x'] // 'foo'
还有个延伸用法,配合 satisfies 保留下来的字面量,可以反向把路由表生成路径联合类型,再也不用手维护一份字符串枚举:
1const routes = [ 2 { path: '/', title: '首页' }, 3 { path: '/posts/:id', title: '文章详情' }, 4 { path: '/admin', title: '管理后台', auth: true }, 5] as const satisfies readonly RouteConfig[] 6 7type RoutePath = (typeof routes)[number]['path'] 8// '/' | '/posts/:id' | '/admin' 9 10function navigate(path: RoutePath) { 11 // 传 '/aboutt' 这种拼错的路径,编译期就红 12 history.pushState(null, '', path) 13}
这套 as const satisfies + 索引取值的组合,是我现在维护各种“单一数据源”表格(路由、权限点、feature flag、菜单)的默认姿势——数据和类型只写一遍,新增一行配置,对应的联合类型自动跟着长出来。
严格模式不要一次性硬上
很多老项目想升级 TypeScript,第一步就打开所有严格配置:
1{ 2 "compilerOptions": { 3 "strict": true 4 } 5}
然后报出几千个错误,团队直接放弃。
更现实的方式是分阶段推进:
1{ 2 "compilerOptions": { 3 "noImplicitAny": true, 4 "strictNullChecks": true 5 } 6}
先处理最有价值的两类问题:隐式 any 和空值错误。
我自己推老项目的顺序一般是:先开 noImplicitAny,把所有“不知道是什么类型”的地方暴露出来,这一步改起来机械但收益高;稳定一两周后再开 strictNullChecks,这是工作量最大的一步,建议配合 CI 卡住新增错误数,老错误慢慢还,别想着一周清完。等这两个稳了,再补 strictFunctionTypes、noImplicitThis 这些,最后整体切 strict: true 基本就没几个错了。
还有个实用技巧:tsconfig 里可以临时把报错最多的目录用 // @ts-nocheck 或者单独的 exclude 隔出去,让主干先达标,存量代码按模块逐步收复,而不是让几千个错误一直挂在那打击士气。
strictNullChecks 对业务质量提升非常明显。很多线上错误本质上就是没有处理 null、undefined:
1function getUserName(user?: User) { 2 return user.name 3}
开启后,TypeScript 会逼你正视这种可能性:
1function getUserName(user?: User) { 2 return user?.name || '匿名用户' 3}
这不是类型系统挑刺,而是在逼你把运行时可能发生的事情写清楚。
说点落地的细节。分阶段最难的不是改代码,是"不让存量错误数往上涨"。我们最早的做法很糙:CI 里用 tsc --noEmit | grep -c 'error TS' 数一遍报错条数,跟基线比,超了就 fail。这招实现简单,但只认数量不认具体是哪几条,有人删一个旧错误、加一个新错误,总数没变就蒙混过去了。
后来换成了更靠谱的方案:用 tsconfig 的 references 把项目拆成几个子工程,已经达标的子工程单独开 strict,没达标的维持宽松,靠 project references 把两边物理上分开,而不是靠满屏 // @ts-nocheck。这样新写的模块从第一天就是严格的,存量的慢慢还,互不拖累,而且是编译器强制分开,不是靠人自觉。
还有个原理层面的点值得知道:strictNullChecks 不只是“多报几个空值错误”,它会改变整个类型系统对 null/undefined 的建模方式。关掉时,null 和 undefined 是所有类型的子类型——string 类型的变量可以合法地存 null,这是 TS 早期为了兼容 JS 的妥协。打开后,null 和 undefined 变成独立的类型,必须显式写进联合里(string | null)才能赋值。所以这个开关一旦打开,几乎所有涉及可选值的函数签名都要重新表态,工作量大的根源就在这——它不是加校验,是把一个底层假设给改了。理解这点之后,我推进时就不再纠结“为什么开个开关报这么多错”,那是必然的。
类型不要过度抽象
TypeScript 用久了,很容易走向另一个极端:什么都想抽象成高级类型。
例如:
1type DeepReadonly<T> = { 2 readonly [K in keyof T]: T[K] extends object 3 ? DeepReadonly<T[K]> 4 : T[K] 5}
这类工具类型有价值,但不应该成为业务代码的常态。业务团队最需要的是可读、可维护、错误清楚,而不是展示类型技巧。
我更倾向于把类型分三层:
- 领域类型:User、Post、Order
- 边界类型:ApiResponse、FormState、RouteParams
- 工具类型:少量通用辅助
领域类型要清楚,边界类型要稳定,工具类型要克制。
我对工具类型的判断标准很简单:如果一个类型要让团队里的人盯着看超过十秒才能搞懂它在干嘛,那它就不该出现在业务代码里。条件类型嵌套、infer 套娃这些放在底层库或者公共类型包里没问题,但 PR 里看到业务同学为了一个表单状态写出三层 extends,我一般会建议拆开。类型是给人读的,编译器能算明白不代表同事能看明白。一个让我印象深的反面例子是有人把一个简单的“可选字段变必填”用五行 mapped type 实现,其实 Required<Pick<T, 'a' | 'b'>> & Omit<T, 'a' | 'b'> 甚至直接重写一个接口都更清楚。
API 错误类型要统一
既然用了 TypeScript,就应该把 API 错误结构也统一起来。
1type ApiError = { 2 status: number 3 code: string 4 message: string 5} 6 7type ApiResponse<T> = 8 | { ok: true; data: T } 9 | { ok: false; error: ApiError }
最初的实现很直白:fetch 失败或 !response.ok 时组装出 error 分支返回,成功就直接 return { ok: true, data: body as T }。问题也很明显——没有任何运行时校验,as T 只是编译期的自我安慰,body 到底长什么样完全没有保证。
页面使用时:
1const result = await request<User>('/api/user') 2 3if (!result.ok) { 4 showToast(result.error.message) 5 return 6} 7 8renderUser(result.data)
这种联合类型能让调用方必须处理失败分支,比到处 throw 再随缘 catch 更适合一些业务页面。关键在于这个 ok 字段——它是辨别联合(discriminated union)的判别字段,if (!result.ok) return 之后,编译器自动把 result 收窄成成功分支,result.data 拿到的就是干净的 T,不用再做任何断言。这种写法逼着调用方在拿数据前先面对失败的可能,比 try/catch 那种“出错了再说”的处理方式要稳得多。
不过我也不主张一刀切。Result 模式在“失败是预期内的业务分支”时最舒服,比如表单校验、查询为空;但对于那种“出错了就该中断整个流程”的场景(比如 token 失效要全局登出),层层往上 return 反而啰嗦,这时候 throw 一个自定义错误、在外层统一拦更顺。我们项目里的做法是:能在当前页面内处理掉的失败用 Result,需要跨层中断的用 throw,两套并存,按错误的“传播距离”来选。
回到前面说的 as T 那个雷:request 函数信任了 body 的结构,但 body 是 response.json() 来的 unknown,真要严谨得接一个校验函数或 zod schema 进来。我一般会把 request 改成接收一个 parse: (v: unknown) => T 参数,把校验责任交给调用方,这样错误结构统一和数据校验就一起解决了:
1async function request<T>( 2 url: string, 3 parse: (v: unknown) => T, 4): Promise<ApiResponse<T>> { 5 try { 6 const response = await fetch(url) 7 const body = await response.json().catch(() => null) 8 9 if (!response.ok) { 10 return { 11 ok: false, 12 error: { 13 status: response.status, 14 code: body?.error?.code || 'HTTP_ERROR', 15 message: body?.error?.message || '请求失败', 16 }, 17 } 18 } 19 20 // 校验失败会 throw,被下面的 catch 收成统一的 PARSE_ERROR 21 return { ok: true, data: parse(body) } 22 } catch (error) { 23 const isParseError = error instanceof Error && error.name === 'ZodError' 24 return { 25 ok: false, 26 error: { 27 status: 0, 28 code: isParseError ? 'PARSE_ERROR' : 'NETWORK_ERROR', 29 message: isParseError ? '返回数据格式异常' : '网络异常', 30 }, 31 } 32 } 33} 34 35// 调用方传 schema 的 parse 进去,类型 T 由 schema 反推 36const result = await request('/api/user', (v) => UserSchema.parse(v)) 37// ^? ApiResponse<User>
注意这里 T 完全是从 parse 的返回类型推出来的,调用处一个手写类型都不用标——这是把“数据校验”和“类型来源”收口到同一个地方的关键。以前那种 request<User>(url) 的写法,<User> 只是个空头承诺,没人保证返回的真是 User;换成传 parse 之后,类型和校验绑死了,想骗类型系统都骗不了。
再补一点辨别联合的底层原理,因为它是上面 Result 模式能成立的根基。编译器做收窄靠的是“判别属性”(discriminant property):联合的每个成员都有同名属性,且该属性的类型是互不重叠的字面量(这里是 ok: true 和 ok: false)。if (result.ok) 这种判断,编译器会去查每个成员上 ok 的字面量类型,把不匹配的成员从联合里剔掉。所以判别字段必须是字面量类型——如果你把 ok: true 写成 ok: boolean,两个成员的 ok 类型重叠了,收窄当场失效,这是新人写辨别联合最常见的翻车点。同理 status: 'success' | 'error' 这种字符串字面量判别也行,但 status: string 不行。
TypeScript 5.5 的意义不只是新增几个特性,而是让类型推断更贴近日常业务写法。数组过滤、类型收窄、配置校验这些场景越顺,开发者越愿意让类型系统参与真实项目。
但类型系统不是万能的。外部数据要运行时校验,错误结构要统一,严格模式要分阶段推进,高级类型要克制使用。写 TypeScript 的目标不是让代码看起来更复杂,而是让那种盯着满屏红线、以为自己哪里写错了的排查时刻越来越少——5.5 帮你少写了几行判定,不代表可以少想一层数据到底会不会是 null。