本节目标:读完这一节,你能解释「为什么有了类型之后重构可以更大胆」;能用
never写出穷尽性检查,让新增联合成员在编译期就报错;能用@deprecated与satisfies做不破坏调用方的 API 演进;能为一支多人团队写出可执行的类型规范与 CI 门禁;还能用类型覆盖率量化「类型用得够不够好」。
18.2 类型驱动的重构与团队规范
上一节我们把存量项目迁到了 TypeScript。但「代码是 .ts 后缀」和「类型真的在发挥作用」是两件完全不同的事。这一节要解决的是后者:让类型系统成为持续重构的引擎,而不是一次性的装饰。
编译器就是你的回归测试
重构最大的心理障碍是「怕改坏」。在纯 JavaScript 里,这种恐惧是合理的:改一个函数签名,谁在调用它、有没有漏改一处,只能靠全局搜索和记忆。
有了类型系统之后,这件事的性质变了:每一次编译就是一次全量调用点检查。
// 重构前
function sendNotification(userId: string, message: string) {
// ...
}
// 重构后:把两个参数换成一个对象
interface NotifyOptions {
userId: string;
message: string;
channel?: "email" | "sms" | "push";
}
function sendNotification(options: NotifyOptions) {
// ...
}
改完之后直接跑类型检查,所有旧调用点会逐一列出:
error TS2345: Argument of type 'string' is not assignable to parameter of type 'NotifyOptions'.
这条错误信息就是一份精确的「待改清单」,不会漏、不会多。这就是「类型驱动的重构」:先改签名,让编译器把需要跟进的地方全部指出来,再逐个修。
反过来,如果某次重构之后编译器一声不吭,那你应该问自己一个问题:这段代码真的被类型覆盖了吗,还是沿途都是 any?
never 穷尽性检查:让新增分支无处可逃
重构里最常见的 bug 是「加了新状态,忘了处理」。用 never 可以把这个错误从运行时提前到编译期:
type OrderStatus = "created" | "paid" | "shipped" | "closed";
function describe(status: OrderStatus): string {
switch (status) {
case "created":
return "待支付";
case "paid":
return "已支付";
case "shipped":
return "已发货";
case "closed":
return "已关闭";
default: {
// 如果所有分支都被覆盖,status 在此处会被收窄为 never
const exhaustive: never = status;
return exhaustive;
}
}
}
现在往 OrderStatus 里加一个 "refunded",tsc 立刻报错:
error TS2322: Type '"refunded"' is not assignable to type 'never'.
这行 const exhaustive: never = status 没有任何运行时作用,它是一个编译期的完整性断言。判别联合的更多收窄技巧见 7.2 判别联合(Discriminated Unions)
。
类型驱动的 API 演进
对外暴露的 API 不能随便改,否则会打断所有调用方。TypeScript 提供了几个专门用于「平滑演进」的工具。
其一:@deprecated 标记。 编辑器会把被标记的成员显示成删除线,并在自动补全里降权:
export interface Client {
/** @deprecated 请改用 requestV2,v1 将于下个大版本移除 */
request(url: string): Promise<unknown>;
requestV2<T>(options: { url: string; parse?: (raw: unknown) => T }): Promise<T>;
}
其二:用可选字段加宽,而不是必填字段收窄。 给接口新增可选字段是安全的(老调用方不受影响),给已有字段改类型则必然破坏调用方。
其三:satisfies 保持字面量精度。 以前为了约束类型只能用注解,结果字面量被拓宽成 string;satisfies 让你既校验又保留精度:
const routes = {
home: "/",
user: "/users/:id",
} satisfies Record<string, `/${string}`>;
type HomePath = typeof routes.home; // "/" —— 而不是 string
routes.user 依然能拿到字面量类型 /users/:id,这让下游的路由参数推导成为可能。
团队规范:先把 tsconfig 统一
规范要从配置文件开始,因为每个开发者本地的 tsconfig.json 差异会让「我这儿能过」变成常态。做法是抽一份共享配置,各项目 extends 它:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": false,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true
}
}
其中几条值得单独说明:
| 开关 | 作用 | 建议 |
|---|---|---|
noUncheckedIndexedAccess | 索引访问结果加上 undefined | 强烈建议开,能挡住大量数组越界 |
noImplicitOverride | 覆写父类方法必须写 override | 建议开,重构基类时很有用 |
noFallthroughCasesInSwitch | 禁止 switch 落空穿透 | 建议开 |
exactOptionalPropertyTypes | 区分「缺失」与「值为 undefined」 | 改动面大,老项目慎开 |
verbatimModuleSyntax | 强制类型导入写 import type | 建议开,对 ESM 友好 |
verbatimModuleSyntax 与模块互操作的关系见 11.2 ESM/CJS 互操作与 moduleResolution
。
规范条目:团队需要明确的几条硬规则
配置管不到「写法」,这部分要靠规范文本和 lint 规则。下面几条是大多数团队跑下来最值钱的:
规则一:公共 API 必须显式标注返回类型。
// ❌ 返回类型靠推导,改内部实现会悄悄改变对外契约
export function parse(input: string) {
return JSON.parse(input);
}
// ✅ 契约写死,内部实现可以放心重构
export function parse(input: string): unknown {
return JSON.parse(input);
}
理由:推导出的返回类型会随实现漂移。内部函数可以省,跨模块导出的函数必须写。
规则二:interface 用于对象结构,type 用于联合、交叉与工具类型。
// ✅ 对象结构用 interface,可被 implements、可声明合并
interface User {
id: string;
name: string;
}
// ✅ 联合与组合用 type
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
两者在结构化类型系统里大部分场景可互换,选一个约定并贯彻,比争论哪个更「正确」有价值得多。取舍细节见 5.3 结构化类型与两者取舍 。
规则三:禁止裸 any,需要「任意值」时用 unknown 加收窄。
// ❌ 关闭了后续所有检查
function handle(payload: any) {
return payload.data.items[0].name;
}
// ✅ 强制在使用前校验
function handle(payload: unknown) {
if (!isOrderPayload(payload)) throw new Error("invalid payload");
return payload.data.items[0].name;
}
规则四:禁用 @ts-ignore,只允许 @ts-expect-error 并附原因。 理由在 18.1 JavaScript 项目渐进式迁移
里已经说明:@ts-expect-error 会在错误消失时提醒你清理。
规则五:类型断言 as 必须有据可依。 允许的场景是「运行时已经校验过」,不允许的场景是「我确定它是这个类型」。
lint 与 CI 门禁
规范不落到工具上就会退化。ESLint 的 typescript-eslint 插件提供了大量与类型系统联动的规则:
// eslint.config.js(扁平配置)
import tseslint from "typescript-eslint";
export default tseslint.config(
...tseslint.configs.recommendedTypeChecked,
{
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unsafe-assignment": "error",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/ban-ts-comment": [
"error",
{ "ts-ignore": true, "ts-expect-error": "allow-with-description" },
],
},
},
);
注意 recommendedTypeChecked 这一档需要 parserOptions.project 指向 tsconfig,因此它比纯语法规则慢,但能抓到「忘了 await」这类纯文本 lint 抓不到的问题。no-floating-promises 尤其值得开——它能挡住一类最难排查的线上问题。
CI 里的门禁至少要包含三步:
npx tsc --noEmit # 类型检查必须零错误
npx eslint . --max-warnings=0
npx vitest run --coverage # 测试与覆盖率
三条命令的完整 CI 编排见 15.3 覆盖率、lint 与 CI 门禁 。Monorepo 下还可以把类型检查做成增量任务,做法见 16.3 Monorepo 与 Project References 。
用类型覆盖率跟踪收敛
开了 strict 也不代表没有 any 漏洞。type-coverage 这类工具能算出「有多少标识符拥有非 any 类型」:
npx type-coverage --detail --strict
src/services/order.ts
87:12 any (legacy adapter)
142:5 any (third-party response)
98.42% (4821/4899) types are covered
把这个数字设成 CI 门禁(例如「不得低于 98% 且不得下降」),就能防止「这次先写 any,下次再改」的累积。它比「有没有开 strict」更能反映真实的类型质量。
Code Review 的类型检查清单
把下面这张表放进团队的 PR 模板,评审时逐条对照,比口头提醒有效得多:
| 检查项 | 不合格示例 | 合格写法 |
|---|---|---|
是否有裸 any | function f(x: any) | function f(x: unknown) + 收窄 |
| 导出函数是否标注返回类型 | export function f() {} | export function f(): void {} |
| 联合类型是否穷尽处理 | switch 无 default 断言 | const x: never = status |
| 类型断言是否有据 | data as User | 先 isUser(data) 校验再断言 |
是否用了 @ts-ignore | // @ts-ignore | // @ts-expect-error 原因 |
| 是否重复定义了同名字段 | 前后端各写一份 User | 抽到共享包,见 17.2 前后端共享类型与 API 契约 |
| 新增可选字段是否破坏调用方 | 把必填改成可选以外的改法 | 只加可选字段,旧字段保持兼容 |
一个真实工程示例:把字符串参数换成判别联合
这是「类型驱动重构」最有代表性的场景。假设有个发通知的函数,渠道用字符串表示:
// 重构前:任何字符串都能传进来,拼错了要等运行时才发现
function notify(userId: string, channel: string, content: string): void {
if (channel === "email") sendEmail(userId, content);
else if (channel === "sms") sendSms(userId, content);
// 其他值静默什么都不做 —— 典型的隐藏 bug
}
改造成判别联合,让每种渠道携带自己的专属参数:
type NotifyInput =
| { channel: "email"; userId: string; subject: string; body: string }
| { channel: "sms"; userId: string; text: string }
| { channel: "push"; deviceToken: string; title: string };
function notify(input: NotifyInput): void {
switch (input.channel) {
case "email":
sendEmail(input.userId, input.subject, input.body);
return;
case "sms":
sendSms(input.userId, input.text);
return;
case "push":
sendPush(input.deviceToken, input.title);
return;
default: {
const exhaustive: never = input;
throw new Error(`unhandled channel: ${JSON.stringify(exhaustive)}`);
}
}
}
改造的价值在调用侧立刻显现:
notify("u1", "emial", "hi"); // 报错:参数类型不匹配,且拼写错误被挡住
notify({ channel: "email", userId: "u1", subject: "Hi", body: "..." }); // 通过
notify({ channel: "push", deviceToken: "t1", title: "Hi" }); // 通过
三个收益同时发生:拼写错误的渠道名被编译期挡住;每个渠道只能传它需要的字段(push 不需要 userId);将来新增渠道时,忘记处理会在编译期报错。这就是「类型驱动」的含义——重构的方向由类型定义决定,而不是由个人经验决定。
常见坑与错误信息
坑一:把 strict 打开就以为万事大吉。 strict 不禁止 any,也不禁止 as。真正的防线是 lint 规则加类型覆盖率门禁。
坑二:为了通过类型检查而滥用 as。 这等于手工关掉了检查。看见 as 就该问:这里有没有可能做运行时校验?
坑三:共享类型包被当成本地文件复制。 一旦复制成两份,两边就会漂移。正确做法是抽到独立的 workspace 包并统一版本。
坑四:穷尽性检查写了但 default 分支直接返回兜底字符串。
function label(status: "ok" | "error"): string {
switch (status) {
case "ok": return "ready";
default:
// ❌ 这样写不会报错,但也失去了断言的意义
return "unknown";
}
}
必须用 const x: never = input 这种赋值给 never 的写法才能真正触发检查。
坑五:CI 只跑 lint 不跑 tsc。 eslint 的类型感知规则依赖 tsconfig,但覆盖不了全部类型错误。两者都要跑。
规范落地的节奏建议
一次性推出二十条规范,结果一定是没人遵守。推荐分三批:
| 批次 | 内容 | 预期 |
|---|---|---|
| 第一批 | 共享 tsconfig(strict: true)+ CI 跑 tsc --noEmit | 立刻见效,无争议 |
| 第二批 | lint 规则(禁 any、禁 ts-ignore、类型导入) | 需要一次集中清理 |
| 第三批 | 类型覆盖率门禁 + Review 清单 | 长期收敛 |
每一批都配一次团队内分享,讲清楚「为什么」而不只是「要求什么」。
小结
- 类型系统让重构从「靠记忆」变成「靠编译器」:改签名之后,待改清单由
tsc精确给出。 const x: never = value是编译期的穷尽性断言,让新增联合成员无处可逃。- API 演进优先用「加可选字段」与
@deprecated;satisfies能同时校验与保留字面量精度。 - 团队规范要从共享 tsconfig 起步,再用 lint 规则把「写法」固化,最后接进 CI 门禁。
noUncheckedIndexedAccess、noImplicitOverride这类开关对老项目收益很高,值得优先打开。- 类型覆盖率是比「有没有开 strict」更真实的类型质量指标,适合作为长期门禁。
到这里,你已经知道怎么把类型系统用成一个持续的工程能力。最后一节我们退后一步,看全局:整本书的知识地图、三条典型学习路径,以及面对不断变化的生态时该怎么做出选型判断。
阅读导航:上一节:18.1 JavaScript 项目渐进式迁移 · 下一节:18.3 学习路径与生态选型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。