本节目标:搞清楚 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.6 | strictFunctionTypes | 回调参数逆变开始报错 | 事件类型定义需重写 |
| 2.7 | strictPropertyInitialization | 类属性必须初始化 | 依赖注入场景需断言 |
| 3.0 | 引入 unknown(strict 总开关在 2.3 加入) | 无(纯新增) | 可用 unknown 替代 any |
| 4.4 | 严格模式下 catch 变量变为 unknown | 访问 catch (e) 的属性前需收窄 | 影响小 |
| 4.9 | satisfies 运算符 | 无(纯新增) | 可精确推断配置对象 |
| 5.0 | 默认 target 提升、bundler 解析、verbatimModuleSyntax | 构建配置必须同步 | 声明文件语法收敛 |
| 5.5 | isolatedDeclarations | 启用后需补充导出注解 | 为工具并行生成声明提供约束 |
| 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 默认值 | ES3 | ES5 | 显式写 "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 | 值的类型是 unknown | useUnknownInCatchVariables | 收窄后使用 |
| TS2564 | 属性无初始化 | strictPropertyInitialization | 构造注入 / ! 断言 |
| TS2322 | 赋值类型不兼容 | strictFunctionTypes 逆变 | 调整参数方向或改属性语法 |
| TS2339 | 属性不存在 | lib.d.ts 更新 | 改代码或评估 lib 覆盖包 |
| TS5102 / TS5023 | 选项停用 / 未知选项 | 旧配置或拼写错误 | 删除或替换选项 |
| TS7006 | 参数隐式 any | noImplicitAny | 补注解或加类型守卫 |
| 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 渐进式迁移与严格化路径 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。