1. 为什么要在 TypeScript 里做领域建模
1.1 从“类型体操”到“业务语义”的认知转变
我见过太多 TypeScript 项目,类型定义写得花里胡哨,Conditional Types、Template Literal Types、infer嵌套玩得飞起,但打开types.ts一看,里面全是UserInfo、DataItem、ResultVO这种万能类型。这种代码在编译期确实不会报错,但业务逻辑该乱还是乱。问题的根源在于:类型系统被当成了语法检查工具,而不是业务语义的载体。
领域建模要解决的核心问题不是“这个字段是 string 还是 number”,而是“这个字段在业务上代表什么、它和其他概念是什么关系、它有哪些合法状态”。举个例子,电商系统里的“订单金额”,如果你只写amount: number,那它可以是 -999,可以是 0.001,可以是 NaN。但业务上订单金额必须是正数、精确到分、有货币单位。这些约束如果只靠运行时校验,那类型系统就白白浪费了。
TypeScript 的类型系统足够强大,强大到可以表达绝大多数业务约束。问题在于,大多数人不知道怎么把业务语言翻译成类型语言。斯坦福本体论七步法提供了一套系统化的方法论,帮我们把“业务专家嘴里的概念”变成“类型系统里的结构”。
1.2 斯坦福本体论七步法到底是什么
本体论(Ontology)这个词听起来很学术,但它的本质很简单:用形式化的方式描述一个领域里有哪些概念、这些概念有什么属性、概念之间有什么关系。斯坦福本体论七步法是斯坦福大学在 Protege 工具实践中总结出来的一套方法论,原本用于知识图谱和语义网领域,但它的核心思想完全可以迁移到 TypeScript 领域建模中。
这七步分别是:
- 确定领域和范围:明确你要建模的业务边界是什么,哪些概念在范围内,哪些不在。
- 考虑复用现有本体:看看有没有现成的模型可以借鉴,避免重复造轮子。
- 列举领域中的重要术语:把业务专家提到的所有名词、动词、状态词都列出来。
- 定义类和类层次结构:把术语归类,确定父子关系。
- 定义类的属性:每个类有哪些字段,字段的类型和约束是什么。
- 定义属性的约束:字段的取值范围、基数约束、默认值等。
- 创建实例:用具体数据验证模型是否合理。
这套方法论的价值在于它强迫你按顺序思考:先划边界,再找概念,再理关系,最后填细节。大多数 TypeScript 项目的问题恰恰是跳过了前几步,直接开始写 interface,结果就是类型定义和业务语义脱节。
1.3 这套方法适合谁、能解决什么问题
如果你正在做以下事情,这套方法会非常有价值:
- 维护一个超过 50 个类型定义的中大型前端项目,类型文件已经变成“垃圾场”
- 团队里前后端对同一个概念的理解不一致,接口联调时经常扯皮
- 业务逻辑复杂,状态流转多,经常出现“理论上不可能”的运行时错误
- 准备做领域驱动设计(DDD),但不知道如何用 TypeScript 落地
我自己的经验是,在一个 SaaS 后台项目里,用这套方法重构了核心的权限模型后,类型相关的运行时错误下降了约 70%,新成员理解业务模型的時間从两周缩短到三天。这不是因为类型写得更复杂了,恰恰相反,是因为类型写得更准确了。
2. 七步法的 TypeScript 落地拆解
2.1 第一步:确定领域和范围——画出你的“限界上下文”
这一步的关键是明确边界。在 DDD 里这叫“限界上下文”(Bounded Context),在 TypeScript 里体现为模块划分和命名空间设计。
我通常的做法是拿一张白纸,让业务方用一句话描述系统是干什么的。比如“这是一个面向中小企业的报销审批系统”。然后追问:报销单从提交到打款,中间经过哪些角色、哪些状态、哪些规则?把答案里的名词圈出来,这些就是候选概念。
范围界定的一个实用技巧是问三个问题:
- 这个概念在系统里有没有独立的生命周期?(有 → 可能是实体)
- 这个概念有没有唯一标识?(有 → 实体;没有 → 值对象)
- 这个概念的变化会不会影响其他概念?(会 → 需要建模关系)
注意:范围不要贪大。我见过一个项目试图把整个公司的业务流程都建模进去,结果类型文件超过 3000 行,没人看得懂。正确的做法是先建模核心域,支撑域和通用域用简单类型带过。
2.2 第二步:复用现有本体——站在巨人的肩膀上
这一步在 TypeScript 里的体现是:优先使用标准库和成熟库的类型,而不是自己造。
比如日期时间,不要自己写{ year: number, month: number, day: number },直接用Date或者Temporal(如果可用)。比如金额,不要自己写{ amount: number, currency: string },可以看看dinero.js的类型定义。比如 ID,不要到处写string,可以定义一个 branded type。
// 不要这样 interface Order { id: string; userId: string; amount: number; } // 可以这样 type OrderId = string & { readonly __brand: unique symbol }; type UserId = string & { readonly __brand: unique symbol }; type Money = { amount: number; currency: 'CNY' | 'USD' }; interface Order { id: OrderId; userId: UserId; amount: Money; }Branded type 的好处是编译期就能防止你把UserId传给需要OrderId的地方。这个技巧在面试里也经常被问到,属于 TypeScript 中高级用法的标配。
2.3 第三步:列举重要术语——建立“词汇表”
这一步我习惯用一个 Markdown 表格来整理,列包括:术语、英文名、类型(实体/值对象/枚举)、描述、来源(业务方/技术方)。
| 术语 | 英文名 | 类型 | 描述 |
|---|---|---|---|
| 报销单 | ExpenseReport | 实体 | 员工提交的报销申请 |
| 报销项 | ExpenseItem | 实体 | 报销单中的单条明细 |
| 金额 | Money | 值对象 | 带货币单位的金额 |
| 状态 | Status | 枚举 | 报销单的审批状态 |
| 审批人 | Approver | 实体 | 有审批权限的用户 |
这个表格的价值在于:它是业务方和技术方之间的“合同”。业务方确认术语含义,技术方确认类型归属。后续所有类型定义都从这个表格派生。
实操心得:术语表一定要让业务方签字确认。我踩过的坑是,业务方说“审批人”和“审核人”是一回事,结果代码里混用了两个词,后来发现业务上“审核”是初审,“审批”是终审,完全不同的权限。这种歧义越早发现越好。
2.4 第四步:定义类和层次结构——用 discriminated union 表达继承
TypeScript 没有传统的类继承(虽然语法上有 class,但在类型层面我们更常用组合和联合类型)。表达类层次结构的最佳实践是discriminated union(可辨识联合)。
比如报销单的状态:
// 不要这样:用一个枚举 + 可选字段 interface ExpenseReport { status: 'draft' | 'submitted' | 'approved' | 'rejected' | 'paid'; approver?: UserId; // 只有 submitted 之后才有 rejectReason?: string; // 只有 rejected 才有 paidAt?: Date; // 只有 paid 才有 } // 应该这样:每个状态一个类型 type DraftReport = { status: 'draft'; // 草稿状态的特有字段 }; type SubmittedReport = { status: 'submitted'; submittedAt: Date; approver: UserId; }; type ApprovedReport = { status: 'approved'; approvedAt: Date; approver: UserId; }; type RejectedReport = { status: 'rejected'; rejectedAt: Date; rejectReason: string; }; type PaidReport = { status: 'paid'; paidAt: Date; paymentId: string; }; type ExpenseReport = DraftReport | SubmittedReport | ApprovedReport | RejectedReport | PaidReport;这样写的好处是:当你switch (report.status)时,TypeScript 会自动收窄类型,你不可能在draft状态下访问approver字段。这就是所谓的“让非法状态不可表示”。
2.5 第五步:定义属性——值对象优先
属性定义的核心原则是:能用值对象就不用原始类型。
原始类型(string、number、boolean)的问题是它们没有语义。string可以是邮箱、可以是 URL、可以是 UUID,编译器分不清。值对象把这些语义封装起来。
// 原始类型 interface User { email: string; age: number; } // 值对象 type Email = string & { readonly __brand: 'Email' }; type Age = number & { readonly __brand: 'Age' }; interface User { email: Email; age: Age; } // 构造函数(智能构造器) function createEmail(input: string): Email { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) { throw new Error('Invalid email'); } return input as Email; } function createAge(input: number): Age { if (input < 0 || input > 150 || !Number.isInteger(input)) { throw new Error('Invalid age'); } return input as Age; }这种模式叫“智能构造器”(Smart Constructor),它把校验逻辑集中在创建时,后续所有使用这个类型的地方都不需要再校验。这是函数式编程里的经典模式,在 TypeScript 里用 branded type 实现非常自然。
2.6 第六步:定义约束——用类型系统表达业务规则
约束分几类:
- 基数约束:一个报销单至少有 1 个报销项,最多 100 个
- 取值范围:金额必须大于 0,年龄必须在 0-150 之间
- 唯一性约束:同一用户同一天不能提交两张相同类型的报销单
- 依赖约束:如果状态是 rejected,必须有 rejectReason
TypeScript 能表达前两类,后两类需要运行时校验。但即使是前两类,也能在编译期拦截大量错误。
// 基数约束:用 tuple 类型 type NonEmptyArray<T> = [T, ...T[]]; interface ExpenseReport { items: NonEmptyArray<ExpenseItem>; // 至少一个 } // 取值范围:用 branded type + 智能构造器 type PositiveMoney = Money & { readonly __brand: 'Positive' }; function createPositiveMoney(amount: number, currency: Currency): PositiveMoney { if (amount <= 0) throw new Error('Amount must be positive'); return { amount, currency } as PositiveMoney; }2.7 第七步:创建实例——用测试验证模型
这一步在 TypeScript 里对应的是写类型测试。我习惯用tsd或者expect-type来写类型层面的断言。
import { expectType } from 'tsd'; // 验证类型收窄 const report: ExpenseReport = getReport(); if (report.status === 'rejected') { expectType<string>(report.rejectReason); // @ts-expect-error rejectReason 在 approved 状态下不存在 expectType<string>(report.paidAt); }类型测试的价值在于:当你重构类型定义时,这些测试会告诉你哪些地方的行为变了。这比运行时测试更早发现问题。
3. 完整实操:从零构建一个报销审批领域模型
3.1 项目初始化与目录结构
我习惯按领域划分目录,而不是按技术分层:
src/ domain/ expense-report/ types.ts # 类型定义 constructors.ts # 智能构造器 validators.ts # 运行时校验 __tests__/ types.test-d.ts # 类型测试 user/ types.ts shared/ money.ts branded-types.ts这种结构的好处是:当你修改报销单模型时,所有相关代码都在一个目录下,不会散落在types/、utils/、services/各个角落。
3.2 基础值对象的实现
先实现共享的值对象:
// shared/branded-types.ts declare const __brand: unique symbol; type Brand<T, B> = T & { readonly [__brand]: B }; export type UserId = Brand<string, 'UserId'>; export type ExpenseReportId = Brand<string, 'ExpenseReportId'>; export type Email = Brand<string, 'Email'>; // shared/money.ts export type Currency = 'CNY' | 'USD' | 'EUR'; export interface Money { readonly amount: number; // 以分为单位,避免浮点误差 readonly currency: Currency; } export function createMoney(amount: number, currency: Currency): Money { if (!Number.isInteger(amount)) { throw new Error('Amount must be integer (in cents)'); } return { amount, currency }; } export function addMoney(a: Money, b: Money): Money { if (a.currency !== b.currency) { throw new Error('Cannot add different currencies'); } return createMoney(a.amount + b.amount, a.currency); }注意:金额用“分”为单位存储是金融系统的铁律。用浮点数存“元”迟早会遇到
0.1 + 0.2 !== 0.3的问题。这个坑我在三个项目里都见过,每次都是上线后才发现。
3.3 实体与聚合根的建模
报销单是聚合根,报销项是实体:
// domain/expense-report/types.ts import { UserId, ExpenseReportId } from '../shared/branded-types'; import { Money } from '../shared/money'; export type ExpenseCategory = 'travel' | 'meal' | 'office' | 'other'; export interface ExpenseItem { readonly id: string; readonly category: ExpenseCategory; readonly amount: Money; readonly description: string; readonly receiptUrl?: string; } // 状态用 discriminated union export type DraftReport = { readonly status: 'draft'; readonly id: ExpenseReportId; readonly submitter: UserId; readonly items: ExpenseItem[]; readonly createdAt: Date; }; export type SubmittedReport = { readonly status: 'submitted'; readonly id: ExpenseReportId; readonly submitter: UserId; readonly items: readonly ExpenseItem[]; readonly createdAt: Date; readonly submittedAt: Date; readonly approver: UserId; }; export type ApprovedReport = { readonly status: 'approved'; readonly id: ExpenseReportId; readonly submitter: UserId; readonly items: readonly ExpenseItem[]; readonly createdAt: Date; readonly submittedAt: Date; readonly approver: UserId; readonly approvedAt: Date; }; export type RejectedReport = { readonly status: 'rejected'; readonly id: ExpenseReportId; readonly submitter: UserId; readonly items: readonly ExpenseItem[]; readonly createdAt: Date; readonly submittedAt: Date; readonly approver: UserId; readonly rejectedAt: Date; readonly rejectReason: string; }; export type PaidReport = { readonly status: 'paid'; readonly id: ExpenseReportId; readonly submitter: UserId; readonly items: readonly ExpenseItem[]; readonly createdAt: Date; readonly submittedAt: Date; readonly approver: UserId; readonly approvedAt: Date; readonly paidAt: Date; readonly paymentId: string; }; export type ExpenseReport = | DraftReport | SubmittedReport | ApprovedReport | RejectedReport | PaidReport;注意这里用了readonly修饰符。领域模型应该是不可变的,状态流转通过创建新对象实现,而不是修改原对象。这是函数式领域建模的核心原则。
3.4 状态流转的类型安全实现
状态流转是领域模型的核心。用 TypeScript 实现时,关键是让非法流转在编译期就报错:
// domain/expense-report/transitions.ts import { ExpenseReport, DraftReport, SubmittedReport, ApprovedReport, RejectedReport, PaidReport } from './types'; export function submit( report: DraftReport, approver: UserId ): SubmittedReport { if (report.items.length === 0) { throw new Error('Cannot submit empty report'); } return { ...report, status: 'submitted', items: [...report.items], submittedAt: new Date(), approver, }; } export function approve(report: SubmittedReport): ApprovedReport { return { ...report, status: 'approved', approvedAt: new Date(), }; } export function reject( report: SubmittedReport, reason: string ): RejectedReport { if (!reason.trim()) { throw new Error('Reject reason is required'); } return { ...report, status: 'rejected', rejectedAt: new Date(), rejectReason: reason, }; } export function pay( report: ApprovedReport, paymentId: string ): PaidReport { return { ...report, status: 'paid', paidAt: new Date(), paymentId, }; }这样设计后,你不可能对DraftReport调用approve,因为approve的参数类型是SubmittedReport。编译器会直接报错。这就是“让非法状态不可表示”的威力。
3.5 运行时校验与类型守卫
类型系统只在编译期起作用,运行时数据来自 API,需要校验。我习惯用zod做运行时校验,然后从 schema 推导类型:
import { z } from 'zod'; const MoneySchema = z.object({ amount: z.number().int(), currency: z.enum(['CNY', 'USD', 'EUR']), }); const ExpenseItemSchema = z.object({ id: z.string().uuid(), category: z.enum(['travel', 'meal', 'office', 'other']), amount: MoneySchema, description: z.string().min(1).max(500), receiptUrl: z.string().url().optional(), }); const DraftReportSchema = z.object({ status: z.literal('draft'), id: z.string().uuid(), submitter: z.string().uuid(), items: z.array(ExpenseItemSchema), createdAt: z.date(), }); // ... 其他状态的 schema const ExpenseReportSchema = z.discriminatedUnion('status', [ DraftReportSchema, SubmittedReportSchema, ApprovedReportSchema, RejectedReportSchema, PaidReportSchema, ]); // 从 schema 推导类型 type ExpenseReport = z.infer<typeof ExpenseReportSchema>;用zod的好处是:类型定义和运行时校验是同一份代码,不会出现“类型改了但校验没改”的问题。z.discriminatedUnion和 TypeScript 的 discriminated union 完美对应。
4. 常见问题与排查技巧实录
4.1 类型定义膨胀怎么办
这是最常见的问题。项目做大了,types.ts变成几千行,没人敢改。我的解决方案是:
- 按聚合根拆分文件:每个聚合根一个目录,类型定义不超过 200 行
- 用
import type避免循环依赖:TypeScript 的import type在编译后会被完全擦除,不会产生运行时依赖 - 定期做类型审计:用
ts-prune找出未使用的类型,用madge找出循环依赖
实操心得:我每个月会花半天时间做类型审计。删掉未使用的类型,合并重复的类型,把过大的类型拆开。这半天投入的回报是后续一个月的开发效率。
4.2 前后端类型不一致怎么破
这是联调时的经典问题。我的做法是:
- 后端用 OpenAPI 或 GraphQL 定义接口,生成 TypeScript 类型
- 前端不手写接口类型,全部从 schema 生成
- 在 CI 里加一步类型检查,如果生成的类型和前端使用的类型不匹配,直接失败
如果后端不用 OpenAPI,那就退而求其次:把接口类型定义放在一个共享的 npm 包里,前后端都依赖这个包。这样至少保证类型定义是同一份。
4.3 类型体操写得太复杂怎么办
我见过一些项目,类型定义里全是infer、extends、条件类型嵌套,看起来很高深,但没人维护得了。我的原则是:
- 业务类型保持简单:
interface和type联合就够了 - 工具类型可以复杂,但要有注释和测试:比如
DeepReadonly<T>这种,写一次就够了 - 能用代码生成就不用类型体操:比如从数据库 schema 生成类型,比手写类型体操可靠得多
4.4 常见问题速查表
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 类型收窄失效 | 用了as断言 | 改用类型守卫或 discriminated union |
| 循环依赖 | 类型文件互相引用 | 用import type,或提取共享类型到单独文件 |
| 类型定义和运行时不一致 | 手写类型 + 手写校验 | 用 zod 等库从 schema 推导类型 |
| 泛型太复杂看不懂 | 过度抽象 | 拆成具体类型,或用代码生成 |
| 类型文件太大 | 没有按领域拆分 | 按聚合根拆分目录 |
| 前后端类型不一致 | 各自手写 | 共享类型包或从 schema 生成 |
4.5 几个容易踩的坑
坑一:enum的陷阱。TypeScript 的enum会生成运行时代码,而且和const enum的行为不一致。我现在的做法是全部用 union type 代替enum:
// 不要这样 enum Status { Draft, Submitted, Approved } // 这样更好 type Status = 'draft' | 'submitted' | 'approved';坑二:any的传染。一个any会污染整条调用链。我的做法是在tsconfig.json里开启noImplicitAny和strict,然后用unknown代替any,强制做类型收窄。
坑三:as断言的滥用。as是逃生舱,不是常规工具。每次用as都应该问自己:为什么编译器推断不出来?是不是类型定义有问题?
坑四:忽略readonly。领域模型应该是不可变的。我习惯在所有领域类型的字段上加readonly,数组用readonly T[]。这样编译器会阻止意外的修改。
5. 工具链与工程化配置
5.1 tsconfig 的关键配置
{ "compilerOptions": { "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "isolatedModules": true, "verbatimModuleSyntax": true } }noUncheckedIndexedAccess和exactOptionalPropertyTypes这两个选项很多人不知道,但它们能拦截大量潜在 bug。前者让arr[0]的类型变成T | undefined,后者区分{ a?: string }和{ a: string | undefined }。
5.2 类型测试的配置
用tsd做类型测试:
npm install --save-dev tsd{ "tsd": { "directory": "src", "compilerOptions": { "strict": true } } }然后在__tests__目录下写.test-d.ts文件。这些文件不会被编译到产物里,只在tsd运行时检查。
5.3 代码生成的最佳实践
如果后端有 OpenAPI schema,用openapi-typescript生成类型:
npx openapi-typescript ./api-schema.yaml -o ./src/generated/api-types.ts生成的类型放在generated目录,不要手动修改。在 CI 里加一步检查:如果重新生成的类型和提交的类型不一致,说明后端改了 schema 但前端没更新,直接失败。
6. 从模型到代码的完整链路
6.1 领域模型如何驱动业务逻辑
领域模型建好后,业务逻辑的写法会发生变化。以前是“拿到数据,判断状态,执行操作”,现在是“根据状态类型,调用对应的转换函数”。
// 以前:运行时判断 function handleApprove(report: ExpenseReport) { if (report.status !== 'submitted') { throw new Error('Invalid status'); } // ... } // 现在:编译期保证 function handleApprove(report: SubmittedReport) { // 不需要判断状态,类型系统已经保证了 return approve(report); }这种转变的价值在于:错误从运行时提前到了编译期。以前要写测试才能发现的 bug,现在编译器直接报错。
6.2 与前端框架的集成
在 React 或 Vue 里,领域模型可以作为状态管理的核心。以 React 为例:
function useExpenseReport(id: ExpenseReportId) { const [report, setReport] = useState<ExpenseReport | null>(null); const handleSubmit = useCallback((approver: UserId) => { setReport(prev => { if (prev?.status !== 'draft') return prev; return submit(prev, approver); }); }, []); const handleApprove = useCallback(() => { setReport(prev => { if (prev?.status !== 'submitted') return prev; return approve(prev); }); }, []); return { report, handleSubmit, handleApprove }; }注意handleApprove里的类型收窄:prev?.status !== 'submitted'之后,TypeScript 知道prev是SubmittedReport,所以approve(prev)能通过类型检查。
6.3 与后端 API 的对接
后端返回的数据是unknown,需要校验后才能进入领域模型:
async function fetchExpenseReport(id: ExpenseReportId): Promise<ExpenseReport> { const response = await fetch(`/api/reports/${id}`); const data: unknown = await response.json(); return ExpenseReportSchema.parse(data); }parse会抛出异常如果数据不合法。如果想返回Result类型,可以用safeParse:
const result = ExpenseReportSchema.safeParse(data); if (!result.success) { // 处理错误 return; } const report = result.data; // 类型是 ExpenseReport7. 模型演进与版本管理
7.1 当业务规则变化时如何调整模型
业务规则变化是常态。关键是让类型变化的影响范围可控。我的做法是:
- 新增状态用新的类型,不要修改现有类型
- 废弃字段用
@deprecated标记,不要直接删除 - 重大变更走版本化,比如
ExpenseReportV2
/** * @deprecated Use `rejectReason` instead. Will be removed in v3. */ reason?: string;7.2 类型版本化的策略
如果 API 有多个版本,类型也要版本化:
// v1 export interface ExpenseReportV1 { status: 'draft' | 'submitted' | 'approved'; } // v2 export interface ExpenseReportV2 { status: 'draft' | 'submitted' | 'approved' | 'rejected' | 'paid'; rejectReason?: string; }然后在适配层做转换:
function adaptV1ToV2(v1: ExpenseReportV1): ExpenseReportV2 { return { ...v1, status: v1.status, }; }7.3 迁移旧代码的实操步骤
迁移旧代码不要一次性全改,风险太大。我的做法是:
- 先加新类型,不改旧代码:新类型和旧类型并存
- 新功能用新类型:新写的代码全部用新类型
- 逐步迁移旧代码:每次改一个模块,改完跑测试
- 最后删除旧类型:确认没有引用后删除
这个过程可能持续几周甚至几个月,但比一次性重构安全得多。
8. 一些个人体会
这套方法我在三个项目里完整实践过,最大的感受是:领域建模的收益不是线性的,而是阶跃的。前期投入时间建模型,可能一两周看不到明显效果,但一旦模型建好,后续开发速度会突然加快。因为大部分业务逻辑的“思考成本”已经在建模阶段付过了。
另一个体会是:类型不是越严格越好,而是越准确越好。我见过一些项目,类型定义严格到变态,但和业务语义脱节,结果开发效率反而下降。好的类型定义应该让正确的代码写起来自然,让错误的代码写起来别扭。
最后,斯坦福本体论七步法只是一个框架,不要死板地按七步走。实际项目中,第三步和第四步经常需要反复迭代,第五步和第六步可能合并。重要的是理解它的核心思想:先划边界,再找概念,再理关系,最后填细节。这个顺序不能乱。
如果你正在维护一个类型混乱的项目,我的建议是不要试图一次性重构。选一个核心聚合根,用这套方法重新建模,然后观察效果。如果效果好,再推广到其他模块。这种“试点-推广”的策略比“大爆炸式重构”靠谱得多。