这节在地图里是工程化的收口:前面配置得再好,没人执行也白搭。质量保障是把"个人写对"变成"团队永远写对"的纪律层。我们讲三道关卡:CI、提交钩子、单测类型,再加一份老项目的渐进路线。
把 tsc --noEmit 作为流水线的硬门槛,任何类型错误都阻断合并。
# CI 步骤(伪代码) steps: - run: npm ci - run: npm run typecheck # 失败则整个流水线红 - run: npm test
输入输出:本地忘了检查就推,CI 立刻拦下并通知。这比"靠自觉"可靠——契约的强制力来自自动化,不来自人。
CI 是最后一关,但错误发现得越晚修得越贵。用提交钩子在代码进仓库前就查。
// package.json { "scripts": { "prepare": "husky install" }, "lint-staged": { "*.ts": ["tsc --noEmit --files", "eslint --fix"] } }
# .husky/pre-commit npx lint-staged
背景:开发者本地可能没开严格模式或忘了检查。操作:提交时只查改动文件。结果:错误在提交那一刻就被挡。解读:比 CI 快、反馈更近,但可被 --no-verify 绕过,所以 CI 仍要兜底。
单测不光验行为,还能验类型契约。用 expectTypeOf 类工具断言某表达式的类型就是预期。
import { expectTypeOf } from "vitest"; function add(a: number, b: number): number { return a + b; } expectTypeOf(add).parameters.toEqualTypeOf<[number, number]>(); expectTypeOf(add).returns.toBeNumber();
输入输出:若有人把 add 返回值改成 string,这条类型断言测试会失败。契约变更因此被单测捕捉,而非等到调用处爆炸。
老 JS/宽松 TS 项目直接全开 strict 会冒出成千错误,团队会放弃。我们用的分阶段法:
# 阶段一:先开 null 检查 + noImplicitAny,修最危险的一批 # 阶段二:给核心模块加 // @ts-check(JS)或逐个文件去 any # 阶段三:开启 strictFunctionTypes # 阶段四:strict: true 收尾,新文件一律严格 # 用 baseline 文件锁定当前错误数,只减不增 npx tsc --noEmit > error-baseline.txt
error-baseline 思路:把当前错误数钉死,CI 只允许减少不允许新增,逐步清零。这张图给出三道关卡的纵深:

# 生成基线 npx tsc --noEmit | grep -c "error TS" > .ts-error-count # CI 里比较:当前错误数必须 ≤ 基线 COUNT=$(npx tsc --noEmit | grep -c "error TS") BASE=$(cat .ts-error-count) if [ "$COUNT" -gt "$BASE" ]; then echo "类型错误增加了"; exit 1; fi
类型检查管"对错",lint 管"风格与契约纪律"。把"禁止 any 显式出现""要求 as 有理由"写成规则,能在编辑期就拦退化。
// .eslintrc 相关规则(节选) { "rules": { "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unsafe-assignment": "error", "@typescript-eslint/explicit-module-boundary-types": "error" } }
背景:any 一旦进代码,会沿调用链传染(见第九章陷阱一)。操作:用规则把显式 any 标红或警告。结果:新人想偷懒写 any 会被工具拦。解读:lint 是"契约纪律"的自动化延伸,和 CI 类型检查互补——一个管类型正确,一个管写法纪律。
把"错误只减不增"落到日常 PR 流程,需要明确谁负责消、何时消。
PR 规则(团队约定): 1. 新文件必须 0 类型错误,否则 CI 红 2. 改旧文件时,顺手修该文件内的所有类型错误 3. 每周五专人消一批 baseline 旧错,并把基线数字下调 4. 季度末复盘:baseline 归零则开启 strict 新子项
背景:baseline 容易"锁住就没人动"。操作:把消错分配进日常 PR 与固定时段。结果:错误数持续下降而非冻结。解读:baseline 是手段不是终点,没有节奏的 baseline 会永远停在高位;把它拆进工作流才能真的收敛。
不是所有东西都该写类型断言测试。行为逻辑用普通单测,类型契约用类型断言,混用会拖慢也拖慢可读性。
// 行为测试:验运行时结果 expect(add(1, 2)).toBe(3); // 类型断言:验签名契约(仅在契约是关键资产时用) expectTypeOf(add).returns.toBeNumber(); // 不要为普通业务逻辑大量堆类型断言,维护成本高
背景:类型断言测试写多了会变成"重复声明类型",收益递减。操作:只给对外的公共 API、库函数签名加类型断言。结果:契约变更被捕捉,又不淹没在断言里。解读:质量保障讲究"在关键处设卡",而非处处设卡——关卡越多,越容易被绕过或忽视。
strict 全开在大型团队初期会有阵痛——大量历史错误要修。但长期看,每一次重构、接手、改名都因契约而便宜。我们的判断是:新项目零成本上严格;老项目用 baseline 法逐步吃,别因为短期痛而永远停在 any。
光在文档里写"要跑 tsc"没用,得把检查变成合并前的硬性关卡。用 PR 流水线在代码评审前先过类型。
# PR 检查(伪代码) on: pull_request steps: - run: npm ci - run: npm run typecheck # 不通过则评审者看不到"已通过"标 - run: npm run lint - run: npm test
输入输出:开发者提 PR 后,类型错误会让检查变红,评审者一眼看到"类型未通过",不会误合并。这比"合并后再在 main 上红"成本低得多——错误停在了作者视野内。
当你维护一个库的类型(.d.ts),光靠 tsc --noEmit 不够——它只验证"能编译",不验证"类型行为符合预期"。tsd 类工具能断言类型层面的期望。
import { expectType } from "tsd"; import { first } from "../src"; expectType<number | undefined>(first([1, 2])); // 若有人把 first 的返回改成 number(漏掉 undefined),这条会失败
背景:库的类型契约也是契约,错了消费方类型就错。操作:给关键工具类型/函数写类型断言测试。结果:返回类型一旦漂移,CI 立刻抓到。解读:和第四章"单测里的类型断言"同理,只是对象从业务函数变成库导出 API。
⚠️ 别把提交钩子当唯一防线。--no-verify 能跳过它,且钩子不跑在 CI 之外的机器上;没有 CI 兜底,契约强制力是虚的。
💡 老项目引入严格度时,用"错误数基线"比"一次性清零"现实得多——先锁住不新增,再靠日常 PR 慢慢消,团队不痛、进度不断。