本节目标:把类型从「逐文件的正确」推进到「系统级的约束」。读完你应该能识别哪些架构规则可以用类型表达、哪些只能靠规范和 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 基线包 | 低 | 配置不再漂移 |
| 2 | CI 门禁 + 棘轮 | 低 | 规范可执行 |
| 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 渐进式迁移与严格化路径 · 全书目录:学习路径与章节总览 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。