本节目标:掌握一条「不停机、可回滚、可度量」的迁移路径。读完你应该能为自己的仓库画出迁移顺序图,知道哪些
strict开关必须先开、哪些可以最后开,以及如何用错误计数棘轮和@ts-expect-error台账把「技术债」变成 CI 能拦截的指标。
11.2 渐进式迁移与严格化路径
上一节我们确认了严格检查迟早要来,剩下的问题是怎么来。这里有个几乎所有人都会踩的坑:把 "strict": true 一次性写进 tsconfig.json,然后看着几千条报错发呆。
这种「大爆炸式」升级失败的原因不是报错太多,而是报错互相淹没。strictNullChecks 打开后,一个原本类型是 User 的变量可能变成 User | undefined,于是它参与的所有表达式都开始报错。你根本分不清哪些是真问题、哪些是连带噪音。
正确做法是把严格化当成一系列独立开关的顺序释放,每一步只引入一类报错。本节先讲起点分类,再讲边界划法,然后给出开关顺序、债务策略与批次验收。
11.2.1 三种起点,三种策略
「迁移」这个词掩盖了三种完全不同的处境:
| 起点 | 特征 | 主要风险 | 首选策略 |
|---|---|---|---|
| 纯 JS 项目 | 无任何类型信息 | 类型全靠猜,容易写成 any 满地 | 边界先行,按目录 .js → .ts |
| 宽松 TS 项目 | 有类型但 strict 关闭 | 存量 any 与隐式 any 混杂 | 开关顺序释放 + 棘轮 |
| 现代 TS 项目 | strict 已开 | 缺 noUncheckedIndexedAccess 等增强项 | 逐项评估收益,选择性开启 |
三者的共同点只有一条:不要试图一次改完。差异在于「边界」这个概念的位置——纯 JS 项目的边界是文件系统,宽松 TS 项目的边界是编译选项。
11.2.2 边界先行:allowJs 与 checkJs 的分层
纯 JS 项目的第一阶段目标不是「全部改成 TS」,而是「让 TS 能看见 JS」。这靠两个开关:
{
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"noEmit": true,
"moduleDetection": "force"
},
"include": ["src"]
}
allowJs 让 .js 文件进入编译图,从而能被 .ts 文件 import 并做推断;checkJs: false 表示暂时不检查 JS 文件本身。这个组合让你可以立刻开始写新的 .ts 文件,而不用先动存量。
moduleDetection: "force"(4.7 引入)是个容易被忽略的关键项。没有它时,一个既没有 import 也没有 export 的 .js 文件会被当成全局脚本,其中的 const config = ... 会污染全局作用域,和别的文件撞名。加上之后所有文件都按模块处理。
第二阶段把 checkJs 打开,但不要全局打开,而是用目录白名单逐块推进:
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true
},
"include": ["src/core", "src/utils"],
"exclude": ["src/legacy", "**/*.test.js"]
}
也可以在单文件粒度上用注释控制,// @ts-check 打开、// @ts-nocheck 关闭:
// @ts-check
/** @param {number} a @param {number} b */
export function add(a, b) {
return a + b;
}
这个阶段的产出是可度量的:被纳管文件数。它比「错误数」更适合当早期指标,因为此时错误数会随纳管范围一起涨。
11.2.3 严格化开关的依赖顺序
strict 不是一个开关,而是一组开关的别名。它的成员大致是:noImplicitAny、strictNullChecks、strictFunctionTypes、strictBindCallApply、strictPropertyInitialization、noImplicitThis、alwaysStrict、useUnknownInCatchVariables。
开启顺序有依赖关系,不能随意:
| 顺序 | 开关 | 报错量级 | 修复性质 | 为什么在这个位置 |
|---|---|---|---|---|
| 1 | noImplicitAny | 最大 | 机械 | 补注解即可,不改变语义 |
| 2 | strictNullChecks | 大 | 语义 | 必须等注解补齐,否则无法判断是 undefined 还是 any |
| 3 | strictFunctionTypes | 中 | 语义 | 依赖 2 完成后的精确类型 |
| 4 | strictBindCallApply | 小 | 机械 | 独立于前两步,可穿插 |
| 5 | strictPropertyInitialization | 小 | 结构性 | 需要构造函数可被识别 |
| 6 | noImplicitThis | 小 | 语义 | 依赖对象字面量上下文类型 |
| 7 | useUnknownInCatchVariables | 小 | 机械 | 与 2 相关但可单独处理 |
第 1、2 步的顺序是硬约束。原因很直接:如果先开 strictNullChecks,那些隐式 any 的参数会让 undefined 检查完全失效——any 与 undefined 的联合还是 any,报错会被静默吞掉。所以必须先用 noImplicitAny 把类型「钉住」。
先开 noImplicitAny 的典型报错:
export function paginate(items, page, size) {
// error TS7006: Parameter 'items' implicitly has an 'any' type.
// error TS7006: Parameter 'page' implicitly has an 'any' type.
// error TS7006: Parameter 'size' implicitly has an 'any' type.
return items.slice(page * size, (page + 1) * size);
}
再开 strictNullChecks 的典型报错:
interface User { name: string }
const cache = new Map<string, User>();
export function getName(id: string): string {
const user = cache.get(id);
// error TS2532: Object is possibly 'undefined'.
return user.name;
}
注意第二条报错的修法选择很关键:用 user?.name ?? 'unknown' 是改语义,用 if (!user) throw new Error(...) 是补契约。迁移期最容易犯的错误是为了消掉报错而随手加 ??,把原本应该暴露的 bug 掩盖成默认值。判断标准是:这个 undefined 在业务上是否真的可能发生?可能就补默认值,不可能就抛错。
第 3 步 strictFunctionTypes 的报错集中在回调与事件处理上,处理手法与上一节的逆变示例一致。
11.2.4 增强项:strict 之外的严格开关
strict 之外还有一批「更严格」的开关,它们默认不开,收益与成本都需要单独评估:
| 开关 | 作用 | 典型收益 | 迁移成本 |
|---|---|---|---|
noUncheckedIndexedAccess | 索引访问结果加 undefined | 抓出数组越界与字典缺键 | 高,波及所有 arr[i] |
exactOptionalPropertyTypes | 区分「缺失」与「值为 undefined」 | 序列化边界更严谨 | 高,波及所有可选属性 |
noImplicitOverride | 重写父类方法必须写 override | 防止改名后静默失联 | 低,机械加关键字 |
noPropertyAccessFromIndexSignature | 索引签名只能用下标访问 | 防止 obj.foo 绕过检查 | 低 |
noFallthroughCasesInSwitch | 禁止 case 穿透 | 抓出漏写 break | 低,通常直接暴露 bug |
verbatimModuleSyntax | 强制显式 import type | 产物模块形态可控 | 中,配合打包器评估 |
建议的策略是「先开后五个低成本项,把高成本的两个留到最后」。exactOptionalPropertyTypes 与序列化边界的关系,可以对照 10.2 序列化与反序列化类型
一起理解。
11.2.5 存量报错的三种处理策略
迁移期每个报错都要被分到三类之一,不允许出现第四类(先放着):
| 策略 | 适用场景 | 写法 | 债务性质 |
|---|---|---|---|
| 真修 | 报错反映真实缺陷 | 补注解 / 补空值分支 | 无 |
| 收窄 | 类型确实存在但无法精确表达 | unknown + 类型守卫 | 轻 |
| 记账 | 短期无法修,但已知且被追踪 | @ts-expect-error + 票据号 | 重,必须有台账 |
第三类必须用 @ts-expect-error 而不是 @ts-ignore,理由是前者在报错消失时会反过来报错:
// @ts-expect-error TODO(TS-1042): 等 SDK 发 2.0 后移除
const meta = sdk.legacy.getMeta();
如果 sdk.legacy.getMeta() 哪天补上了类型,@ts-expect-error 会变成:
error TS2578: Unused '@ts-expect-error' directive.
这正是我们要的——债务自动提醒。@ts-ignore 没有这个性质,它会在报错消失后静默留下,变成永久垃圾。
台账可以用一条 CI 脚本强制:
grep -rn '@ts-expect-error' src --include='*.ts' | grep -v 'TODO(' \
&& echo '存在无票据号的类型豁免' && exit 1
11.2.6 用棘轮把债务变成指标
「报错数在减少」这种感受不可靠,需要变成 CI 能拦截的数字。做法是维护一个基线文件,任何一次 PR 只要让错误数上升就失败:
npx tsc --noEmit --pretty false -p tsconfig.json > .tsc-report.txt 2>&1 || true
CURRENT=$(grep -c 'error TS' .tsc-report.txt)
BASELINE=$(cat .tsc-baseline)
if [ "$CURRENT" -gt "$BASELINE" ]; then
echo "类型错误从 $BASELINE 上升到 $CURRENT"
exit 1
fi
这个机制叫棘轮(ratchet):数字只能往下走。它解决了一个组织问题——迁移周期长达数月时,「今天多欠一点没关系」的惯性会让债务重新膨胀。棘轮让每个 PR 的贡献方向可判定。
棘轮上线后,BASELINE 的下降曲线就是迁移进度。建议按目录拆成多个棘轮(src/core、src/legacy 各一个基线),这样某个目录的下降不会被另一个目录的上涨抵消。
11.2.7 codemod:把机械改动交给工具
第 1 步 noImplicitAny 的报错里,有相当比例是机械的。这类改动不该手工做,应该写 codemod。工具选择上,ts-morph 比字符串替换安全得多,因为它操作的是 AST:
import { Project } from "ts-morph";
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
for (const file of project.getSourceFiles("src/**/*.ts")) {
for (const fn of file.getFunctions()) {
for (const p of fn.getParameters()) {
if (!p.getTypeNode() && p.getType().isAny()) {
p.setType("unknown");
}
}
}
file.saveSync();
}
注意这里把隐式 any 改成 unknown 而不是 any——unknown 会强制调用点收窄,把「隐式 any 的数量」转化成「显式待收窄点的数量」,后者更容易逐个击破。codemod 的完整工程化做法见 6.3 重构工具与 codemod
,而 unknown 作为边界类型的取舍见 10.3 边界数据与不可信输入
。
11.2.8 与打包器、运行时的协同
迁移不只是 tsc 的事。只要项目用了 Babel、esbuild、swc 或 tsx 这类逐文件转译的工具,就会撞上 isolatedModules 的约束——这些工具一次只看一个文件,无法做跨文件类型分析。
开启 isolatedModules 后,两类写法会报错:
export { User } from "./types";
// error TS1205: Re-exporting a type when 'isolatedModules' is enabled
// requires using 'export type'.
export type { User } from "./types";
const enum Color { Red, Blue }
const c = Color.Red;
// error TS2748: Cannot access ambient const enums when
// 'isolatedModules' is enabled.
修法是显式区分类型与值:类型用 export type / import type,常量枚举改成字面量联合或普通对象。5.0 引入的 verbatimModuleSyntax 把这件事推到极致——它要求所有类型导入都写 import type,否则产物的模块形态会与源码不一致。模块解析与产物形态的细节见 9.3 循环依赖与类型-only 导入
。
还要确认运行时的目标环境。tsc 只降级语法,不注入 polyfill——target: "ES2019" 会把可选链编译成条件表达式,但不会为 Array.prototype.at 提供实现。运行时的真实差异见 8.2 类型擦除后的运行时形态
。
11.2.9 JSDoc 阶段的类型表达
纯 JS 项目在 checkJs 打开后、.ts 重命名完成前,会有一段「用 JSDoc 写类型」的过渡期。这段过渡期的成本常被低估:JSDoc 的表达力弱于 .ts,写复杂泛型会非常笨重。
| 类型写法 | JSDoc | TypeScript |
|---|---|---|
| 基础注解 | @param {number} a | a: number |
| 联合类型 | @param {string | null} s | s: string | null |
| 对象结构 | @typedef {{ id: string }} User | interface User |
| 泛型函数 | @template T + @param {T} x | <T>(x: T) |
| 导入类型 | @param {import("./t").T} x | import type { T } |
经验法则是:JSDoc 只用来标注简单边界,一旦需要泛型或条件类型,直接改成 .ts。硬用 JSDoc 表达复杂类型,收益不如把文件重命名。
/** @typedef {{ id: string, tags: string[] }} Article */
/** @param {Article} a @returns {string} */
export function title(a) {
return a.tags[0];
}
11.2.10 迁移看板与节奏控制
迁移周期通常跨越数个迭代,需要一个不依赖记忆的看板。推荐只跟踪四个数字:
| 指标 | 采集方式 | 目标趋势 |
|---|---|---|
| 纳管文件数 | include 覆盖的文件数 | 单调上升 |
| 类型错误数 | tsc --noEmit 计数 | 单调下降 |
| 豁免条目数 | @ts-expect-error 计数 | 单调下降 |
| 已开严格开关数 | tsconfig 中的显式项 | 单调上升 |
四个数字里,豁免条目数是唯一可能失控的:真修和收窄会让错误数下降,而记账只是把错误换了个位置。因此必须给豁免设上限,例如「任何目录的豁免条目不超过该目录文件数的 2%」,超过上限就停止纳管新目录,先还债。
节奏上建议「一轮一开关、一轮一目录」:每个迭代只推进一个严格开关或一个目录,完成后写一页简短复盘,记录这次遇到的主要报错类型与修法。这些复盘累积起来就是团队自己的迁移手册,比任何外部文档都贴合实际。
11.2.11 批次划分与验收标准
最后一个实操问题:一次迁移多大合适?判据不是文件数,而是能否在一个 PR 内被 review 完并独立回滚。经验值是单批次新增报错不超过 200 条。
每批次的验收清单:
| 检查项 | 通过标准 |
|---|---|
| 编译 | tsc --noEmit 无新增错误 |
| 棘轮 | 基线数字未上升 |
| 台账 | 新增 @ts-expect-error 均带票据号 |
| 测试 | 全量测试通过,覆盖率未下降 |
| 产物 | 构建产物 diff 无意外变化 |
其中「产物 diff」是常被跳过但很重要的一项:verbatimModuleSyntax 与模块解析调整会改变产物,必须确认改动符合预期。模块语义的细节见 9.2 条件导出与 bundler 语义
。
小结
渐进式迁移的核心不是「慢慢来」,而是把一次大变更拆成有依赖顺序的小变更,并给每一步配上可度量的指标。边界先行(allowJs / checkJs / 目录白名单)让纯 JS 项目能立刻开始;开关顺序(noImplicitAny → strictNullChecks → strictFunctionTypes → 其余)保证每步只引入一类报错;@ts-expect-error 台账让豁免可追踪、可自动回收;棘轮让「债务只能减少」变成 CI 能执行的规则。这套机制跑通后,严格化就从一场运动变成了一条流水线。
迁移完成后,strict 全开的仓库会立刻暴露下一个问题:类型写得很严,但架构上没有边界——任何人可以从任何层 import 任何模块,类型只是逐文件的正确,不是系统级的约束。这就是下一节 11.3 类型驱动架构与团队规范
要处理的问题。若你还在评估是否值得迁移,可先延伸阅读 TypeScript 严格配置
与 从 JavaScript 迁移到 TypeScript
,前者讲开关的取舍,后者讲跨语言迁移的实操细节。
阅读导航:上一节:11.1 TS 版本演进与 breaking changes · 下一节:11.3 类型驱动架构与团队规范 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。