《TypeScript高级编程》11.1 TS 版本演进与 breaking changes

本节梳理 TypeScript 从 1.x 到 5.x 的演进主线,把 breaking change 归为检查收紧、默认值变更、lib.d.ts 更新与废弃项移除四类,并逐类给出可复现的报错示例与回退边界。你会掌握一套「基线快照 + 目标版本双跑 + 增量 diff」的升级流程,能在升级前量化影响面、判断某个报错是版本所致还是代码本身的问题。读完你能为团队定下可回滚的升级节奏。

本节目标:搞清楚 TypeScript 的版本演进有哪些规律,为什么只升一个次版本号也可能让整个仓库报红,以及一套在真实团队里能落地的升级流程。读完你应该能回答三个问题:这次升级会带来哪一类 breaking change、如何提前量化它的影响面、以及回滚的边界在哪里。

11.1 TS 版本演进与 breaking changes

团队对 TypeScript 升级的态度通常是两极的:要么「类型只是编译期的,随手升到最新」,要么「锁死在 4.9 不敢动」。两种态度来自同一个误解——把 TypeScript 当成普通依赖。它更像编译器:升级会改变它对同一份代码的判定结果,某些 emit 修复或默认配置变化也会改变生成的 JavaScript,因而必须同时验证运行时行为。而「判定结果变了」在库作者手里会被放大成下游几万个项目的连锁报错。

这也解释了一个现象:npm install typescript@latest 本身几乎不会失败,失败的是紧随其后的 tsc --noEmit。所以升级问题的本质不是「怎么装」,而是「怎么知道自己被判定结果的变化影响了多少」。

本节分四步走:先看清演进的节奏,再给 breaking change 分类,然后逐类看最容易撞上的报错,最后落到一套可执行的升级流程。

11.1.1 为什么升级对库作者是「一等公民」问题

应用作者升级失败,成本是自己仓库的报错;库作者升级失败,成本是所有下游。区别来自 typescript 在依赖树里的两种角色:

角色谁在用升级影响面关键约束
开发期工具应用项目仅本仓库可随时回退
类型契约来源库 / SDK下游全部消费者受 semver 约束

当你的包发布 .d.ts 时,消费者用他们自己的 TypeScript 版本去检查你生成的声明文件。这意味着你的类型写法必须在一段版本区间内都能通过检查。这个区间就是你的「类型支持窗口」。

支持窗口不能随便承诺。业界常见做法是「最近三个次版本」,具体范围取决于维护成本,不能把社区类型包的支持政策直接当作自家库的承诺。窗口开得越宽,你能用的新语法越少;开得越窄,下游被逼升级的压力越大。这个取舍与发布策略强相关,可以对照 7.3 semver、发布与类型破坏性变更 与 库发布的 semver 依赖解析 一起看。

11.1.2 四个阶段:从补丁到平台

把 1.x 到 5.x 连起来看,演进可以分成四个阶段,每个阶段的风险形态完全不同:

阶段代表版本主题对升级的影响
奠基期1.x–2.x类型系统成型,--strict 家族陆续加入严格开关默认关闭,应用侧风险低
表达力爆发期3.x–4.x条件类型、模板字面量类型、unknown、可选链检查项增多,库的类型定义需要重写
工程化期5.0–5.4标准装饰器、const 类型参数、bundler 解析、verbatimModuleSyntax默认值与解析策略变更,构建配置必须同步
收敛期5.5–5.x推断增强、旧选项集中废弃、isolatedDeclarations为下个大版本删除 deprecated 项做铺垫

注意第三、四阶段的特征:变化从「类型系统」移到了「编译选项与模块语义」。这比新增一个类型检查规则危险得多,因为它会同时影响类型检查结果和产物的模块形态。

把其中对升级影响最大的节点单独列出来,它们构成了必须提前规划的清单:

版本变更对应用的影响对库的影响
2.6strictFunctionTypes回调参数逆变开始报错事件类型定义需重写
2.7strictPropertyInitialization类属性必须初始化依赖注入场景需断言
3.0引入 unknown(strict 总开关在 2.3 加入)无(纯新增)可用 unknown 替代 any
4.4严格模式下 catch 变量变为 unknown访问 catch (e) 的属性前需收窄影响小
4.9satisfies 运算符无(纯新增)可精确推断配置对象
5.0默认 target 提升、bundler 解析、verbatimModuleSyntax构建配置必须同步声明文件语法收敛
5.5isolatedDeclarations启用后需补充导出注解为工具并行生成声明提供约束
5.6空值/真值恒真检查if (x ?? y) 类写法报错影响小

看这张表能发现一个规律:应用侧痛点在早期版本,库侧痛点在中后期版本。如果你的项目既是应用又是库(例如内部 SDK),两边的痛会叠加。

11.1.3 breaking change 的四种类型

把破坏性变更分类,是因为每一类的回退手段完全不同:

类型触发原因典型症状回退手段
检查收紧新增类型检查规则原本通过的代码出现 TS2xxx关掉对应开关(临时)
默认值变更target / module / lib 默认值调整产物形态或内置类型变化显式写回旧值
lib.d.ts 更新跟随 DOM / ES 标准演进某个全局属性「不存在了」修正 API,必要时使用 lib 覆盖包
语法与选项移除deprecated 项到点停用TS5102 或未知选项 TS5023替换选项,短期可回退编译器

先确认是否存在对应开关:不是所有检查都能关闭。选择旧编译器可短期回退,但长期仍需修正代码或配置;显式 lib 数组只选择类型集合,不会冻结声明版本。

11.1.4 类型检查收紧:最常撞上的三类

第一类:catch 变量从 any 变成 unknown。 TypeScript 4.4 起,--strict 隐含开启 useUnknownInCatchVariables:

async function load(id: string) {
  try {
    await fetchUser(id);
  } catch (e) {
    // TS 4.4 起(strict 下)e 的类型是 unknown,不是 any
    console.log(e.message);
    // error TS18046: 'e' is of type 'unknown'.
  }
}

修法是先收窄再用,而不是加 as Error 把问题藏起来:

async function load(id: string) {
  try {
    await fetchUser(id);
  } catch (e) {
    const msg = e instanceof Error ? e.message : String(e);
    console.error(`load ${id} failed: ${msg}`);
  }
}

第二类:函数参数从双变变成逆变。 TypeScript 2.6 的 strictFunctionTypes 只对函数类型语法生效,方法语法仍然双变——这是它最著名的坑:

type Handler = (e: Event) => void;

// 参数位置逆变:目标函数接收 Event,源实现必须也能处理 Event;这里只接收 MouseEvent,失败
const bad: Handler = (e: MouseEvent) => {};
// error TS2322: Type '(e: MouseEvent) => void' is not assignable
// to type 'Handler'. Types of parameters 'e' and 'e' are incompatible.

// 反方向是安全的:源参数更宽
const ok: (e: MouseEvent) => void = (e: Event) => {};

同一条规则在 interface 的方法写法下不报错,因为方法声明默认双变:

interface A { run(e: MouseEvent): void }
interface B { run(e: Event): void }
declare const a: A;
const b: B = a; // 通过:方法语法双变,这是刻意保留的

如果希望接口属性也严格逆变,把方法语法改写成属性语法 run: (e: MouseEvent) => void。

第三类:属性必须初始化。 strictPropertyInitialization(2.7 加入)要求类属性在构造函数里被确定赋值:

class Repo {
  private client: Client;
  // error TS2564: Property 'client' has no initializer and is not
  // definitely assigned in the constructor.
}

三种合法修法,按推荐度排序:构造函数注入(推荐)、声明为可选、用确定赋值断言 client!: Client(仅在框架会注入时使用,例如 4.1 标准装饰器(TS 5.x) 里的容器场景)。

11.1.5 默认值与 lib 的静默漂移

默认值变更不会给你任何报错,它只是让同一份配置产生不同的行为。5.0 是这类变更最集中的版本:

项目5.0 之前5.0 及之后应对
--target 默认值ES3ES5显式写 "target": "ES2022"
--moduleResolution node合法写法旧名 node 仍是 node10 的别名,另增 bundler显式写 node10 或迁移到 bundler
--importsNotUsedAsValues + --preserveValueImports两个独立开关废弃,推荐迁移到 verbatimModuleSyntax换成新开关,见 9.1 moduleResolution 各模式对照
--out / --charset / --keyofStringsOnly / --noStrictGenericChecks可用废弃删除配置项

lib.d.ts 更新是另一条隐蔽路径。它不是「新增了检查」,而是内置类型定义被替换:

// TS 4.0 从 DOM 声明中移除了 document.origin
const o = document.origin;
// error TS2339: Property 'origin' does not exist on type
// 'Document'.

此变更见 TS 4.0 发布说明 ,应改用适合当前上下文的标准 API(例如 self.origin)。下面的数组只明确所需 ES 与 DOM 类型集合:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  }
}

lib 的内容仍来自当前 TypeScript 包,因此升级编译器后声明照样会变化。需要独立控制 DOM 声明时,可评估 @typescript/lib-dom 覆盖机制;迁移新模块开关也须核对 emit 语义,不能把它当作旧开关的等价替换。

11.1.6 废弃语法与选项移除

废弃与停用时间应按具体发布说明核对,不能假定一定等到下一个大版本。5.0 废弃的一批选项在 5.5 起停用;本书的 5.9.3 基线可以复现下面的诊断:

npx tsc --noEmit --keyofStringsOnly
# error TS5102: Option 'keyofStringsOnly' has been removed. Please remove it from your configuration.

除了选项,语法层面也有移除:--target ES3 在 5.0 废弃,5.5 起不再提供该目标;5.0–5.4 可用 ignoreDeprecations: "5.0" 临时抑制提示。对库作者来说更值得关注的是声明文件语法的收敛:早期版本允许的 declare module 通配写法、export = 与 export default 混用等,在新版本下会被更严格地检查。

处理原则只有一条:废弃告警当错误处理。在 CI 里把 tsc 的废弃提示视为需要开票的债务,而不是可以忽略的噪音。

11.1.7 编辑器与 CI 的版本一致性

升级期最常见的幽灵问题是「编辑器不报错、CI 报错」,或者反过来。原因几乎总是两处用了不同的 TypeScript 版本。

VS Code 默认加载内置的 TypeScript,而不是项目 node_modules 里的那一个。要锁定工作区版本需要两行配置:

{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true
}

第一行指定工作区 SDK 路径,第二行启用使用该 SDK 的提示;它们不会自动替你选择版本。打开 TS 文件,在命令面板运行 TypeScript: Select TypeScript Version 并选择工作区版本,再对照状态栏与 pnpm exec tsc --version。

CI 侧则要确保安装的是锁文件里的版本,而不是按区间解析出的最新版:

- name: 类型检查
  run: |
    pnpm install --frozen-lockfile
    pnpm exec tsc --noEmit

--frozen-lockfile 是关键:它会拒绝清单与锁文件不一致的安装;普通安装在已有兼容锁文件时也会复用版本,但允许更新锁文件——于是你测的不是升级,而是一个随机版本。

确认两处版本一致只需一条命令:

npx tsc --version && pnpm exec tsc --version

这两条只核对 CLI 的解析路径;编辑器版本还要查看 VS Code 状态栏。任一处不一致时,先解决版本选择再测升级成本。

11.1.8 升级流程:基线快照 + 双跑 + 增量 diff

不要在 main 上直接改 package.json 里的版本号然后跑 pnpm install。正确的做法是把「升级」当成一次可度量的实验,分三步。

第一步:在当前版本上取基线快照。

# 使用仓库已经锁定的基线版本,先记录版本号
pnpm exec tsc --version
pnpm exec tsc --noEmit --pretty false -p tsconfig.json > baseline.txt 2>&1
grep -c 'error TS' baseline.txt

--pretty false 很重要:彩色输出带 ANSI 转义,diff 时会全部算成变化。

第二步:用目标版本再跑一次,只看增量。

npx -p typescript@5.6.3 tsc --noEmit --pretty false -p tsconfig.json > candidate.txt 2>&1
diff baseline.txt candidate.txt | grep '^>' | wc -l

npx -p typescript@x.y.z 让新版本临时可用而不改动锁文件,这一步的产出是一个数字——新增诊断文本行数(位置或措辞变化也会产生 diff,需人工归并)。它是评估影响的起点,不能直接当作需要修改的代码数量。

第三步:按「报错码 × 文件数」分组,决定策略。

grep -o 'error TS[0-9]*' candidate.txt | sort | uniq -c | sort -rn

输出形如 42 error TS18046、17 error TS7006。同码同因是升级里最大的杠杆:一类报错往往对应一个机械改法(例如 TS18046 全部是 catch 收窄),修完一类就消掉几十条。反之,如果新增报错分散在十几种错误码上,说明这次升级跨度太大,应该拆成多次小步。

真实的增量 diff 大致长这样:

> src/api/client.ts(42,7): error TS18046: 'e' is of type 'unknown'.
> src/model/user.ts(18,3): error TS2564: Property 'name' has no initializer.
> src/legacy/adapter.ts(9,21): error TS2339: Property 'origin' does not exist.

三行报错来自三种不同原因,说明这次升级同时触发了两类以上的 breaking change。这本身就是信号:一次升级同时踩到三类,就应该拆成两次来做。

还有一个容易被忽略的前置检查:确认依赖树里没有多个 TypeScript 版本。

pnpm why typescript

多个版本并存时,编辑器用 A 版本、CI 用 B 版本,就会出现「本地不报错、CI 报错」的幽灵问题。构建期性能与版本的关系可延伸阅读 TypeScript 构建性能优化 ,具体大版本的迁移要点见 TypeScript 大版本升级 。

最后一条经验是关于 --skipLibCheck。很多仓库为了提速长期开着它,代价是依赖包之间的声明冲突不会被发现。升级期建议至少完整跑一次:

npx -p typescript@5.6.3 tsc --noEmit --skipLibCheck false -p tsconfig.json

它会暴露 @types/* 包之间的重复声明冲突——这类问题在依赖树批量更新时非常常见,而且一旦发生,报错位置往往在 node_modules 里,极难定位。

11.1.9 报错定位速查

升级期遇到的报错,九成落在这张表里:

报错码含义常见诱因处理
TS18046值的类型是 unknownuseUnknownInCatchVariables收窄后使用
TS2564属性无初始化strictPropertyInitialization构造注入 / ! 断言
TS2322赋值类型不兼容strictFunctionTypes 逆变调整参数方向或改属性语法
TS2339属性不存在lib.d.ts 更新改代码或评估 lib 覆盖包
TS5102 / TS5023选项停用 / 未知选项旧配置或拼写错误删除或替换选项
TS7006参数隐式 anynoImplicitAny补注解或加类型守卫
TS2872表达式恒真5.6 起空值/真值检查删除冗余判断
TS2454变量使用前未赋值5.7 增强未初始化变量检查补初始化或收窄

看到 TS 编号先别急着改代码:先判断它属于四种 breaking change 里的哪一类,再决定是改代码、改配置还是钉版本。这个判断顺序能省掉大量返工。

小结

TypeScript 的 breaking change 可以归纳为四条规律:检查收紧给你报错,默认值变更悄悄改变行为,lib.d.ts 更新让标准类型追上规范,废弃项按发布计划逐步停用。升级流程也因此可以标准化为「基线快照 → 目标版本双跑 → 增量 diff → 按错误码分组修复」,其中「按错误码分组」是效率的关键,因为同码同因意味着一个改法消掉几十条报错。最后记住升级的边界:有对应开关时才能暂时关闭检查;lib 数组不冻结声明版本;编译器可暂时回退,但旧选项仍应在废弃期清理。

搞清楚版本演进的规律之后,下一个问题就变成了:既然严格检查迟早要来,为什么不能一次性把 strict 打开?答案藏在迁移的顺序里——某些开关必须在另一些之前打开,否则存量报错会互相淹没。下一节 11.2 渐进式迁移与严格化路径 就来拆解这个顺序,以及如何用一个可度量的棘轮把十万行项目稳稳推过严格模式。若你想先回看本章在全书中的位置,见 《TypeScript高级编程》目录 。

阅读导航:上一节:10.3 边界数据与不可信输入 · 下一节:11.2 渐进式迁移与严格化路径 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》10.3 边界数据与不可信输入