条件工作流是开发过程中最常见、也最容易失控的一种逻辑形态。一开始你可能只是在业务代码里加一个分支判断,几个月之后,那段代码就会变成一串没人敢动的字符串状态和 if-else 嵌套。这篇文章想讲清楚一个判断:条件工作流的复杂度从来不在“流程图画得多漂亮”,而在“状态流转的约束是否被编译器兜住”。把散落的字符串状态换成字面量联合类型,把分支条件收敛成带签名的守卫函数,这种写法就叫“有类型写法”。
读完本文,你会理解条件工作流为什么容易腐化,也能拿到一份完整可运行的 TypeScript 示例,在内容审核发布、订单流转、Agent 工具链选择等场景中直接复用这套思路。
1. 这篇文章真正要解决的问题
很多开发者在项目中会天然地实现“条件工作流”:用户提交内容后,系统根据内容长度、敏感词命中结果、审核人操作来决定下一步。状态可能是draft、submitted、approved、rejected、published,流转条件是若干if判断。这种写法在节点少的时候非常直观,一旦节点增多,问题就会集中爆发。
最典型的场景有三类:
第一类是内容平台的内容审核流程。一条内容要经过草稿、提交、审核、驳回、发布,中间还有重新编辑和重复提交。如果不加约束,状态与状态之间可以任意跳转,draft直接变published这种非法路径在运行时才暴露。
第二类是电商或后端系统的订单流转。待支付、已支付、已发货、已签收、已退款,不同状态对参数有完全不同的要求。无类型写法里,状态是字符串,参数是Map<String, Object>,任何状态都能携带任何参数,编译器完全帮不上忙。
第三类是 Agent 或 LLM 应用中的任务编排。工具调用、条件分支、人工确认,任何一个环节写错条件,整个流程都会走偏,而且错误往往要到真实任务执行时才出现。
这三类场景的共同痛点是:状态本身是分散的字符串,流转条件没有类型签名,错误发生的时间点被推迟到了运行时。有类型写法做的事情,就是把这三种风险全部提前到编译期。
所以这篇文章适合的读者很明确:正在用 TypeScript、Java 或其他静态类型语言编写审批流、状态机、流程编排、Agent 工具链的开发者,以及维护过复杂 if-else 流程、想找一种可维护方案的技术负责人。
2. 条件工作流的基础概念与常见误区
2.1 什么是条件工作流
条件工作流可以拆成两个词理解:工作流是一组节点和节点间的转移关系,条件则决定了转移是否发生。节点在业务里叫“状态”,转移在代码里通常体现为“状态变更”,条件是“守卫函数”。
举个例子,一个简化版的内容审核流程:
- 状态:草稿、待审核、已通过、已驳回、已发布
- 事件:提交审核、审核通过、审核驳回、发布
- 条件:内容长度是否达标、审核结论是否为通过
用伪代码描述一次提交动作:
if (content.length >= 20 && status === 'draft') { status = 'submitted'; }这个逻辑本身不复杂,但它把“状态判断”和“业务条件”混在一起,并且status只是一个字符串。如果项目里同时有十几个这样的判断,代码就会变成一张谁也看不懂的状态蜘蛛网。
2.2 无类型写法的典型代码
很多项目最初的工作流代码长这样:
type Status = string; interface ReviewContext { contentId: string; content: string; approveResult?: boolean; } function canSubmit(ctx: ReviewContext, status: Status): boolean { return status === 'draft' && ctx.content.trim().length >= 20; } function nextStatus(status: Status, ctx: ReviewContext): Status { if (status === 'draft' && canSubmit(ctx, status)) { return 'submitted'; } if (status === 'submitted' && ctx.approveResult === true) { return 'approved'; } if (status === 'submitted' && ctx.approveResult === false) { return 'rejected'; } return status; }这段代码的优点是短,缺点是三个层面都缺乏保护:
Status是string,写错一个字母,比如'sumitted',编译器完全无感。nextStatus可以返回任意字符串,draft到published也能通过编译。ctx.approveResult是一个可选字段,submitted状态下读取它没问题,但draft状态下也能读到,类型系统无法表达“某些字段只在某状态下存在”。
这些缺陷在代码量小的时候不算致命,但随着分支增加,每次重构都要靠人肉搜索字符串,每次上线都要祈祷没有漏掉某个状态组合。
2.3 三个常见误区
误区一:状态只是一个字符串。状态是流程模型的骨架,应该用类型把它约束起来,而不是放任它变成任意字符串。
误区二:条件只是 if-else。条件函数应该是流程模型的一等公民,它有明确的输入和输出,也应该有明确的类型签名。
误区三:类型只用于数据建模,不用于流程建模。类型系统不仅能描述“一个对象有哪些字段”,还能描述“一个流程允许哪些跳转、每个跳转需要什么参数”。
| 维度 | 无类型写法 | 有类型写法 |
|---|---|---|
| 状态定义 | type Status = string | 'draft' | 'submitted' | ... |
| 非法跳转 | 运行时才发现 | 编译期直接报错 |
| 条件参数 | 松散、任意 | 函数签名强约束 |
| 新增状态 | 引发连锁 if 修改 | 编译器提示所有分支 |
| 重构安全性 | 依赖人工排查 | 编译器兜底 |
3. 有类型写法的核心思路
有类型写法的本质,是把流程模型变成编译器可理解的约束。具体来说有四层思路。
3.1 用字面量联合类型约束状态集合
第一步是让状态从一个无限字符串集合缩小到一个有限联合类型:
export type WorkflowStatus = | 'draft' | 'submitted' | 'approved' | 'rejected' | 'published';从此之后,任何WorkflowStatus类型的变量只能取这五个值。写错拼写,编译器会立刻报错。
3.2 用映射类型约束合法跳转
光是约束状态集合还不够,还需要约束“从 A 状态能跳到哪些状态”。在 TypeScript 里可以用一个映射接口表达:
export interface TransitionMap { draft: 'submitted'; submitted: 'approved' | 'rejected'; approved: 'published'; rejected: 'draft' | 'submitted'; published: never; }never表示该状态没有任何合法去向。这样,draft只能跳到submitted,approved只能跳到published,所有非法跳转都会在编译期被拒绝。
3.3 用泛型守卫函数把条件签名化
条件不再是散落的 if 判断,而是一个有明确签名的守卫函数:
export type Condition<Ctx> = (ctx: Ctx) => boolean;流转规则把“来源状态、目标状态、守卫条件”绑定在一起:
export interface TransitionRule< S extends keyof TransitionMap, Ctx > { from: S; to: TransitionMap[S]; condition: Condition<Ctx>; description?: string; }这里的关键在于:to的类型取决于from。如果from是'draft',那么to只能是'submitted',想写成'published'都过不了编译。
3.4 用穷尽性检查兜底
当状态集合扩展时,所有处理状态的switch都应该有一个兜底分支,调用一个never函数:
export function assertNever(value: never): never { throw new Error(`非法状态: ${JSON.stringify(value)}`); }这样做的好处是:以后新增一个状态,switch中如果没有新增对应case,编译器会通过assertNever的参数类型不匹配来提醒开发者。流程模型扩展时,所有需要修改的地方都会被编译器标出来。
这四层思路合在一起,流程错误就从“运行时猜谜”变成了“编译期提示”。这就是有类型写法最核心的价值。
4. 环境准备与前置条件
本文示例使用 TypeScript,需要 Node.js 环境和 TypeScript 编译器。建议使用 Node.js 的长期支持版本,TypeScript 使用 5.x 及以上版本,具体小版本以安装时为准,示例代码不依赖特定小版本特性。
创建项目并安装依赖:
mkdir typed-workflow cd typed-workflow npm init -y npm install typescript @types/node --save-dev初始化 TypeScript 配置:
npx tsc --init将tsconfig.json核心配置调整如下:
{ "compilerOptions": { "target": "ES2020", "module": "CommonJS", "rootDir": "src", "outDir": "dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src"] }strict必须开启。有类型写法的价值建立在严格类型检查的基础上,如果关闭strict,undefined、空值、联合类型相关的保护都会失效。
在package.json中添加脚本:
{ "scripts": { "build": "tsc", "start": "npm run build && node dist/demo.js" } }目录结构规划如下:
typed-workflow/ ├── src/ │ ├── types.ts │ ├── conditions.ts │ ├── engine.ts │ └── demo.ts ├── package.json └── tsconfig.json5. 完整示例:内容审核发布工作流
示例场景是一条内容从草稿到发布的完整审核流程,包含提交、审核通过、审核驳回、驳回后重新提交、最终发布五个节点。状态定义、流转规则、业务条件、执行引擎分离在四个文件中。
5.1 定义状态与上下文
文件路径:src/types.ts
// 状态集合:程序里只有这五种状态是合法的 export type WorkflowStatus = | 'draft' | 'submitted' | 'approved' | 'rejected' | 'published'; // 合法跳转表:key 是来源状态,value 是允许的目标状态集合 export interface TransitionMap { draft: 'submitted'; submitted: 'approved' | 'rejected'; approved: 'published'; rejected: 'draft' | 'submitted'; published: never; } // 流程上下文:存储内容信息和审核结果 export interface ReviewContext { contentId: string; author: string; content: string; submittedAt?: string; reviewedBy?: string; approveResult?: boolean; publishAt?: string; } // 守卫条件:接收上下文,返回是否允许流转 export type Condition<Ctx> = (ctx: Ctx) => boolean; // 流转规则:从哪个状态来、到哪个状态去、满足什么条件 export interface TransitionRule< S extends keyof TransitionMap, Ctx extends ReviewContext > { from: S; to: TransitionMap[S]; condition: Condition<Ctx>; description?: string; } // 穷尽性检查兜底函数 export function assertNever(value: never): never { throw new Error(`非法状态: ${JSON.stringify(value)}`); } // 状态的中文标签,用于日志输出 export function getStatusLabel(status: WorkflowStatus): string { switch (status) { case 'draft': return '草稿'; case 'submitted': return '待审核'; case 'approved': return '已通过'; case 'rejected': return '已驳回'; case 'published': return '已发布'; default: return assertNever(status); } }TransitionMap是这套设计的关键。它描述了流程层面的业务规则,比如draft只能去submitted,rejected可以退回draft,也可以重新提交到submitted。状态流转规则集中维护在这一个接口里,后续新增状态或调整流程,只需要修改这一处。
getStatusLabel中的default分支使用了assertNever。如果以后在WorkflowStatus中增加一个新状态,而这里没有补充对应case,assertNever(status)的调用就会触发编译错误,因为此时status的类型不再是never。
5.2 定义业务条件
文件路径:src/conditions.ts
import { Condition, ReviewContext } from './types'; // 内容达到一定长度后,才允许提交审核 export const hasEnoughContent: Condition<ReviewContext> = (ctx) => { return ctx.content.trim().length >= 20; }; // 审核结论为通过 export const isApproved: Condition<ReviewContext> = (ctx) => { return ctx.approveResult === true; }; // 审核结论为驳回 export const isRejected: Condition<ReviewContext> = (ctx) => { return ctx.approveResult === false; }; // 审核通过后,默认允许发布 export const canPublish: Condition<ReviewContext> = () => { return true; };条件函数全部是纯函数,不修改上下文,只根据输入返回布尔值。这样做的好处是方便单元测试:给一个确定上下文,必然得到确定结果。canPublish虽然直接返回true,但在真实项目中可以在这里加入发布窗口时间、敏感词复核等逻辑,而不需要改动引擎。
5.3 实现类型安全执行引擎
文件路径:src/engine.ts
import { ReviewContext, TransitionMap, TransitionRule, WorkflowStatus } from './types'; export class WorkflowEngine<Ctx extends ReviewContext> { private rules: Array< TransitionRule<keyof TransitionMap, Ctx> > = []; // 添加流转规则,S 会根据 from 字段被自动推断 addRule<S extends keyof TransitionMap>( rule: TransitionRule<S, Ctx> ): void { this.rules.push(rule); } // 执行一次状态流转:遍历所有规则,找到来源匹配且条件满足的规则 run(current: WorkflowStatus, ctx: Ctx): WorkflowStatus { for (const rule of this.rules) { if (rule.from === current && rule.condition(ctx)) { this.logTransition(current, rule.to, rule.description); return rule.to; } } return current; } private logTransition( from: WorkflowStatus, to: WorkflowStatus, description?: string ): void { const reason = description ?? '无描述'; console.log(`[流转] ${from} -> ${to}, 原因: ${reason}`); } }引擎本身很短,但它包含了重要的类型约束:addRule的from和to必须在同一个规则内匹配。当调用方写from: 'draft'时,to的类型会自动收窄为'submitted',这在编译期就杜绝了“草稿直接发布”这类非法流转。
run方法按注册顺序遍历规则,因此规则的注册顺序也是一种配置。在条件互斥的情况下,顺序不影响结果;如果条件可能同时满足,则需要明确规则的优先级。
5.4 组装工作流并执行
文件路径:src/demo.ts
import { WorkflowEngine } from './engine'; import { ReviewContext, WorkflowStatus } from './types'; import { hasEnoughContent, isApproved, isRejected, canPublish } from './conditions'; const ctx: ReviewContext = { contentId: 'post-001', author: 'zhangsan', content: '这是一篇关于条件工作流有类型写法的技术分享文章。', submittedAt: new Date().toISOString(), approveResult: true }; const engine = new WorkflowEngine<ReviewContext>(); engine.addRule({ from: 'draft', to: 'submitted', condition: hasEnoughContent, description: '内容长度达到要求,提交审核' }); engine.addRule({ from: 'submitted', to: 'approved', condition: isApproved, description: '审核通过' }); engine.addRule({ from: 'submitted', to: 'rejected', condition: isRejected, description: '审核驳回' }); engine.addRule({ from: 'approved', to: 'published', condition: canPublish, description: '允许发布' }); let status: WorkflowStatus = 'draft'; console.log(`初始状态: ${status}`); status = engine.run(status, ctx); console.log(`当前状态: ${status}`); status = engine.run(status, ctx); console.log(`当前状态: ${status}`); status = engine.run(status, ctx); console.log(`当前状态: ${status}`);这里有意没有注册rejected到draft的规则,因为示例中审核结论是true,流程走的是通过分支。把approveResult改为false,流程就会停在rejected状态,方便观察条件分支效果。
如果尝试注册一条非法规则,比如从draft直接跳到published:
// 这行代码无法通过编译: // Type '"published"' is not assignable to type '"submitted"' engine.addRule({ from: 'draft', to: 'published', condition: hasEnoughContent });类型系统会直接拦截这种错误,而不是等程序运行到某一步才崩溃。
6. 运行结果与效果验证
6.1 编译运行
在项目根目录执行:
npm start程序会先执行tsc编译,再运行编译产物。预期输出如下:
初始状态: draft [流转] draft -> submitted, 原因: 内容长度达到要求,提交审核 当前状态: submitted [流转] submitted -> approved, 原因: 审核通过 当前状态: approved [流转] approved -> published, 原因: 允许发布 当前状态: published说明整条正常路径已经跑通。值得注意的是,[流转]日志说明run每执行一次只推进一个状态,调用三次后流程到达终点published。
6.2 验证非法流转被编译拦截
把demo.ts中合法的approved到published规则改成draft到published:
engine.addRule({ from: 'draft', to: 'published', condition: hasEnoughContent, description: '非法规则' });重新执行npm start,编译阶段就会报错:
src/demo.ts:36:7 - error TS2322: Type '"published"' is not assignable to type '"submitted"'.这个报错比任何运行时日志都值钱:错误发生在开发环境,而不是生产环境。这就是有类型写法最直观的效果验证。
6.3 验证逻辑正确性
除了编译通过,还可以做一个“分支切换”实验。把ctx.approveResult改为false:
const ctx: ReviewContext = { contentId: 'post-002', author: 'lisi', content: '这是一篇会被驳回的内容,用于验证审核不通过分支。', submittedAt: new Date().toISOString(), approveResult: false };再次运行,预期输出:
初始状态: draft [流转] draft -> submitted, 原因: 内容长度达到要求,提交审核 当前状态: submitted [流转] submitted -> rejected, 原因: 审核驳回 当前状态: rejected流程正确走到了rejected,因为isApproved返回false,而isRejected返回true。这说明条件守卫在运行时的逻辑也是正确的,类型安全没有牺牲业务灵活性。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 类型报错信息复杂,难以定位 | TransitionMap映射关系过深,TS 提示包含多层推断 | 先看报错的第一行,定位具体文件与行号 | 把复杂规则拆成独立函数,或使用TransitionRule显式标注类型 |
| 条件需要访问异步接口 | Condition是同步函数,无法直接等待请求结果 | 检查守卫函数是否有异步操作 | 在进入run之前先异步加载数据并写入ReviewContext,保持条件同步 |
| 驳回后再次提交流程不一致 | rejected状态没有注册后续规则 | 打印当前状态,观察停在哪个节点 | 为rejected补上draft或submitted的合法跳转规则 |
| 团队不熟悉类型写法,维护困难 | 项目其他部分仍在使用无类型模式 | 从状态定义和规则表开始讲解 | 渐进式改造,先替换字符串状态,再逐步引入条件守卫 |
| 运行时规则与类型定义不一致 | 引擎规则在运行时被动态修改,绕过了类型约束 | 检查代码中是否有as强制类型转换 | 增加不可变规则设计,禁止运行时修改规则列表 |
其中最容易踩坑的是第一条。TypeScript 的类型推断在泛型嵌套时会产生很长的报错信息,但只要把from类型写清楚,绝大多数错误都会在addRule调用处直接暴露为“目标状态不匹配”,可读性已经比无类型写法好很多。
异步条件问题在真实项目中非常常见。比如审核要调用远程敏感词服务,服务返回结果后才会决定是否通过。这套模型的处理方式是:把异步调用放在流程引擎之外,先把服务结果写入上下文,再执行run。条件函数保持纯同步,既方便测试,也简化引擎实现。
驳回后的回退路径是一道典型业务设计题。示例中把rejected的合法去向设为draft和submitted,意味着作者可以退回草稿重新修改,也可以直接再次提交。很多团队在早期会把rejected直接指向submitted,这样省去了一次草稿编辑,但作者无法修正内容。具体选择由业务决定,类型表只需要如实反映业务规则即可。
8. 最佳实践与工程建议
8.1 从状态表出发,而不是从代码出发
在写任何类型之前,先画一张状态转移表。表头是当前状态,表格内容是合法目标状态,单元格里写清楚触发条件。这张表既是业务文档,也是TransitionMap的蓝本。示例中的TransitionMap就是状态表直接翻译成代码。
8.2 不要让条件函数产生副作用
条件函数只做判断,不做修改。不要在守卫函数里写日志、发通知、变更上下文。原因有两个:一是守卫函数可能被多次调用,副作用会被重复执行;二是纯函数更容易测试和复用。如果需要记录审计日志,放在引擎的run方法或addRule的description中实现。
8.3 用穷尽性检查兜住所有 switch
所有处理WorkflowStatus的switch都加上default分支并调用assertNever。以后扩展状态,编译器会强制找出所有漏改的分支。这是有类型写法最容易忽视、但价值极高的一步。
8.4 先替换字符串常量,再引入守卫函数
如果项目里已经有大量字符串状态代码,不要试图一次性重写。第一步把type Status = string改成字面量联合类型,让编译器把所有不受控的字符串赋值暴露出来。第二步把status === 'draft'这类散落判断收敛成一个条件常量或条件函数。第三步再引入TransitionRule。渐进式改造可以显著降低迁移风险。
8.5 规则列表保持不可变
引擎的rules数组在初始化后不应被外部追加或删除。生产阶段可以把规则注册收敛到一个配置方法里,避免运行时被业务代码动态修改。示例中的addRule暴露了修改入口,在工程化时可以替换为构造函数注入规则列表。
8.6 类型定义和业务文档放一起维护
TransitionMap和业务流程图是一体两面。建议在类型文件头部注释里保留一份状态表,或者将类型文件放在业务领域目录中,而不是放在通用types目录。否则会出现文档和代码分叉,业务人员看文档,开发人员看类型,两边对不上。
8.7 善用日志追踪流转路径
每条规则都提供description,引擎每次跳转都打印来源、去向和原因。这能显著降低生产环境的排查成本:拿到一条日志就能知道某个状态为什么发生跳转,以及跳到了哪里。真实项目中还可以把流转记录写入数据库或消息队列,构建完整审计链。
8.8 复杂并发场景考虑成熟状态机库
手写引擎适合流程节点有限、并发不高的场景。如果流程中包含并行分支、子流程、超时重试、多实例并发,手写实现会明显复杂化。这时候可以考虑引入 XState、Spring StateMachine 等成熟状态机框架,本文的类型设计思路仍然适用,只是底层的调度能力交给了更成熟的实现。
9. 总结与后续学习方向
条件工作流的有类型写法,核心不是消灭 if-else,而是把运行时才暴露的错误提前到编译期。字面量联合类型约束了状态集合,映射类型约束了合法跳转,泛型守卫函数让条件有了明确签名,穷尽性检查让未来扩展不至于漏改分支。这套组合让流程代码在重构时可以放心修改,因为编译器会告诉你哪里遗漏了。
示例项目虽然只有四个文件,但内容审核、订单流转、审批流这些常见场景都能按同一套模式落地。下一步你可以做三件事:把手头项目里最频繁出现的字符串状态替换成联合类型;为每个状态转移补上守卫条件函数;在关键switch分支里加上assertNever兜底。三件事做完,流程的可维护性会有明显变化。
如果你对状态机本身感兴趣,可以继续研究 XState 的建模思想、Java 的 sealed class 模式匹配,以及领域驱动设计中对状态流转的建模方式。类型系统只是工具,真正重要的是把流程模型变成团队都能理解、编译器都能验证的明确约束。