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 知道 valuestring;出了分支,也能推出剩下是 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=truedata 有值,到底展示啥?状态建清楚了,组件分支自然也清楚。

顺带说一个 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 里明确下来的行为:对 switchunique 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 是同一个诉求:尽量保住字面量的精确类型,别让它过早退化成宽泛的 stringnumber

以前写一个接受选项数组的函数,泛型推出来的往往是 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-tscvolar(编辑器插件)和项目 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 比较这些收窄相关的改进,属于升上去就能白拿的,可以尽早享受;至于 satisfiesconst 类型参数、判别联合这些其实不依赖 5.3、今年一直就能用的实践,倒是最该趁这次升级一起在团队里推开的——它们不需要等版本,需要的只是养成习惯。真正的升级动作,我还是会等 @typescript-eslintvue-tsc 都跟上,再把主仓库统一推上去。