TypeScript Strict 模式渐进迁移:从零严格到全量 strict 的 6 步路线图
一、为什么 Strict 不是一刀切
很多团队面对 TypeScript 的 "strict": true,要么全开导致 500+ 编译错误直接放弃,要么永远关着用 any 缝缝补补。实际上 strict 是 8 个独立子选项的语法糖,完全可以逐个击破。
// tsconfig.json — strict: true 实际开启的 8 个子选项
{
"compilerOptions": {
// "strict": true 等价于以下全部开启:
"strictNullChecks": true, // ① null/undefined 严格检查
"noImplicitAny": true, // ② 禁止隐式 any
"strictFunctionTypes": true, // ③ 函数类型参数逆变检查
"strictBindCallApply": true, // ④ bind/call/apply 参数检查
"strictPropertyInitialization": true, // ⑤ 类属性必须初始化
"noImplicitThis": true, // ⑥ 禁止隐式 this 类型
"alwaysStrict": true, // ⑦ 输出 "use strict"
"useUnknownInCatchVariables": true // ⑧ catch 变量默认 unknown
}
}
下面按难度从低到高,排出 6 步渐进路线。
第一步:alwaysStrict + useUnknownInCatchVariables(零迁移成本)
这两个选项几乎不影响现有代码,开启即生效,编译错误通常为 0。
alwaysStrict:在 emit 的 JS 文件头部加 "use strict",浏览器以严格模式执行。这是 ES5+ 的基础,几乎所有现代项目都应该开。
useUnknownInCatchVariables(TS 4.4+):catch 的 error 从 any 变为 unknown。它迫使你在使用 error 前做类型收窄,消除一大类运行时崩溃。
// ❌ 迁移前:catch 变量是 any,访问属性无提示,运行时可能崩溃
try {
const data = JSON.parse(raw);
} catch (e) {
// e 是 any,可以随意访问不存在的属性
console.log(e.message); // 编译通过,但如果 e 不是 Error —— 💥
}
// ✅ 迁移后:e 是 unknown,必须类型收窄
function isErrorLike(v: unknown): v is { message: string } {
return typeof v === "object" && v !== null && "message" in v;
}
try {
const data = JSON.parse(raw);
} catch (e) {
const msg = isErrorLike(e) ? e.message : "Unknown error";
console.log(msg); // 安全
}
第二步:noImplicitThis(低摩擦)
noImplicitThis 禁止在函数中隐式使用 this——要求你显式声明 this 的类型或使用箭头函数。
// ❌ 隐式 this,类型不安全
class Debouncer {
timeout: number | null = null;
setup(element: HTMLElement) {
element.addEventListener("click", function() {
this.timeout = 1000; // ❌ this 指向 element,不是 Debouncer
});
}
}
// ✅ 方案一:箭头函数(推荐,自动绑定外层 this)
class Debouncer {
timeout: number | null = null;
setup(element: HTMLElement) {
element.addEventListener("click", () => {
this.timeout = window.setTimeout(() => {}, 1000); // ✅ this = Debouncer
});
}
}
// ✅ 方案二:显式 this 类型声明(回调场景)
function handleClick(this: HTMLElement, e: MouseEvent) {
console.log(this.dataset.id); // ✅ this 类型明确为 HTMLElement
}
element.addEventListener("click", handleClick);
这一步通常只涉及事件回调的改造,影响面小,收益明确。
第三步:strictBindCallApply(小修补)
开启后会检查 bind / call / apply 的参数类型是否匹配。大多数项目开启后错误极少(<10),因为很少有人对类型迥异的函数做 bind。
// ❌ bind 参数类型不匹配
function greet(name: string, age: number): string {
return `${name} is ${age}`;
}
greet.bind(null, 42); // ❌ number 不能赋给 string
greet.call(null, "Alice"); // ❌ 缺 age 参数
// ✅ 修复
const greetAlice = greet.bind(null, "Alice"); // ✅ 返回 (age: number) => string
const bound = greetAlice(30); // "Alice is 30"
第四步:strictFunctionTypes(中难度,核心价值)
这是 strict 家族中最容易被误解但回报最高的选项。它强制函数参数类型的逆变(contravariance),防止你写出一个类型声明上合法但运行时崩的函数传递。
// ❌ 不开启 strictFunctionTypes:这行不会报错
interface Animal { name: string }
interface Dog extends Animal { breed: string }
type DogHandler = (dog: Dog) => void;
const animalLogger: (animal: Animal) => void = (a) => console.log(a.name);
const handler: DogHandler = animalLogger;
// 🔥 关闭 strictFunctionTypes 时通过编译!
// 但 handler 被调用时传入 Dog,animalLogger 只能看到 Animal 的属性 —— 虽然这里恰好安全
// 反向则致命 ↓
// ❌ 真正危险的是反向赋值
type AnimalHandler = (animal: Animal) => void;
const dogBreeder: DogHandler = (d) => console.log(d.breed.toUpperCase());
const bad: AnimalHandler = dogBreeder;
// 🔥 关闭 strictFunctionTypes:编译通过
bad({ name: "cat" });
// 💥 Runtime Error: Cannot read property 'toUpperCase' of undefined
// ✅ 开启后,TypeScript 拒绝不安全的函数赋值
// 修复方式一:放宽参数类型
const safeDogBreeder: (animal: Animal) => void = (a) => {
if ("breed" in a) console.log(a.breed.toUpperCase());
};
// 修复方式二(多数实际场景):检查调用方,确认是否真的需要窄参数
// 如果确实需要 Dog,就不要把 DogHandler 赋值给 AnimalHandler
迁移策略:先开 strictFunctionTypes,修复报错后再继续。典型项目会有 10-50 个报错,集中在回调类型声明上。
第五步:strictNullChecks(核心攻坚战)
这是 strict 模式中报错量最大的选项,也是类型安全收益最高的选项。strictNullChecks 把 null 和 undefined 从所有类型中剥离,变成独立的类型。
典型迁移场景
场景 1:DOM 查询返回值
// ❌ 关闭 strictNullChecks 时
const btn = document.querySelector(".submit-btn");
btn.addEventListener("click", handleSubmit);
// 🔥 btn 实际上是 HTMLElement | null,但没有提示
// ✅ 开启后必须处理 null
const btn = document.querySelector(".submit-btn");
if (!btn) {
throw new Error("Submit button not found");
}
btn.addEventListener("click", handleSubmit);
// ✅ 更优雅:非空断言仅在 100% 确认时使用
const btn = document.querySelector(".submit-btn")!;
场景 2:可选对象属性访问
interface User {
profile?: {
avatar?: string;
};
}
// ❌ 关闭 strictNullChecks 时
function getAvatarUrl(user: User): string {
return user.profile.avatar; // 🔥 profile 可能为 undefined
}
// ✅ 开启后:使用可选链 + 空值合并
function getAvatarUrl(user: User): string {
return user.profile?.avatar ?? "/default-avatar.png";
}
场景 3:Map/Record 查询
// ❌ 关闭时:Map.get 返回 V | undefined 被当成 V
const cache = new Map<string, User>();
const user = cache.get(id);
console.log(user.name); // 🔥 user 可能是 undefined
// ✅ 开启后
const user = cache.get(id);
if (!user) {
throw new NotFoundError(`User ${id} not in cache`);
}
console.log(user.name);
迁移策略:建议先全局开启,然后用 // @ts-expect-error 逐文件标注有意推迟修复的告警,在一个迭代内消化。或者按目录逐步开启(通过 project references)。
第六步:noImplicitAny + strictPropertyInitialization(收尾)
noImplicitAny 禁止 TypeScript 将无法推导的类型隐式设为 any。这是类型安全的最后一道防线。
// ❌ 参数无类型标注 → 隐式 any
function parseConfig(raw) { // ❌ Parameter 'raw' implicitly has 'any' type
return JSON.parse(raw);
}
// ✅ 显式标注
function parseConfig(raw: string): unknown {
return JSON.parse(raw);
}
// ❌ 常见陷阱:回调中的隐式 any
[1, 2, 3].map(x => x * 2); // ✅ TS 能从数组推导 x: number
["a", "b"].map(x => x.toUpperCase()); // ✅ 同上
// 但如果初始值是空数组
const items: string[] = []; // ✅ 必须显式标注
items.push("hello");
strictPropertyInitialization 要求类的属性要么在声明时初始化,要么在构造函数中赋值。
// ❌ 属性未初始化
class ApiClient {
private baseUrl: string; // ❌ Property has no initializer
configure(url: string) {
this.baseUrl = url;
}
}
// ✅ 方案一:声明时初始化
class ApiClient {
private baseUrl: string = "/api/v1";
}
// ✅ 方案二:构造函数初始化
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
}
// ✅ 方案三:确定赋值断言(少用,仅在 DI 注入等场景)
class ApiClient {
private baseUrl!: string; // 告诉 TS: "我保证会在使用前初始化"
}
渐进迁移路线图总结
第 1 步 alwaysStrict + useUnknownInCatchVariables • 错误数 ≈ 0
第 2 步 noImplicitThis • 错误数 < 20
第 3 步 strictBindCallApply • 错误数 < 10
第 4 步 strictFunctionTypes • 错误数 10-50
第 5 步 strictNullChecks • 错误数 50-500+
第 6 步 noImplicitAny + strictPropertyInitialization • 错误数 20-200
↓
全部通过后 → "strict": true ✅
工程化配套:用 CI 锁定成果
迁移完成后,必须用 CI 锁死,防止回退:
# .github/workflows/typecheck.yml
name: Type Check
on: [push, pull_request]
jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx tsc --noEmit
// tsconfig.json 的最终形态
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true, // 额外推荐:数组/Record 索引自动含 undefined
"exactOptionalPropertyTypes": true // 额外推荐:可选属性禁止显式赋 undefined
}
}
noUncheckedIndexedAccess 是 strict 之外的"第 9 个选项",强烈推荐开启:
const arr: string[] = ["a", "b"];
const item = arr[5];
// 关闭 noUncheckedIndexedAccess: item 类型是 string 🔥
// 开启 noUncheckedIndexedAccess: item 类型是 string | undefined ✅
console.log(item.toUpperCase()); // ✅ 开启后有编译错误,强迫你做空值检查
结语
TypeScript 的 strict 模式不是一天建成的,但通过这条 6 步路线,你可以在不阻塞业务迭代的前提下逐步提升类型覆盖。每一步的报错量可控,且每一步都有明确的代码改造模式。最关键的是:每修复完一步就合入主干,让 CI 锁死成果,远比攒一个「strict 大 PR」实际得多。
当你的 CI 中 tsc --noEmit 零错误通过时,你会发现:Review 代码时的关注点从"这里会不会是 null"变成了业务逻辑本身——这才是类型系统真正的价值。
评论区
登录 后参与评论