用TypeScript类型系统重构条件工作流:从if/else到可辨识联合

📅 发布时间:2026/8/31 20:28:10
用TypeScript类型系统重构条件工作流:从if/else到可辨识联合 在业务系统里做条件工作流时最常见的问题不是流程本身有多复杂而是流程被拆散到代码之后没人能快速说清楚当前系统有哪些状态、每个状态携带什么数据、哪些条件可以触发哪些状态迁移。很多项目习惯直接用string表示状态再用十几层if/else去处理分支前期写起来很快后期每加一个状态都胆战心惊漏改一个分支就要靠线上告警来发现。本文要聊的就是如何用 TypeScript 的“有类型写法”来重构这类条件工作流。我会从最朴素的条件分支写法开始逐步过渡到字面量联合类型、可辨识联合、类型守卫、事件分发表以及穷尽性检查最后用一个完整的审批流例子把这些知识点串起来。无论是前端表单状态机还是后端审批流、工单系统这种写法都能直接落地。1. 为什么条件工作流需要“有类型”的写法1.1 一段常见的“无类型”实现很多早期项目里的状态流转代码长这样type OrderStatus string; function nextStatus(status: OrderStatus): OrderStatus { if (status pending) { return paid; } if (status paid) { return shipped; } if (status shipped) { return completed; } return unknown; }这段代码的问题不在于if/else本身而在于OrderStatus被定义成了string。这意味着任何字符串都可以赋给OrderStatus编译器完全不知道系统里到底有哪些合法状态。你在调用nextStatus(pendingxxx)的时候TypeScript 不会报错只有运行到某个分支拿不到预期结果你才意识到状态枚举被写错了。这类问题的典型表现包括状态字段存了一个不在设计文档里的字符串页面上没有对应展示。某个状态少了处理分支但编译期没有任何提示。同一个含义在代码里出现多个叫法比如status、state、type混用。条件分支内部访问了错误的字段直到运行时才发现undefined。对于流程简单的项目这些问题不容易暴露。一旦流程变多状态变多条件组合变多维护成本会快速上升。1.2 有类型写法解决了什么问题有类型写法的核心思路是把“状态集合”和“每个状态对应的数据结构”都变成类型系统可以理解的东西。这样编译器可以在开发阶段就发现以下问题状态名写错会直接报错。某个状态缺少处理分支会报错。在某个状态下访问了不属于它的字段会报错。条件判断的目标类型不匹配会报错。换句话说以前靠代码评审和经验来规避的问题现在可以交给 TypeScript 编译器来拦截。类型系统在这里不是增加负担而是变成了一套“可执行的文档”。1.3 适用场景有类型的条件工作流写法适合以下场景订单状态流转、退款流程、支付回调处理。审批流、工单系统、任务调度。前端多步骤表单、步骤条、引导流程。任何存在有限状态集合并且状态之间有条件迁移的业务逻辑。如果你的项目状态很少只有两三个分支那么写传统if/else也没有问题。但一旦状态超过四个或者状态数据差异较大我建议尽早切换成有类型写法因为重构成本会随着状态数量增加而指数级上升。2. 环境准备与类型基础2.1 搭建 TypeScript 运行环境本文示例使用 TypeScript 5 的常见配置运行工具使用tsx方便直接执行.ts文件。先初始化一个项目npm init -y npm install -D typescript tsx types/node然后创建tsconfig.json。如果已经从模板生成过配置可以保留自定义部分这里给出一份适合本文示例的最小配置{ compilerOptions: { target: ES2020, module: ESNext, moduleResolution: Bundler, strict: true, skipLibCheck: true, outDir: dist }, include: [src] }在package.json中添加运行脚本{ type: module, scripts: { start: tsx src/index.ts } }项目的源码统一放在src目录下运行命令为npm start如果你的项目已经在使用 Vite、Next.js、NestJS 等框架就不需要单独搭建环境下面讲的核心类型写法可以直接复用。2.2 先理解三个类型工具在进入实战之前先熟悉三个高频出现的类型工具它们是理解“有类型写法”的基础。第一个是字面量联合类型。它限定了变量的合法取值集合例如type State draft | submitted。它比string精确得多任何不在集合内的字符串都会在编译期被拦截。第二个是Extract泛型。它可以从联合类型中提取符合某个结构的子类型。比如ExtractApproval, { state: submitted }会从Approval联合类型中取出state字段为submitted的那个成员。第三个是never类型。它表示永远不会发生的值。在条件工作流中我们经常用never做穷尽性检查确保switch覆盖了所有状态分支。这三个工具不需要背后面看代码示例会更容易理解。3. 类型化条件分支的三板斧3.1 用字面量联合类型约束状态集合第一步把状态字段从string改成字面量联合类型。// 反例状态是 string任何字符串都合法 type OrderStatus string; // 正例状态是明确枚举值 type OrderStatus pending | paid | shipped | completed | cancelled;改造之后下面的代码会在编译期报错const status: OrderStatus paidxxx; // 报错这可能看起来是一个很小的改动但它是整个类型化设计的地基。一旦状态值被限定后面所有条件判断、函数入参、状态映射表就都有了共同的约束基准。3.2 可辨识联合让每个状态携带自己的数据很多时候不同状态需要不同的数据。例如“已提交”状态有提交时间“已通过”状态有审批人和审批时间“已驳回”状态有驳回原因。如果所有状态都塞进同一个接口所有字段都可选代码里会到处出现order.approvedAt ?? 这样的兜底逻辑。可辨识联合Discriminated Union可以解决这个问题。每个状态有一个唯一的判别字段state再分别定义自己的扩展字段interface BaseApproval { id: string; title: string; amount: number; } interface DraftApproval extends BaseApproval { state: draft; } interface SubmittedApproval extends BaseApproval { state: submitted; submittedAt: number; needManager: boolean; } interface ApprovedApproval extends BaseApproval { state: approved; approvedAt: number; approver: string; } type Approval DraftApproval | SubmittedApproval | ApprovedApproval;有了这个类型定义TypeScript 就能够在if (approval.state submitted)或switch (approval.state)分支中自动收窄类型。在 submitted 分支里你可以直接访问submittedAt和needManager在 approved 分支里访问它们就会在编译期报错。3.3 类型守卫与穷尽检查可辨识联合在switch里可以自动收窄但有时候我们需要把“是否处于某个状态”的判断提炼成独立的类型守卫函数。一个典型场景是过滤数组function isSubmitted(approval: Approval): approval is SubmittedApproval { return approval.state submitted; } const submittedList approvalList.filter(isSubmitted); // filteredList 的类型自动变成 SubmittedApproval[]在filter之后数组里的每一项类型会自动收窄为SubmittedApproval后续访问字段时不需要再手动断言。另一个非常重要的写法是never穷尽检查。当我们在switch中遗漏某个状态时never能让编译器直接报错function assertNever(value: never): never { throw new Error(未知状态${JSON.stringify(value)}); } function describe(approval: Approval): string { switch (approval.state) { case draft: return 草稿; case submitted: return 已提交; case approved: return 已通过; default: return assertNever(approval); } }如果后续增加了一个RejectedApproval状态而describe没有添加对应分支default分支的参数类型会从never变成RejectedApprovalTypeScript 会提示assertNever的参数不满足never类型。这就是“让编译器提醒你补全分支”。4. 完整实战审批工作流改造4.1 需求描述与状态设计假设我们要实现一个简单的审批工作流。业务规则如下提单人创建审批单初始状态是draft。创建后可提交状态变为submitted。提交后如果金额大于 1000 元需要经理人工审批如果金额小于等于 1000 元可以走自动通过逻辑。审批通过变为approved驳回变为rejected。草稿和已提交状态可以取消取消后变为cancelled。已提交状态超过 48 小时没有审批可以自动驳回。这个需求里已经有“条件分支”和“状态流转”非常适合展示有类型写法。4.2 创建项目结构项目结构如下condition-workflow-demo/ ├── package.json ├── tsconfig.json └── src/ ├── types.ts ├── workflow.ts └── index.tstypes.ts负责定义所有领域类型workflow.ts负责流转逻辑index.ts是运行入口。4.3 定义领域类型在src/types.ts中写入以下类型定义export type ApprovalState | draft | submitted | approved | rejected | cancelled; export type ApprovalEvent | SUBMIT | APPROVE | REJECT | CANCEL | AUTO_APPROVE | AUTO_REJECT; export type Role employee | manager; export interface BaseApproval { id: string; title: string; owner: string; amount: number; createdAt: number; } export interface DraftApproval extends BaseApproval { state: draft; } export interface SubmittedApproval extends BaseApproval { state: submitted; submittedAt: number; needManager: boolean; } export interface ApprovedApproval extends BaseApproval { state: approved; approvedAt: number; approver: string; } export interface RejectedApproval extends BaseApproval { state: rejected; rejectedAt: number; reason: string; } export interface CancelledApproval extends BaseApproval { state: cancelled; cancelledAt: number; } export type Approval | DraftApproval | SubmittedApproval | ApprovedApproval | RejectedApproval | CancelledApproval;这里的要点是每个状态接口都继承BaseApproval并有一个唯一的state字段。这样Approval就是一个标准的可辨识联合编译器可以精确识别每个状态下有哪些字段。4.4 实现流转动作在src/workflow.ts中先实现单个流转动作。每个动作都遵循“先校验当前状态再返回新状态”的模式import type { Approval } from ./types; function now(): number { return Date.now(); } function createApproval(input: OmitApproval, state): Approval { const { id, title, owner, amount, createdAt } input; return { id, title, owner, amount, createdAt, state: draft }; }这里createApproval是一个辅助函数实际项目中通常从数据库读取或由前端传入。接下来写submitfunction submit(approval: Approval): Approval { if (approval.state ! draft) { throw new Error(只能提交草稿单据当前状态${approval.state}); } const needManager approval.amount 1000; return { ...approval, state: submitted, submittedAt: now(), needManager, }; }因为approval的类型是Approval通过approval.state ! draft判断后后面的代码会把参数收窄为DraftApproval所以可以访问amount等基础字段并且返回对象的类型也能被正确推导。然后是审批和驳回function approve(approval: Approval): Approval { if (approval.state ! submitted) { throw new Error(只能审批已提交单据当前状态${approval.state}); } return { ...approval, state: approved, approvedAt: now(), approver: manager, }; } function reject(approval: Approval, reason: string): Approval { if (approval.state ! submitted) { throw new Error(只能驳回已提交单据当前状态${approval.state}); } return { ...approval, state: rejected, rejectedAt: now(), reason, }; }取消逻辑允许draft和submitted两个状态function cancel(approval: Approval): Approval { if (approval.state ! draft approval.state ! submitted) { throw new Error(当前状态不允许取消当前状态${approval.state}); } return { ...approval, state: cancelled, cancelledAt: now(), }; }自动审批逻辑模拟定时任务或后台扫单function autoHandle(approval: Approval): Approval { if (approval.state ! submitted) { return approval; } const elapsed now() - approval.submittedAt; if (approval.needManager elapsed 48 * 60 * 60 * 1000) { return reject(approval, 超过 48 小时未审批自动驳回); } if (!approval.needManager) { return approve(approval); } return approval; }这里体现了“条件分支”的典型场景金额大且超时走自动驳回金额小走自动通过其他情况继续保持 submitted 状态。4.5 用事件分发表替代 if/else上面的动作函数已经比较清晰的但真正的业务代码里调用方会面临一堆if/elseif (event SUBMIT) { submit(approval); } else if (event APPROVE) { approve(approval); }这种写法的问题是当状态和事件增多时条件分支会越来越长而且很容易遗漏组合。更优雅的方案是把“事件”和“状态”映射到处理函数形成一个事件分发表。type TransitionHandler (approval: Approval) Approval; const transitions: Partial RecordApprovalEvent, PartialRecordApproval[state], TransitionHandler { SUBMIT: { draft: submit, }, APPROVE: { submitted: approve, }, REJECT: { submitted: (approval) reject(approval, 审批人驳回), }, CANCEL: { draft: cancel, submitted: cancel, }, AUTO_APPROVE: { submitted: approve, }, AUTO_REJECT: { submitted: (approval) reject(approval, 自动驳回), }, }; export function dispatch(approval: Approval, event: ApprovalEvent): Approval { const byState transitions[event]; if (!byState) { throw new Error(不支持的事件${event}); } const handler byState[approval.state]; if (!handler) { throw new Error(状态 ${approval.state} 不允许事件 ${event}); } return handler(approval); }这个分发表把“当前状态 事件”和“处理函数”对应起来。以后新增一个状态如果状态名写错TypeScript 会立刻报错如果要检查某个状态是否允许某个事件也只需要看表结构不需要追踪十几个if/else。这里有一个取舍transitions使用了Partial意味着某个事件下没有覆盖全部状态不会编译报错。如果你的流程要求“每个事件必须定义所有合法状态”可以把类型写得更严格不过业务上通常只需要覆盖允许的状态组合所以Partial更实用。4.6 运行与验证在src/index.ts中写一个简单的运行示例import { createApproval, dispatch, autoHandle } from ./workflow; const approval createApproval({ id: A-2024-001, title: 购买开发机, owner: zhangsan, amount: 12800, createdAt: Date.now(), }); console.log(创建后, approval); const submitted dispatch(approval, SUBMIT); console.log(提交后, submitted); const afterAutoHandle autoHandle(submitted); console.log(自动处理后, afterAutoHandle); try { dispatch(afterAutoHandle, SUBMIT); } catch (err) { console.log(预期异常, (err as Error).message); }运行npm start输出大致如下创建后 { id: A-2024-001, title: 购买开发机, owner: zhangsan, amount: 12800, createdAt: 1710000000000, state: draft } 提交后 { id: A-2024-001, title: 购买开发机, owner: zhangsan, amount: 12800, createdAt: 1710000000000, state: submitted, submittedAt: 1710000000001, needManager: true } 自动处理后 { id: A-2024-001, title: 购买开发机, owner: zhangsan, amount: 12800, createdAt: 1710000000000, state: submitted, submittedAt: 1710000000001, needManager: true } 预期异常 状态 submitted 不允许事件 SUBMIT由于金额是 12800大于 1000needManager为true并且没有超过 48 小时所以自动处理后依然是submitted。这是符合业务规则的。5. 进阶让类型系统帮我们把关5.1 用 Extract 精确约束子状态在 4.4 节中每个动作函数接收的参数类型都写成了Approval然后在函数内部做状态判断。这种写法已经不错但还有一种更精确的方式直接用Extract约束某个函数的参数为特定子状态。import type { Approval, SubmittedApproval } from ./types; type HandlerOfS extends Approval[state] ( approval: ExtractApproval, { state: S }, ) Approval; const submitHandler: HandlerOfdraft (approval) { return { ...approval, state: submitted, submittedAt: Date.now(), needManager: approval.amount 1000, }; }; const approveHandler: HandlerOfsubmitted (approval) { return { ...approval, state: approved, approvedAt: Date.now(), approver: manager, }; };这样在submitHandler内部参数approval直接被收窄为DraftApproval不需要自己写if (approval.state ! draft)的判断。这种方式适合把每个状态动作拆分成独立文件、独立测试的场景。需要注意的是这种精确类型不能在原样塞进 4.5 节的transitions表因为表的 handler 类型是(approval: Approval) Approval。如果要结合使用可以做一个适配层在适配层中先断言状态再调用具体的 handler。5.2 用 never 做穷尽性检查前面已经提过assertNever的写法这里再结合describe函数完整展示一次function assertNever(value: never): never { throw new Error(未知状态${JSON.stringify(value)}); } function describe(approval: Approval): string { switch (approval.state) { case draft: return 草稿${approval.title}; case submitted: return 已提交需要${approval.needManager ? 经理 : 系统}审批; case approved: return 已通过${approval.approver} 于 ${approval.approvedAt}; case rejected: return 已驳回${approval.reason}; case cancelled: return 已取消; default: return assertNever(approval); } }当以后有人往Approval联合类型里新增一个PendingReviewApproval但忘记更新describe时编译期就会出现错误。这是有类型写法带来的最大好处之一状态扩展时编译器会帮我们找出所有遗漏的分支。5.3 用 satisfies 校验配置表除了状态流转条件工作流还经常需要维护一些“状态元信息”比如页面上显示的状态名称、颜色、图标。这类配置表也可以用satisfies做类型约束import type { ApprovalState } from ./types; const stateMeta { draft: { label: 草稿, color: #999 }, submitted: { label: 已提交, color: #f90 }, approved: { label: 已通过, color: #090 }, rejected: { label: 已驳回, color: #c00 }, cancelled: { label: 已取消, color: #666 }, } as const satisfies RecordApprovalState, { label: string; color: string };这里的satisfies表示“配置对象必须符合RecordApprovalState, ...但保留每个字段的字面量类型”。好处是配置表漏掉某个状态时编译期报错。某个状态配置了多余的字段编译期也报错。读取stateMeta.approved.label时类型是精确的字符串字面量已通过而不是宽泛的string。这个技巧在实际项目中非常实用前端根据状态渲染 UI 时经常用到。6. 常见问题与排查思路问题现象常见原因解决思路Type string is not assignable to type ApprovalState调用方用普通字符串赋给状态类型检查状态来源使用类型守卫或在入口做运行时校验switch新增状态后没有编译提示没有写default分支或者default分支没有调用assertNever添加default: return assertNever(approval)在某个状态分支中访问字段报错可辨识联合的判别字段不统一或者字段不在该状态接口中统一使用state作为判别字段并检查联合类型定义后端返回的数据类型与定义不符使用了as Approval或as any绕过了类型检查手写类型守卫或使用zod等运行时校验库所有字段都写成可选代码大量?? 没有按状态拆分接口所有状态共用一个宽接口改为可辨识联合按状态精确建模事件分发表中找不到处理函数新增状态时没有在transitions中补充映射查看transitions表结构补充对应事件和状态的处理函数如果遇到类型报错可以先判断报错是“类型来源问题”还是“类型收窄问题”。前者通常是入口数据没有做运行时校验后者通常是可辨识联合设计不规范。优先保证状态字段是唯一的判别字段并且所有状态接口都继承同一个基础接口。7. 最佳实践与工程建议在实际项目中落地有类型条件工作流我认为下面几条建议很值得参考。第一整个项目统一使用一个判别字段。无论是state、status还是type选一个后就不要再混用。如果历史代码里已经有不同的叫法建议在领域模型层做一次适配不要让“状态字段叫什么”这个问题散落到每个页面和服务里。第二状态变更入口要收敛。比较推荐的做法是提供一个统一的dispatch或transition函数所有状态变更都通过它完成。这样可以集中处理权限校验、日志记录、审计字段更新。如果业务代码可以随便调用approval.state approved那类型系统再强也拦不住运行时直接改对象。第三开发期类型约束和运行时校验要配合使用。TypeScript 类型在编译后会被擦除后端接口返回的数据不会因为 TS 类型就说“我保证有这个字段”。所以在系统边界比如 HTTP 请求入口、数据库读取入口应该用类型守卫或zod之类的方案做运行时校验验完后再当作强类型数据使用。第四条件分支尽量做成规则表。如果业务条件越来越复杂可以考虑把“条件”也抽象成数据。例如定义一个ApprovalRule接口包含优先级、条件函数、动作函数。这样新增规则时不需要改动原有代码只需要在表里添加一条配置。第五每个状态动作函数保持单一职责。一个函数只负责从一个状态迁移到另一个状态不要在一个动作里同时处理提交、审批、驳回。这样单元测试会非常好写每个函数都可以独立覆盖“当前状态不正确”“字段不完整”“条件不满足”等分支。第六生产环境要关注状态流转的并发和幂等。有类型写法解决的是代码层面的安全性但数据库层面的并发控制比如重复提交、重复审批仍然需要靠唯一索引、乐观锁或状态字段的 CAS 更新来保证。不要把类型系统当成唯一防线。8. 总结与下一步这篇文章从最普通的字符串状态和if/else分支讲起逐步介绍了字面量联合类型、可辨识联合、类型守卫、事件分发表、never穷尽检查、satisfies配置校验等有类型写法并用一个审批工作流示例串联了全部知识点。掌握这些内容后你应该能看懂大部分 TypeScript 项目中的状态机设计也能动手把现有散落的流程代码改写成有类型约束的版本。下一步可以继续学习以下几个方向使用zod做运行时数据校验和 TS 类型形成双保险。使用xstate这类状态机库把状态图变成可视化配置。学习领域驱动设计中的值对象和聚合概念把业务流程建模得更清晰。研究函数式编程中的Either、Option类型进一步规范异常分支。遇到新的业务状态时不要急着写if/else。先把状态集合列出来把每个状态的数据字段画出来再让 TypeScript 用类型把它们约束住。你会发现很多低级 bug 在编译阶段就被挡住了线上告警也会安静很多。