用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泛型。它可以从联合类型中提取符合某个结构的子类型。比如Extract<Approval, { 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变成RejectedApproval,TypeScript 会提示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: Omit<Approval, 'state'>): Approval { const { id, title, owner, amount, createdAt } = input; return { id, title, owner, amount, createdAt, state: 'draft' }; }这里createApproval是一个辅助函数,实际项目中通常从数据库读取或由前端传入。接下来写submit:
function 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/else:
if (event === 'SUBMIT') { submit(approval); } else if (event === 'APPROVE') { approve(approval); }这种写法的问题是:当状态和事件增多时,条件分支会越来越长,而且很容易遗漏组合。更优雅的方案是把“事件”和“状态”映射到处理函数,形成一个事件分发表。
type TransitionHandler = (approval: Approval) => Approval; const transitions: Partial< Record<ApprovalEvent, Partial<Record<Approval['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,大于 1000,needManager为true,并且没有超过 48 小时,所以自动处理后依然是submitted。这是符合业务规则的。
5. 进阶:让类型系统帮我们把关
5.1 用 Extract 精确约束子状态
在 4.4 节中,每个动作函数接收的参数类型都写成了Approval,然后在函数内部做状态判断。这种写法已经不错,但还有一种更精确的方式:直接用Extract约束某个函数的参数为特定子状态。
import type { Approval, SubmittedApproval } from './types'; type HandlerOf<S extends Approval['state']> = ( approval: Extract<Approval, { state: S }>, ) => Approval; const submitHandler: HandlerOf<'draft'> = (approval) => { return { ...approval, state: 'submitted', submittedAt: Date.now(), needManager: approval.amount > 1000, }; }; const approveHandler: HandlerOf<'submitted'> = (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 Record<ApprovalState, { label: string; color: string }>;这里的satisfies表示“配置对象必须符合Record<ApprovalState, ...>,但保留每个字段的字面量类型”。好处是:
- 配置表漏掉某个状态时,编译期报错。
- 某个状态配置了多余的字段,编译期也报错。
- 读取
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 在编译阶段就被挡住了,线上告警也会安静很多。
