《TypeScript编程入门》18.2 类型驱动的重构与团队规范

本节讲如何把类型系统从「编译能过」升级为「持续重构的引擎」。内容包括编译器作为重构安全网的原理、用 never 做穷尽性检查让新增分支在编译期暴露、借助 @deprecated 与 satisfies 做渐进式 API 演进、以共享 tsconfig 与 ESLint 规则固化团队规范、把类型检查接进 CI 门禁,以及用类型覆盖率跟踪收敛。读完你能为一支多人团队定出可执行的类型规范。

本节目标:读完这一节,你能解释「为什么有了类型之后重构可以更大胆」;能用 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 模板,评审时逐条对照,比口头提醒有效得多:

检查项不合格示例合格写法
是否有裸 anyfunction 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 学习路径与生态选型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes