《TypeScript高级编程》11.3 类型驱动架构与团队规范

本节把类型从「事后补的注解」提升为架构约束:用品牌类型表达不可互换的标识、用可辨识联合表达状态机、用 schema 作为契约的单一事实来源,把「谁能调用谁」写进编译期。再给出团队落地方法,包括 tsconfig 基线继承、项目引用、CI 类型门禁与类型债务台账。读完你能设计出新人自动写出合规代码的仓库结构。

本节目标:把类型从「逐文件的正确」推进到「系统级的约束」。读完你应该能识别哪些架构规则可以用类型表达、哪些只能靠规范和 review,并能为团队搭出一套让类型检查真正成为门禁的仓库结构。

11.3 类型驱动架构与团队规范

前两节解决的是「类型够不够严」。这一节换一个问题:类型表达了什么设计。

一个 strict 全开的仓库,仍然可能写出这样的代码:

function transfer(from: string, to: string, amount: number) {
  return bank.move(from, to, amount);
}

类型上完全正确,但它有一个致命的架构缺陷——from 和 to 都是 string,调用时传反了编译器不会吭声。这类错误在测试里往往也测不出来(测试通常也用同一种参数顺序写),最后只能靠线上事故发现。

类型驱动架构的核心主张是:凡是编译期能表达的约束,就不要留给文档和 review。本节从表达手段讲到组织手段。

11.3.1 从「补类型」到「用类型表达设计」

先区分两种工作方式:

维度补类型(annotation-first)类型驱动(type-first)
时机代码写完后补注解先写类型再写实现
目标让 tsc 不报错让非法状态无法构造
关注点单个函数签名模块边界与不变量
失败模式类型正确但语义错类型设计错,成本前置
度量错误数非法调用被拦截的比例

两者不是对立的。补类型是必要的地基,类型驱动是在地基上盖楼。前一节的迁移做完,你才具备做类型驱动的条件——因为类型驱动依赖精确的类型,而精确的类型依赖 strict。

判断一段逻辑是否值得用类型表达,可以问三个问题:这个约束违反后会不会出事故?它能不能被类型系统表达?表达它的成本是否低于维护文档的成本?三个都是「是」才值得做。

11.3.2 用品牌类型表达不可互换的标识

回到开头的 transfer。修法不是加参数名注释,而是让两种 id 在类型上不可互换:

declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type AccountId = Brand<string, "AccountId">;
type UserId = Brand<string, "UserId">;

declare function accountId(raw: string): AccountId;
declare function userId(raw: string): UserId;

function transfer(from: AccountId, to: AccountId, amount: number) {
  return bank.move(from, to, amount);
}

const a = accountId("A-1");
const u = userId("U-9");
transfer(a, a, 100);
transfer(u, a, 100);
// error TS2345: Argument of type 'UserId' is not assignable
// to parameter of type 'AccountId'.

Brand 的关键是 unique symbol:它保证品牌字段无法被外部构造,因此 AccountId 只能由 accountId() 这个工厂产生。这实际上把「校验」和「类型」绑定在了一起——任何能拿到 AccountId 类型的值,都已经过了一次校验。

这个技巧在边界数据上价值最大:不可信输入经过校验函数后返回品牌类型,后续代码就不需要重复校验。它和 10.1 类型守卫与验证库原理 是同一件事的两面——守卫负责在运行时判定,品牌负责把判定结果编码进类型。

品牌类型的成本也要看清:它引入了运行时的包装函数(哪怕只是 return raw as AccountId),并且在 JSON.stringify、日志打印时会带来心智负担。所以它只该用在类型相同但语义不同的地方——AccountId 与 UserId、Cents 与 Yuan、Milliseconds 与 Seconds。

11.3.3 用可辨识联合表达状态机

第二种高价值场景是状态。用一个「万能对象 + 若干可选字段」表示状态,是 bug 的温床:

// 反模式:哪些字段有效取决于 status,编译器不知道
interface Request {
  status: "idle" | "loading" | "success" | "error";
  data?: string;
  error?: Error;
}

改成可辨识联合后,每个状态的合法形态被精确描述:

type Request =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string }
  | { status: "error"; error: Error };

function render(r: Request): string {
  switch (r.status) {
    case "idle": return "空闲";
    case "loading": return "加载中";
    case "success": return r.data;
    case "error": return r.error.message;
  }
}

这个写法的收益是穷尽检查:如果哪天新增了 "cancelled" 状态,上面的 switch 会立刻报错。要让编译器强制这一点,需要显式声明返回值不可达:

function assertNever(x: never): never {
  throw new Error(`未处理的状态: ${JSON.stringify(x)}`);
}

在 switch 末尾调用 assertNever(r),任何未覆盖的分支都会变成类型错误。反过来,如果写成 default: return "",穷尽检查就失效了——这是最常见的自毁操作。

状态机的推演与 2.1 条件类型与分发 中的分发机制是配套的:联合类型在条件类型里会分发,因此可以基于状态联合派生出「哪些状态允许哪些动作」的映射表。

11.3.4 契约先行:schema 即单一事实来源

第三种场景是跨边界契约。前端与后端、服务与服务之间,最大的浪费是同一份数据结构被描述三遍:一次在类型里、一次在校验代码里、一次在文档里。三份会各自漂移。

做法是让 schema 成为唯一来源,类型从 schema 推导:

import { z } from "zod";

export const OrderSchema = z.object({
  id: z.string().uuid(),
  amount: z.number().int().positive(),
  currency: z.enum(["CNY", "USD"]),
  createdAt: z.string().datetime(),
});

export type Order = z.infer<typeof OrderSchema>;

z.infer 让类型与运行时校验共享同一个定义,两者不可能不一致。这个模式在边界数据上的完整用法见 10.2 序列化与反序列化类型 ,实践细节可延伸阅读 类型安全的运行时校验 与 Zod 校验实践 。

一个需要避开的陷阱:不要为了「省一次校验」而让内部代码直接接收 Order 类型。Order 类型的值只有在经过 OrderSchema.parse 之后才可信,未经校验的 JSON.parse 结果应该先用 unknown 接住,再走 schema。把「已校验」和「未校验」在类型上区分开,正是品牌类型的另一个应用点。

11.3.5 用配置表达模块边界

前面三种手段解决的是「值」的约束,还有一类约束针对依赖方向:数据访问层不该 import 表现层,核心域不该 import 基础设施。这类规则可以用两种方式落地。

第一种是 tsconfig 的 paths 别名 + lint 规则。给每层一个别名,再用 no-restricted-imports 禁止跨层引用:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@core/*": ["src/core/*"],
      "@infra/*": ["src/infra/*"],
      "@app/*": ["src/app/*"]
    }
  }
}
// eslint.config.js
export default [{
  files: ["src/core/**/*.ts"],
  rules: {
    "no-restricted-imports": ["error", {
      patterns: [
        { group: ["@infra/*", "@app/*"], message: "core 不得依赖外层" }
      ]
    }]
  }
}];

第二种是项目引用(project references),它把边界变成编译期的事实而非 lint 约定。每个层一个 tsconfig,composite: true,依赖关系写在 references 里:

{
  "files": [],
  "references": [
    { "path": "./src/core" },
    { "path": "./src/infra" },
    { "path": "./src/app" }
  ]
}

项目引用的额外收益是增量编译——只有改动的项目会重新检查,这对大仓库的 tsc 耗时影响巨大。代价是配置复杂度上升,且 composite 要求显式声明产出。多包仓库的完整形态见 TypeScript 单仓与 Turborepo 与 前端单仓方案对比 。

11.3.6 团队规范:基线继承与 CI 门禁

类型驱动的最后一块拼图是让规范不依赖人的自觉。散落在各仓库的 tsconfig.json 一定会漂移,解决办法是抽出一个可发布的基线包:

{
  "name": "@acme/tsconfig",
  "version": "2.1.0",
  "files": ["base.json", "library.json", "app.json"]
}
{
  "extends": "@acme/tsconfig/base.json",
  "compilerOptions": { "outDir": "dist" },
  "include": ["src"]
}

基线包的价值不只是复用配置,而是升级的单一入口:strict 家族、lib、target、moduleResolution 只需在基线里改一次,所有仓库通过依赖升级同步。这条路径让上一节的版本升级从「N 个仓库各自为战」变成「一次版本发布」。

CI 门禁至少包含三条命令:

门禁命令拦截的问题
类型检查tsc --noEmit -p tsconfig.json类型错误
全仓类型检查pnpm -r exec tsc --noEmit子包被遗漏
类型一致性tsc --noEmit --skipLibCheck false(定期)声明文件冲突

第三条不必每次 PR 跑(skipLibCheck: false 会显著变慢),但应该在 nightly 任务里跑一次——它能抓出「本地通过、下游崩」的库作者问题。CI 的整体编排可参考 单仓 CI 策略 。

11.3.7 类型债务台账与 ADR

规范要能长期存活,必须给例外留出口,否则人们会绕开规范。两个机制够用:

类型债务台账:所有 @ts-expect-error 必须带票据号,并且每周有人认领。台账字段建议是「位置 / 原因 / 票据 / 预计消除版本」。

// @ts-expect-error TODO(TS-1042): 等 SDK 2.0 移除 legacy 命名空间
export const meta = sdk.legacy.getMeta();

ADR(架构决策记录):把「为什么不用 enum」「为什么品牌类型只用于 id」这类决策写成一页文档。类型驱动架构里最贵的不是写代码,而是后人不知道某个约束为什么存在,于是在重构时顺手删掉。ADR 就是给约束留的注释。

11.3.8 度量:拦截率与豁免密度

类型驱动架构的效果需要度量,否则无法判断投入是否值得。三个可采集的指标:

指标定义采集方式健康值
类型覆盖率无 any 且被 strict 覆盖的公开 API 占比统计导出符号签名> 95%
边界拦截率非法调用在编译期被拦截的比例构造反例用例集,统计编译失败数> 90%
豁免密度每千行代码的 @ts-expect-error 数grep -c 后按行数归一< 1

「边界拦截率」需要主动构造用例,做法是维护一个 type-tests/ 目录,里面全是期望编译失败的代码:

// type-tests/transfer.test-d.ts
import { transfer, accountId, userId } from "../src/bank";

const a = accountId("A-1");

// @ts-expect-error UserId 不可传给 AccountId 参数
transfer(userId("U-9"), a, 100);

transfer(a, a, 100);

注意 @ts-expect-error 在这里的含义与迁移期完全不同:迁移期它是债务标记,这里它是断言——如果哪天这个调用不再报错,测试就会失败,说明架构约束被破坏了。这类「类型测试」的工程化做法可参考 类型安全的测试 。

11.3.9 落地的推荐顺序

一次性把所有手段铺开,团队会被淹没。推荐按「收益 / 成本」排序推进:

阶段动作投入收益
1统一 tsconfig 基线包低配置不再漂移
2CI 门禁 + 棘轮低规范可执行
3可辨识联合替换状态对象中消除非法状态
4边界 schema 化中契约单一来源
5品牌类型用于 id 与单位中消除同类值互换
6项目引用划分层次高边界成为编译期事实

前两阶段是组织动作,几乎不需要改业务代码,但决定了后面四个阶段能否持续。很多团队失败的原因恰恰是反过来:先花三个月做品牌类型,却因为没有门禁而在半年后全部退化。

11.3.10 反模式清单

最后把这一节的反面收进一张表,可以直接当 review 清单用:

反模式症状替代方案
any 兜底类型在边界处断链unknown + 收窄
as 断言成瘾编译期通过、运行时崩类型守卫 / schema 校验
Partial<T> 到处传调用方不知道哪些字段必填定义独立的 DTO
巨型 types.ts循环依赖、编译变慢按领域拆分并就近放置
barrel index.ts 全量导出构建变慢、tree-shaking 失效显式子路径导出
enum 做状态穷尽检查弱、产物有额外对象字面量联合 + satisfies
default: return ""穷尽检查失效assertNever
@ts-ignore掩盖后续新错误@ts-expect-error + 台账

关于 satisfies(4.9 引入)补充一句:它解决的是「既要精确推断又要类型校验」的两难。写 const routes = {...} satisfies Record<string, Handler> 可以同时保留每个键的精确字面量类型和整体约束,这是纯注解或纯 as const 都做不到的。

小结

类型驱动架构把类型从「检查工具」变成「设计语言」,可归纳为四种表达手段:品牌类型让同构的值不可互换,可辨识联合让状态机的非法形态无法构造,schema 推导让契约只有一个来源,项目引用让分层边界成为编译期事实。它们共同的判据是——约束违反后是否会导致事故、能否被类型表达、表达成本是否低于维护文档。

组织手段与表达手段同样重要:基线包把配置升级收敛成一次发布,CI 门禁让规范不依赖自觉,债务台账与 ADR 给例外留出可追踪的出口。三者缺一,规范就会在半年内退化成「文档里写着但没人遵守」。

到这里,《TypeScript高级编程》的三条主线已经闭环:第一至四章讲类型系统本身,第五至七章讲工具链与发布,第八至十一章讲运行时、边界与工程治理。回到 《TypeScript高级编程》目录 可以看到完整脉络。如果你要立刻动手,建议从 11.2 渐进式迁移与严格化路径 的棘轮开始——先把指标建起来,再谈架构。进一步阅读可参考 类型优先的开发方式 、TypeScript 工程化进阶 与 TypeScript 设计模式实践 。

阅读导航:上一节:11.2 渐进式迁移与严格化路径 · 全书目录:学习路径与章节总览 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  2. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes
  3. 《TypeScript高级编程》10.3 边界数据与不可信输入