接手这个项目时,tsconfig 里 strict: false,全仓 38% 的函数签名带 any。CI 跑 tsc --noEmit 永远通过,因为类型检查形同虚设。
三个月后,strict: true,any 比率 4.7%,剩下的都是有明确标注的逃生通道。这篇文章记录我们怎么走过来的——不是「开启开关」那么简单。
严格模式不是开关
strict: true 不是一个布尔位,而是一组子开关的总和。理解每一项的含义,是渐进迁移的前提:
| 子开关 | 检查什么 | 关掉的代价 |
|---|---|---|
noImplicitAny |
未标注类型的参数是否隐式 any | 静默吞掉错误 |
strictNullChecks |
null / undefined 必须显式声明 | 运行时 NPE 漏网 |
strictFunctionTypes |
函数参数双向协变 → 逆变 | 回调类型不安全 |
strictPropertyInitialization |
类属性必须初始化或断言 | 实例字段读到 undefined |
noImplicitThis |
this 必须能推断 | 回调里 this 指向失控 |
一上来全开,报错可能上千条,没人能审完。正确的做法是按风险从低到高逐项开启,每开一项就清掉它带来的错误。
strictNullChecks 是收益最高的一项。据统计,JS 运行时错误的 70% 与 null/undefined 有关。先开它,能把一类崩溃从根上堵掉。
渐进迁移路线
我们的迁移分四步走,每步都有明确的退出标准。强行一步到位的代价是——没人愿意审 1200 条报错。
第一步:建立基线
先量化现状,再谈目标。用 type-coverage 统计 any 比率作为基线:
# 安装并统计:被精确类型覆盖的比例
npx type-coverage --detail
# 输出示例
# 78.2% covered
# Details:
# src/api/request.ts:15 any (参数 event)
# src/utils/format.ts:8 any (返回值)
把基线写进 CI,只允许下降、不允许上升。这条规则比开启 strict 更重要——它把"恶化"变成了一次可阻断的提交。
第二步:逐项开启子开关
{
"compilerOptions": {
// 第一周:风险最低
"noImplicitThis": true,
"noUnusedLocals": true,
// 第二周:收益最高
"strictNullChecks": true,
// 第三周:结构性收紧
"noImplicitAny": true,
"strictFunctionTypes": true,
// 最后:类相关
"strictPropertyInitialization": true
}
}
每一项开启后,运行 tsc --noEmit,把报错按文件聚合,分配给最熟悉对应模块的人修。不要让一个人扛全部——他会用 @ts-ignore 蒙混过关。
第三步:清理逃生通道
迁移过程中会冒出大量 as any 和 @ts-ignore。它们是必要的过渡手段,但必须给每个加注释,标注原因:
// ✅ 标注原因:三方库类型缺失,已提 issue #1234
const editor = new MonacoEditor() as any;
// ✅ 标注原因:JSON Schema 运行时推断,待 zod 迁移后移除
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function parse(raw: any): Config { /* ... */ }
// ❌ 没注释的 any 就是定时炸弹
function handle(data: any) { /* ... */ }
配合 ESLint 规则强制要求注释,能让"逃生通道"变成"已知债务",而不是隐式蔓延。
第四步:用 Zod 替换运行时 any
很多 any 不是懒,是「运行时数据形状不确定」。这类用 Zod 把推断权交给类型系统:
import { z } from 'zod';
// 定义 schema:运行时校验 + 编译期类型,一份
const ArticleSchema = z.object({
id: z.string(),
title: z.string(),
tags: z.array(z.string()),
publishedAt: z.string().datetime(),
});
// 类型自动派生,不再手写 interface
type Article = z.infer<typeof ArticleSchema>;
// 边界处校验,失败即报错,不再信任外部输入
function loadArticle(raw: unknown): Article {
return ArticleSchema.parse(raw); // 抛错带路径
}
把"相信外部输入"变成"校验外部输入",any 就无处藏身。
— 第三周的顿悟
把 any 比率降下来
工具链是这次迁移最关键的杠杆。光靠人盯,三个月降不到 4.7%。
三件套
| 工具 | 职责 | 在 CI 的角色 |
|---|---|---|
| type-coverage | 统计 any 比率 | 设阈值,低于则失败 |
| eslint-plugin-ts | 禁止无注释 any | 每次提交拦截 |
| prune-unused | 标记未被消费的 any | 周报里列出可清理项 |
关键是把工具的输出变成数字,把数字变成 CI 阈值。我们的配置:
# .github/workflows/typecheck.yml
- name: Type coverage
run: npx type-coverage --at-least 95 --detail
- name: No bare any
run: npx eslint . --rule '@typescript-eslint/no-explicit-any: error'
- name: No ts-ignore without reason
run: npx eslint . --rule '@typescript-eslint/ban-ts-comment: error'
三个月的 any 比率走势(每两周统计一次):
把比率做成看板,而不是任务清单。每周把数字贴到团队群,下降就庆祝、上升就复盘。把"质量"变成可视化的趋势,比写规范文档有效十倍。
TS 救不了你的时刻
严格模式挡住了很多错,但它不是银弹。下面这些场景,TS 的类型检查在编译期就放弃了对真值的判断:
结构化类型 + 运行时形状
// TS 认为这两个结构兼容,但运行时 data 可能缺字段
interface User { id: string; name: string; }
interface Admin { id: string; name: string; role: string; }
// 编译通过!但 admin 没有 role 字段
const u: User = { id: '1', name: 'a' };
const a: Admin = u; // ✅ 结构兼容,但运行时 a.role 是 undefined
这是结构化类型的本质——它看形状不看名义。边界处(API、localStorage、URL 参数)必须用 Zod 或 io-ts 校验,否则类型保证只是一厢情愿。
泛型擦除与运行时
// 泛型在运行时被擦除,is 检查没法依赖 T
function first<T>(arr: T[]): T {
return arr[0]; // 编译通过
}
// 调用方传入空数组 → undefined,但 TS 不会警告
const x = first<number>([]); // 类型是 number,实际是 undefined
x.toFixed(); // 运行时崩溃
这类问题只能靠边界校验 + 空数组守卫,类型系统在擦除后帮不上忙。
as 断言:绕过检查的合法通道
任何 as 都是开发者向编译器签下的"我负责"声明。我们统计过:项目里 80% 的运行时类型错误,根因都能追到某个 as。给 as 加 lint 计数,是值得的长期投资。
类型系统挡住了粗心,挡不住撒谎。
— 关于 as 断言的总结
团队规范模板
把前面所有经验收成一份可直接抄走的团队规范。我们的版本长这样:
- tsconfig:全仓
strict: true,不得单独关闭子项。 - any:新代码禁止裸 any;必须用则加
// reason:注释。 - as:禁止
as unknown as T双重断言;单层as需说明来源。 - 边界校验:API 响应、localStorage、URL 参数入口必须有 Zod schema。
- unknown > any:不确定形状的输入一律用
unknown,靠类型守卫收窄。 - non-null:禁止
!后缀,用类型守卫显式判断。 - CI:type-coverage ≥ 95%,
no-explicit-any报错,ban-ts-comment报错。
规范不是贴在墙上就生效的。把每一条对应到 CI 规则上,让它变成可拦截的提交,否则它只是文档里的一段话。代码质量从来靠机器守,不靠人自觉。
严格模式的真正价值不是挡住错误,而是逼团队把意图写进类型。一旦每个函数签名都能说出"我要什么、我给什么",协作成本会肉眼可见地下降。
三个月走下来,最大的收获不是 any 比率降到 4.7%,而是团队开始用类型思考——这种思维方式比任何 lint 规则都长久。