开启 TS 严格模式时,让 CI 只对新改动做增量检查,历史错误先挂账不爆仓

直接在 CI 里对 git diff 的结果跑 tsc --strict,新代码从第一天起就是严格模式,历史错误全部记入 // @ts-expect-error 挂账清单,等有空再逐条销账。这套方案我在两个存量项目上跑过,一个 12 万行、一个 4 万行,效果是:新 PR 不会再往代码库里增加任何新的隐式 any 或空值风险,同时 CI 不会被 800 多个历史错误卡死。

下面把完整落地步骤拆开讲。

核心思路:用 tsconfig.strict.json 覆盖主配置,只对增量文件生效

很多团队的做法是直接改主 tsconfig.jsonstrict: true,然后面对上千个报错慢慢修。但更务实的路径是:保留主配置不动,新建一个 tsconfig.strict.json,把 strict 打开,然后在 CI 里只对本次 PR 改动的 .ts / .tsx 文件跑严格检查。

tsconfig.strict.json 长这样:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noUncheckedIndexedAccess": true
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"]
}

注意两点:extends 继承主配置的所有 pathsjsxtarget 等设置,避免重复维护;include 先写全量,实际跑的时候再通过 CLI 参数把范围收窄到增量文件。

在 CI 脚本里,先拿到本次 PR 相对目标分支(比如 main)的改动文件列表,过滤出 TypeScript 文件:

git diff --name-only origin/main...HEAD -- '*.ts' '*.tsx' > changed_files.txt

然后把这批文件传给 tsc。这里有个关键细节:tsc 不直接接受文件列表参数,但可以用 --project 配合一个临时生成的 tsconfig,或者用 tsc --noEmit --strict 配合 files 字段。我推荐用临时 tsconfig 的方式,因为可以直接复用 tsconfig.strict.json 的完整 compilerOptions:

node scripts/check-strict-diff.mjs

这个 Node 脚本负责三件事:读取 changed_files.txt,生成一个临时 tsconfig.ci-strict.json,把 files 字段设为增量文件列表,然后调用 tsc -p tsconfig.ci-strict.json --noEmit。脚本核心逻辑:

import { execSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';

const changed = readFileSync('changed_files.txt', 'utf-8')
  .split('\n')
  .filter(Boolean)
  .filter(f => /\.(ts|tsx)$/.test(f))
  .filter(f => !f.endsWith('.d.ts'));

if (changed.length === 0) {
  console.log('No TS files changed, skip strict check.');
  process.exit(0);
}

const baseConfig = JSON.parse(readFileSync('tsconfig.strict.json', 'utf-8'));
baseConfig.files = changed;
// 删除 include,避免 files 和 include 同时存在时 tsc 行为不一致
delete baseConfig.include;
delete baseConfig.exclude;

writeFileSync('tsconfig.ci-strict.json', JSON.stringify(baseConfig, null, 2));

try {
  execSync('npx tsc -p tsconfig.ci-strict.json --noEmit', { stdio: 'inherit' });
} catch (err) {
  process.exit(1);
}

这个脚本放到 CI 的 job 里,和常规的 tsc --noEmit(主配置)并行跑。主配置负责保证项目整体仍然能编译通过,增量严格检查负责保证新代码符合严格模式。

历史错误挂账:@ts-expect-error 优于 @ts-ignore

历史代码里的隐式 any、可能为 null 的访问、索引越界风险,如果现在全部修掉,一个 PR 可能牵涉几十个文件的改动,review 成本极高。更合理的做法是:先把这些已知问题「挂账」,让严格模式检查能跑起来,之后按模块逐步销账。

挂账的方式是在报错行上方加 @ts-expect-error,而不是 @ts-ignore。两者都能压制错误,但 @ts-expect-error 有一个关键优势:如果下面的代码后来被修好了、错误消失了,TypeScript 会立刻报「Unused '@ts-expect-error' directive」,提醒你删掉这个标记。@ts-ignore 则不会,它会永远留在代码里,哪怕问题早已不存在。

举个例子,历史代码里有一段:

function getUserName(user) {
  return user.name;
}

在严格模式下,user 是隐式 any。挂账写法:

function getUserName(user) {
  // @ts-expect-error TODO(PROJ-123): 迁移到严格模式时补齐 user 类型
  return user.name;
}

这个注释里的 TODO 编号建议关联到你们自己的任务系统,方便后续追踪。我习惯用一个统一的格式,比如 // @ts-expect-error strict-migration: <模块名>-<序号>,这样后续可以用 grep 快速统计每个模块还有多少笔「欠账」。

对于 strictNullChecks 报的「Object is possibly 'null'」,挂账方式类似:

const config = getConfig();
// @ts-expect-error strict-migration: config 可能为 null,需评估调用方契约
const timeout = config.timeout;

让挂账可追踪:生成欠账清单

光在代码里加注释还不够,要让这笔账真正「挂」起来,需要一份可查看、可统计的清单。我的做法是在 CI 里加一个脚本,扫描全量严格模式报错,生成 strict-debt-report.json 提交到 artifact,同时输出一个 Markdown 摘要。

脚本可以用 tsc --pretty false 的输出做解析,但更可靠的方式是直接用 TypeScript 的 Compiler API。不过为了简单,我通常直接跑一次全量严格检查,把输出重定向到文件,然后解析错误行:

npx tsc -p tsconfig.strict.json --noEmit --pretty false > strict-errors.txt 2>&1 || true

解析 strict-errors.txt,提取每个错误对应的文件路径和错误码,按文件分组统计。把结果写成 JSON:

{
  "generatedAt": "2025-01-15T10:30:00Z",
  "totalErrors": 847,
  "byFile": {
    "src/legacy/order-service.ts": 23,
    "src/legacy/user-service.ts": 18
  },
  "byCode": {
    "TS7006": 312,
    "TS2345": 156
  }
}

这份报告每次 CI 跑完都更新,团队可以直观看到欠账总量是在下降还是上升。如果某个 PR 的增量严格检查通过了,但全量欠账数比上一次多了,说明有人可能在历史代码里引入了新的严格模式错误而没被增量检查覆盖到(比如改了 A 文件,但 A 文件里调用了 B 文件的函数,B 文件暴露出了新的类型错误)。这时候需要人工判断:是挂账,还是顺手修掉。

一个容易忽略的坑:增量检查的依赖闭包

tsc 在检查一个文件时,会沿着 import 链把依赖的文件也拉进来做类型解析。这意味着,即使你只把 files 设为改动的 3 个文件,tsc 实际可能会检查 30 个文件。如果这 30 个文件里有历史严格模式错误,增量检查就会「误报」——报的错误根本不是本次改动引入的。

这个问题在 strictNullChecks 开启时尤其明显。比如你改了 a.ts,它 import 了 b.ts,而 b.ts 里有一个函数在严格模式下返回类型不兼容,tsc 就会在增量检查时报 b.ts 的错误,尽管你根本没碰 b.ts

解决方式有两种。第一种是在脚本里对报错做过滤:只保留报错文件在 changed_files 列表里的错误,其余的错误忽略。第二种更彻底:给历史文件也做挂账,让全量严格检查先变成 0 错误,然后再跑增量检查。但第二种方案前期工作量大,适合欠账本来就不多的项目。

我推荐第一种,脚本里加一个过滤:

const errors = parseTscOutput(rawOutput);
const changedSet = new Set(changed);
const relevantErrors = errors.filter(e => changedSet.has(e.file));

如果 relevantErrors.length > 0,CI 失败,把错误打印出来。如果只有「依赖文件」的报错,CI 通过,但同时在日志里警告:有 N 个依赖文件的严格模式错误等待挂账。

与 lint 工具配合:ESLint 的 @typescript-eslint 规则

严格模式不只是 tsc 的事,ESLint 的 @typescript-eslint 插件也能检测一部分类型安全问题。在增量检查里,我建议同时跑 ESLint,但只对改动文件跑:

npx eslint $(cat changed_files.txt | grep -E '\.(ts|tsx)$') --no-error-on-unmatched-pattern

重点开启这几条规则:

{
  "@typescript-eslint/no-explicit-any": "error",
  "@typescript-eslint/no-unsafe-assignment": "error",
  "@typescript-eslint/no-unsafe-member-access": "error",
  "@typescript-eslint/no-floating-promises": "error"
}

这几条规则和 tsc --strict 互补:tsc 管类型推断和空值,ESLint 管显式 any 和 Promise 处理。两者一起跑,能把大部分「新代码类型不严谨」的路径堵住。

销账的节奏:按模块推进,而不是按文件

挂账只是权宜之计,最终目标还是清零。我的经验是:不要按文件逐个销账,而是按模块(比如一个 service、一个 utils 目录)整体推进。原因很简单,一个文件里的类型错误往往和它依赖的其他文件有关联,按文件修会反复在依赖边界上碰壁。

具体操作:挑一个欠账最多的模块,拉一个专门的分支,把模块里所有 @ts-expect-error strict-migration 注释清掉,把类型补齐,跑一次全量严格检查确认该模块 0 错误,然后合入。合入后更新欠账报告,团队在周会上花两分钟过一下数字变化。

我在一个 12 万行的项目上用了这套流程,初始欠账 847 个,三个月后降到 210 个,期间没有因为严格模式迁移阻塞过任何业务需求。

常见问题

增量检查会不会漏掉「改了一个文件的类型,导致另一个文件报错」的情况?

会漏,但这个场景本身说明被影响的那个文件已经处于「类型不健全」的状态。正确的处理是:在 PR 里把被影响文件的新报错也一并挂账或修复,而不是让增量检查忽略它。我建议在 CI 输出里把「非改动文件的报错」单独列出来,强制 PR 作者至少看一眼。

如果项目里有大量 .js 文件迁移到 .ts,这套方案适用吗?

适用,但需要额外处理。.js 改名为 .ts 后,git diff 会显示整个文件是新增的,增量检查会把它完整纳入。如果文件很大,可能一次性爆出很多错误。这时候建议在改名 PR 里先给文件加 // @ts-nocheck 或逐行挂账,让严格检查通过,后续再专门开 PR 销账。

@ts-expect-error 会被 Prettier 或 ESLint 自动删除吗?

不会。Prettier 只处理格式,不删注释。ESLint 默认也不删注释,但如果你开了 --fix 且有自定义规则去删注释,需要注意。另外注意 @ts-expect-error 必须紧贴报错行的上一行,中间不能有空行或别的语句,否则不生效。

CI 里跑两次 tsc(主配置 + 严格配置)会不会太慢?

对于 4 万行左右的项目,主配置 tsc --noEmit 大约 20-30 秒,严格增量检查只查几个文件,通常 3-5 秒,总开销可接受。如果项目更大,可以考虑用 tsc --incremental 配合缓存,或者把严格检查拆到单独的 CI job 里并行跑。