TypeScript 5.3 值得关注的几个变化
TypeScript 5.3 昨天(11 月 20 号)发布,我照例把 release notes 从头到尾读了一遍,边读边在一个测试仓库里把每个变化亲手敲进去验一验。这是我这几年养成的习惯:版本更新的 changelog 不能只看标题,得动手跑——TypeScript 每次升级牵动的其实是日常开发时的类型反馈、和构建工具的协作方式,以及它和 JavaScript 新标准之间的距离,新增了几个语法关键字反倒是最次要的信息。
5.3 属于那种"不一定一眼惊艳、但会渗进工程细节"的版本。它没让前端写法发生大转向,却把几个方向继续补齐了:导入属性、类型收窄、运行时互操作,还有更细的类型检查体验。读文档加动手验之后,我把几个值得留意的点连同"该不该升、升完重点回归哪儿"的判断一起记了下来。
先说结论里最要紧的一条:我现在评估 TS 版本,不只看新语法,更看它会不会牵动编辑器反馈、CI 类型检查、构建工具解析和第三方类型声明。很多升级看着平滑,真正吃时间的是 ESLint、测试、构建脚本、monorepo 子包一起对齐。
Import Attributes:把资源类型写进导入语句
5.3 里最容易注意到的变化之一,是对 Import Attributes 的支持。我先在测试仓库里拿一个 JSON 导入试。以前我们导入 JSON 通常只写:
1import data from "./data.json";
在很多构建工具里它能跑,但从语言标准的角度看,模块系统其实不知道这个导入到底是什么类型的资源。Import Attributes 把它写明确了:
1import data from "./data.json" with { type: "json" }; 2 3console.log(data);
这类语法的价值不在"少写几行",而在把资源类型写进导入语句本身。对浏览器、Node.js、打包工具和类型系统来说,这更容易形成一致约定。
但我动手验的时候立刻撞见一层落差:TypeScript 支持语法,不等于运行环境支持。能不能真跑起来,得同时满足几层:
- Node.js 版本本身支不支持
with语法 - 浏览器(如果是浏览器端 import)支不支持
- 打包工具的 loader/transform 能不能解析
- 类型系统这一层认不认
TypeScript 认的只是最后那一层。在 Vite、Webpack、Next.js 这类工程里,得确认对应版本已经处理了这类导入属性,否则语法过了、运行时不认。所以别一看到语法支持就大面积替换,先在少量资源导入场景里把构建、测试和线上运行环境都验一遍。这也是我读文档时给自己划的一条硬线:release notes 说"支持"的东西,我一律先假设只是"编译器支持",运行时的账要自己去对。
类型收窄,继续变聪明
TypeScript 一直在打磨控制流分析——编译器根据你的判断条件,推断某个变量在当前分支里更具体的类型。最基础的例子:
1function print(value: string | number) { 2 if (typeof value === "string") { 3 console.log(value.toUpperCase()); 4 return; 5 } 6 7 console.log(value.toFixed(2)); 8}
进了 if 分支,TS 知道 value 是 string;出了分支,也能推出剩下是 number。看着普通,可在真实项目里极其重要——接口字段、表单状态、权限信息、组件 props 到处是联合类型,收窄不准,你就得被迫写一堆 as。
接口返回的状态用判别联合类型表达最稳:
1type RequestState<T> = 2 | { status: "idle" } 3 | { status: "loading" } 4 | { status: "success"; data: T } 5 | { status: "error"; message: string }; 6 7function renderUser(state: RequestState<{ name: string }>) { 8 if (state.status === "success") { 9 return state.data.name; 10 } 11 12 if (state.status === "error") { 13 return state.message; 14 } 15 16 return "暂无数据"; 17}
这比到处写可选链靠谱,因为每个状态携带什么字段是钉死的。类型系统会拦住你在 loading 里读 data,新增状态漏了处理它也会提醒。我越来越偏爱这种建模,就是因为它比 data?: T; loading: boolean; error?: string 更不容易冒出矛盾状态——loading=true 又 data 有值,到底展示啥?状态建清楚了,组件分支自然也清楚。
顺带说一个 5.3 里我特别喜欢的小改进:switch (true) 的收窄。以前用 switch (true) 配合各分支的布尔判断,TS 在每个 case 里并不会帮你收窄类型,你还是得手动断言;5.3 让它在 case 分支里也能按条件收窄了:
1function describe(value: string | number | boolean) { 2 switch (true) { 3 case typeof value === "string": 4 // 这里 value 被收窄成 string 5 return value.toUpperCase(); 6 case typeof value === "number": 7 // 这里 value 被收窄成 number 8 return value.toFixed(2); 9 default: 10 return String(value); 11 } 12}
对喜欢用 switch (true) 代替长 if-else 链的写法,这是实打实少写几个 as。这类小改进单独看不起眼,但它们累加起来就是"少写断言"这个大方向——TypeScript 每个版本都在往这条线上走,让编译器更懂你的判断意图,而不是让你去哄编译器。
还有一个和收窄相关、5.3 里明确下来的行为:对 switch 里 unique symbol 常量的比较收窄也更准了。日常业务里未必天天用到,但如果你在写偏底层的工具库、用 symbol 做类型标记,这类改进会让推断少几处意外。评估要不要升时,这种"边角推断更准"往往比某个明星语法更实在,因为它影响的是你天天调用时的类型体验,而不是偶尔炫技的写法。
顺手复习:satisfies 才是替代很多 as 的正解
说到少写 as,就不得不提 satisfies。它是去年(4.9)加进来的,今年一整年都能用,但我发现团队里还有不少人没养成用它的习惯,动不动还是 as。这两者的差别值得单独说清楚。
as 是"我告诉编译器这个值是什么类型",它会关掉检查;satisfies 是"我要求这个值满足某个类型,但保留它自己更具体的类型"。区别在一个具体例子里最清楚:
1type RouteConfig = Record<string, { path: string; auth: boolean }>; 2 3// 用 as:满足了约束,但每个 key 的字面量类型丢了 4const routesA = { 5 home: { path: "/", auth: false }, 6 admin: { path: "/admin", auth: true }, 7} as RouteConfig; 8 9// 用 satisfies:既校验了结构,又保留了 "home" | "admin" 这些字面量 key 10const routesB = { 11 home: { path: "/", auth: false }, 12 admin: { path: "/admin", auth: true }, 13} satisfies RouteConfig; 14 15// routesB.home 有精确类型,routesA 拿 key 时已经退化成 string
很多本该用 satisfies 的地方被写成了 as,结果就是"约束加上了,但自动补全和字面量收窄没了"。我现在 review 时看到配置对象后面跟 as,第一反应就是问一句:这里是不是该换 satisfies。这跟下面要说的"少用 as"是一条线上的事。
少用 as,让类型从数据结构里长出来
升级 TS 后,有些项目会突然冒出更多类型错误。第一反应往往是加 as 压过去:
1const user = response.data as User;
这不是完全不能用,但它的含义是"我比编译器更确定"。如果这份确定来自运行时校验、后端契约或明确的接口封装,可以接受;如果只是为了让报错闭嘴,那你是在把真实问题藏起来。
更好的做法是把不确定性留在边界层:
1type User = { 2 id: string; 3 name: string; 4}; 5 6function isUser(value: unknown): value is User { 7 if (!value || typeof value !== "object") { 8 return false; 9 } 10 11 const user = value as Record<string, unknown>; 12 return typeof user.id === "string" && typeof user.name === "string"; 13} 14 15async function loadUser() { 16 const response = await fetch("/api/user"); 17 const data: unknown = await response.json(); 18 19 if (!isUser(data)) { 20 throw new Error("Invalid user response"); 21 } 22 23 return data; 24}
这样业务层拿到的就是可靠的 User。类型判断集中在接口边界,组件里不必反复猜字段在不在。
如果项目已经有运行时 schema 校验工具,比如社区里越来越常见的 Zod,也可以把这层校验交给 schema,用一份声明同时得到运行时校验和推断出来的静态类型,省掉手写 isUser:
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 10async function loadUser() { 11 const response = await fetch("/api/user"); 12 // parse 失败会抛错,通过之后 data 就是可靠的 User 13 return UserSchema.parse(await response.json()); 14}
一份 UserSchema 同时管住了运行时和编译时,z.infer 把类型从 schema 里推出来,不用再手写一遍 type User,两边也不会写着写着就对不上。重点不在于一定要手写类型守卫,而在于别把未知的接口数据直接断言成业务类型。as 用多了,类型系统就退化成注释。
顺带一提今年的 const 类型参数
既然聊到"让类型从数据里长出来",今年 5.0 加的 const 类型参数也值得一并复习,因为它和判别联合、satisfies 是同一个诉求:尽量保住字面量的精确类型,别让它过早退化成宽泛的 string、number。
以前写一个接受选项数组的函数,泛型推出来的往往是 string[],丢掉了具体值:
1function pick<T extends readonly string[]>(options: T): T[number] { 2 return options[0]; 3} 4 5// 没有 const,keys 推成 string,返回类型就是 string 6const a = pick(["home", "about", "contact"]);
给类型参数加上 const,推断就会保留成字面量联合:
1function pick<const T extends readonly string[]>(options: T): T[number] { 2 return options[0]; 3} 4 5// 有 const,返回类型收窄成 "home" | "about" | "contact" 6const b = pick(["home", "about", "contact"]);
这类能力放到真实项目里,最典型的用途就是那些"传一组固定字符串进去、还想拿回精确类型"的工具函数——表单字段名、事件名、路由 key。它和判别联合是一套思路的两头:一头在建模时用联合把状态钉死,一头在函数入参上用 const 把字面量保住。我升级 TS 时会顺便扫一遍这类工具函数,看有没有该加 const 却还在返回宽类型的地方。
升级 TS,不是只改一个版本号
TypeScript 升级通常算平滑,但大型项目仍得谨慎,因为它正卡在构建工具、编辑器、类型声明和 ESLint 规则的交界处,一个版本变化能牵动好几个环节。我升级前会逐条核对:
- 构建工具是否支持新版本 TypeScript
@typescript-eslint是否兼容- IDE 用的 TypeScript 版本是否和项目一致
- CI 上的
tsc --noEmit是否通过 - 第三方库的类型声明是否冒出新报错
- 是否依赖了旧的模块解析行为
有 monorepo、组件库、Node.js 脚本、Next.js 或 Vite 这种多运行环境的项目,还得分别验。业务代码能编译,不代表构建脚本、测试脚本、发布脚本都没事。我的做法是把 TS 升级拆成单独 PR,不跟业务需求混——升级 PR 只做版本、配置和必要的类型修正,好回滚,也方便 review 判断哪些报错是版本带来的。混进功能 PR 里,风险很容易被淹掉。
我们团队主力是 Vue,所以升 TS 我还会多盯一处:vue-tsc。Vue 单文件组件的类型检查走的是 vue-tsc 这条独立链路,它内部依赖一个特定版本的 TypeScript,未必和你项目根目录装的那个同步。有过一次经验是根目录 TS 升上去了,vue-tsc 没跟上,模板里的类型报错和编辑器里对不上,查了半天才反应过来是两个 TS 版本在打架。所以 Vue 项目升 TS,得把 vue-tsc、volar(编辑器插件)和项目 TS 版本三者一起看,别只盯 devDependencies 里那一行 typescript。这也是"升级不是改一个版本号"在 Vue 生态里的具体形态。
工具链断层,比新报错更难找
升级时卡住我的,多半是工具链适配的断层——TypeScript 认得新语法,打包工具、测试框架还不认识。那些一眼就能看到的类型报错反而好处理,改改代码就过了。
5.3 正式支持 Import Attributes(with { type: "json" }),同时把 TS 4.5 引入的 Import Assertions 旧写法(assert { type: "json" })标记为废弃:
1// 旧写法:Import Assertions(TS 4.5 引入,5.3 起废弃告警) 2import data from "./config.json" assert { type: "json" }; 3 4// 新写法:Import Attributes(5.3 正式支持) 5import data from "./config.json" with { type: "json" };
麻烦在于:编译器认识 with,但项目里若用着较老版本的 Webpack loader、Babel 插件或 Jest 转换器,它们可能只认 assert 不认 with。升完 TS 改用新语法,构建或测试直接抛 parse error——这不是你代码写错了,是两端切换期的时间差。
排查这类断层,我的顺序是:先确认 TypeScript 本身能编译过(tsc --noEmit),再单独验 Vite/Webpack/Jest/Vitest 这条链路能不能处理新语法。工具链还没跟上,就先留着旧的 assert 写法(5.3 虽废弃但仍接受),等对齐了再统一切。这也是我坚持把 TS 升级单开 PR 的理由之一——工具链没准备好时,可以随时回退 TS 版本,不牵连业务改动。
我的评估结论
我现在升 TypeScript,会把它当成一次工程链路变更,而不是改一个 devDependency。最少盯三条线:编辑器类型反馈有没有异常,tsc --noEmit 或 CI 类型检查过不过,构建和测试链路还能不能正确处理 JSON、ESM、Jest/Vitest、Next/Vite 这些边界。Import Attributes 这类语法尤其得小心,编译器认得,不代表运行时和打包工具都认得。
对业务项目来说,5.3 最实在的价值不是"多了新语法",而是让模块导入、状态分支和类型收窄更明确。能少写几个不必要的断言,能让漏掉的分支提前暴露——这些看不见的细节,才是真正决定要不要升级的理由。
这次我给自己的结论分了三档:Import Attributes 这类新语法先在测试仓库和内部工具里放放,等打包工具、Jest/Vitest 都对齐了再进主仓库;switch (true) 收窄、symbol 比较这些收窄相关的改进,属于升上去就能白拿的,可以尽早享受;至于 satisfies、const 类型参数、判别联合这些其实不依赖 5.3、今年一直就能用的实践,倒是最该趁这次升级一起在团队里推开的——它们不需要等版本,需要的只是养成习惯。真正的升级动作,我还是会等 @typescript-eslint 和 vue-tsc 都跟上,再把主仓库统一推上去。