尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

TypeScript领域建模实战:斯坦福本体论七步法

TypeScript领域建模实战:斯坦福本体论七步法 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必须有 rejectReasonTypeScript 能表达前两类后两类需要运行时校验。但即使是前两类也能在编译期拦截大量错误。// 基数约束用 tuple 类型 type NonEmptyArrayT [T, ...T[]]; interface ExpenseReport { items: NonEmptyArrayExpenseItem; // 至少一个 } // 取值范围用 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) { expectTypestring(report.rejectReason); // ts-expect-error rejectReason 在 approved 状态下不存在 expectTypestring(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 BrandT, B T { readonly [__brand]: B }; export type UserId Brandstring, UserId; export type ExpenseReportId Brandstring, ExpenseReportId; export type Email Brandstring, 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.infertypeof 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联合就够了工具类型可以复杂但要有注释和测试比如DeepReadonlyT这种写一次就够了能用代码生成就不用类型体操比如从数据库 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] useStateExpenseReport | 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): PromiseExpenseReport { const response await fetch(/api/reports/${id}); const data: unknown await response.json(); return ExpenseReportSchema.parse(data); }parse会抛出异常如果数据不合法。如果想返回Result类型可以用safeParseconst 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. 一些个人体会这套方法我在三个项目里完整实践过最大的感受是领域建模的收益不是线性的而是阶跃的。前期投入时间建模型可能一两周看不到明显效果但一旦模型建好后续开发速度会突然加快。因为大部分业务逻辑的“思考成本”已经在建模阶段付过了。另一个体会是类型不是越严格越好而是越准确越好。我见过一些项目类型定义严格到变态但和业务语义脱节结果开发效率反而下降。好的类型定义应该让正确的代码写起来自然让错误的代码写起来别扭。最后斯坦福本体论七步法只是一个框架不要死板地按七步走。实际项目中第三步和第四步经常需要反复迭代第五步和第六步可能合并。重要的是理解它的核心思想先划边界再找概念再理关系最后填细节。这个顺序不能乱。如果你正在维护一个类型混乱的项目我的建议是不要试图一次性重构。选一个核心聚合根用这套方法重新建模然后观察效果。如果效果好再推广到其他模块。这种“试点-推广”的策略比“大爆炸式重构”靠谱得多。
返回列表