状态机(State Machine)是前端和后端开发中无处不在的模式——订单流转、表单步骤、WebSocket 连接生命周期、权限状态管理,几乎每个复杂业务逻辑的底层都是一个隐式状态机。但 90% 的开发者用 string 联合类型 + if-else 来管理状态,非法状态转换只会在运行时才暴露。TypeScript 的类型系统足够强大,能在编译期就拦截所有非法状态转换,实现零运行时开销的状态安全。 本文将手把手教你用条件类型、模板字面量类型和递归类型推导,构建一个生产级的类型级状态机。
🔐 一、为什么需要类型级状态机?
1.1 运行时状态机的痛点
大多数项目的状态管理长这样:
// ❌ 错误写法:用 string 管理状态,编译器无法帮你检查
type OrderStatus = string;
function transition(status: OrderStatus, action: string): OrderStatus {
if (status === 'pending' && action === 'pay') return 'paid';
if (status === 'paid' && action === 'ship') return 'shipped';
// 忘记处理 shipped → delivered?编译器不会提醒你
// 非法转换 paid → pending?运行时才会发现
throw new Error(`Invalid transition: ${status} -> ${action}`);
}
即使用联合类型稍微改进,问题依然存在:
// ⚠️ 聊胜于无:联合类型只能约束「状态值合法」,不能约束「转换合法」
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
function transition(status: OrderStatus, action: string): OrderStatus {
// 编译器不知道 'paid' + 'cancel' 是否合法
// 编译器不知道 'delivered' + 'ship' 是非法的
// 一切都要靠开发者自己记住转换规则
}
⚠️ **警告:**当状态超过 5 个、转换规则超过 10 条时,靠人脑记忆状态转换表是不可靠的。生产环境中,一个遗漏的非法状态转换可能导致订单金额错误、权限泄露、数据不一致。
1.2 类型级状态机能解决什么
类型级状态机的核心思想是:把状态转换表编码到类型系统中,让 TypeScript 编译器在编译期检查每一次状态转换的合法性。
// ✅ 正确写法:编译期检查状态转换
const order = createOrder(); // 状态: pending
const paid = order.pay(); // ✅ 合法:pending → paid
const shipped = paid.ship(); // ✅ 合法:paid → shipped
// const cancelled = shipped.cancel(); // ❌ 编译报错!shipped 不能 cancel
// const doublePay = paid.pay(); // ❌ 编译报错!paid 不能再次 pay
💡 **提示:**类型级状态机和 XState 不是替代关系。XState 是运行时状态机引擎,提供可视化、副作用管理等能力;类型级状态机是编译期安全网,提供零成本的类型检查。两者可以组合使用。
1.3 核心原理预览
实现类型级状态机需要三个 TypeScript 高级类型能力:
| 能力 | 作用 | 示例 |
|---|---|---|
| 条件类型(Conditional Types) | 根据输入类型选择输出类型 | T extends 'a' ? 'b' : never |
| 映射类型(Mapped Types) | 批量转换对象类型的属性 | { [K in Keys]: Transform<K> } |
| 模板字面量类型(Template Literal Types) | 字符串级别的类型运算 | `on${Capitalize<S>}` |
🛠️ 二、从零构建类型级状态机
2.1 第一步:定义状态转换表
我们以一个真实的「文档审批流」为例,定义状态和合法转换:
// 文档审批流的状态定义
type DocState = 'draft' | 'reviewing' | 'approved' | 'rejected' | 'published';
// 状态转换表:每个状态可以转换到哪些状态
// 这个类型就是「规则」——所有非法转换都会在编译期报错
type DocTransitions = {
draft: 'reviewing'; // 草稿 → 提审
reviewing: 'approved' | 'rejected'; // 审核中 → 通过 | 驳回
approved: 'published' | 'draft'; // 已通过 → 发布 | 退回草稿
rejected: 'draft'; // 已驳回 → 退回草稿
published: 'draft'; // 已发布 → 退回草稿(修订)
};
📌 **记住:**转换表是类型级状态机的核心。它既是类型定义,也是业务文档——任何开发者看到这个类型就能理解整个审批流的规则。
2.2 第二步:实现类型安全的 transition 函数
// 类型安全的状态转换函数
// S extends DocState:当前状态必须是合法状态
// DocTransitions[S]:根据当前状态 S,自动推导出合法的目标状态集合
function transition<S extends DocState>(
currentState: S,
nextState: DocTransitions[S] // 关键:编译期检查 nextState 是否合法
): DocTransitions[S] {
console.log(`状态转换: ${currentState} → ${nextState}`);
return nextState;
}
// ✅ 合法转换 — 编译通过
const draft = 'draft' as const;
const reviewing = transition(draft, 'reviewing'); // ✅ draft → reviewing
const approved = transition(reviewing, 'approved'); // ✅ reviewing → approved
const published = transition(approved, 'published'); // ✅ approved → published
// ❌ 非法转换 — 编译报错!
// transition(draft, 'approved'); // Error: 'approved' is not assignable to 'reviewing'
// transition(published, 'approved'); // Error: 'approved' is not assignable to 'draft'
// transition(reviewing, 'published'); // Error: 'published' is not assignable to 'approved' | 'rejected'
这个实现的核心是泛型约束 S extends DocState 和索引访问类型 DocTransitions[S]。TypeScript 会根据传入的 currentState 的具体类型,在编译期精确推导出 nextState 的合法值。
2.3 第三步:构建状态机类(面向对象封装)
实际项目中,我们通常需要一个封装好的状态机类:
// 通用类型级状态机类
class TypeSafeStateMachine<S extends string, T extends Record<S, string>> {
private state: S;
private history: Array<{ from: S; to: T[S]; timestamp: number }> = [];
constructor(initialState: S) {
this.state = initialState;
}
// 获取当前状态
getState(): S {
return this.state;
}
// 类型安全的状态转换
// N extends T[S]:下一个状态必须是当前状态的合法后继
transition<N extends T[S]>(nextState: N): N {
this.history.push({
from: this.state,
to: nextState,
timestamp: Date.now(),
});
console.log(`[${new Date().toISOString()}] ${this.state} → ${nextState}`);
this.state = nextState as unknown as S;
return nextState;
}
// 检查某个转换是否合法(运行时辅助,类型已经在编译期检查过了)
canTransition(nextState: string): boolean {
// 这里需要运行时的转换表,下面会讲到
return true;
}
// 获取转换历史
getHistory() {
return [...this.history];
}
}
// 使用示例:文档审批流
const doc = new TypeSafeStateMachine<DocState, DocTransitions>('draft');
const s1 = doc.transition('reviewing'); // ✅ 类型: 'reviewing'
const s2 = doc.transition('approved'); // ✅ 类型: 'approved'
const s3 = doc.transition('published'); // ✅ 类型: 'published'
// doc.transition('reviewing'); // ❌ 编译错误:published 只能转到 'draft'
// doc.transition('cancelled'); // ❌ 编译错误:'cancelled' 不在 DocState 中
💡 提示:
transition方法的返回值类型是具体的字面量类型(如'reviewing'),而不是宽泛的DocState。这意味着 TypeScript 能根据返回值精确推导后续操作的合法性——这就是类型级状态机的精髓。
🚀 三、进阶:组合状态机与历史状态回溯
3.1 守卫条件(Guard)的类型安全实现
实际业务中,状态转换通常需要满足额外条件(守卫)。我们可以用泛型约束来实现:
// 带守卫条件的状态机
interface GuardedTransition<S extends string, T extends Record<S, string>> {
from: S;
to: T[S];
guard: (context: any) => boolean;
errorMessage: string;
}
// 类型安全的带守卫转换执行器
function guardedTransition<
S extends string,
T extends Record<S, string>,
From extends S,
To extends T[From]
>(
machine: TypeSafeStateMachine<S, T>,
from: From,
to: To,
context: { userRole: string; amount?: number }
): { success: boolean; error?: string } {
// 守卫条件示例:金额超过 10000 需要 admin 审批
if (to === 'approved' && context.amount && context.amount > 10000) {
if (context.userRole !== 'admin') {
return { success: false, error: '金额超过 10000 需要管理员审批' };
}
}
machine.transition(to);
return { success: true };
}
3.2 事件驱动的类型安全状态机
大多数真实场景不是直接指定目标状态,而是通过「事件」触发转换:
// 定义每个状态可以响应的事件
type DocEvents = {
draft: { type: 'submit' };
reviewing: { type: 'approve'; comment: string } | { type: 'reject'; reason: string };
approved: { type: 'publish' } | { type: 'revise' };
rejected: { type: 'revise' };
published: { type: 'unpublish' };
};
// 事件 → 状态转换的映射
type EventTransition = {
draft: { submit: 'reviewing' };
reviewing: { approve: 'approved'; reject: 'rejected' };
approved: { publish: 'published'; revise: 'draft' };
rejected: { revise: 'draft' };
published: { unpublish: 'draft' };
};
// 类型安全的事件分发器
function dispatch<
S extends DocState,
E extends DocEvents[S]
>(
currentState: S,
event: E
): EventTransition[S][E['type']] {
// 实际的状态转换逻辑
console.log(`[${currentState}] 处理事件: ${event.type}`);
return {} as any; // 实际实现会执行转换
}
// ✅ 合法事件分发
const result1 = dispatch('reviewing', { type: 'approve', comment: 'LGTM' });
// 类型: 'approved'
const result2 = dispatch('approved', { type: 'publish' });
// 类型: 'published'
// ❌ 非法事件 — 编译报错!
// dispatch('published', { type: 'approve', comment: 'xxx' });
// Error: 'approve' 不在 published 的事件列表中
// dispatch('draft', { type: 'reject', reason: 'xxx' });
// Error: 'reject' 不在 draft 的事件列表中
⚠️ **警告:**事件驱动的状态机类型推导会显著增加 TypeScript 编译器的负担。如果状态超过 15 个或事件类型过于复杂,建议将状态机拆分为多个子状态机,或在编译性能和类型安全之间做权衡。
3.3 层次状态机(Hierarchical State Machine)
复杂业务往往需要嵌套状态。例如,订单的「支付中」状态内部还有子状态:
// 层次状态定义
type OrderState =
| 'cart' // 购物车
| 'checkout.payment' // 结算-支付中
| 'checkout.payment.pending' // 结算-支付中-待处理
| 'checkout.payment.processing' // 结算-支付中-处理中
| 'checkout.payment.failed' // 结算-支付中-失败
| 'fulfilling' // 履约中
| 'delivered'; // 已送达
// 用模板字面量类型提取父状态
type ParentState<S extends string> =
S extends `${infer Parent}.${string}` ? Parent : never;
// 测试
type Test1 = ParentState<'checkout.payment.pending'>; // 'checkout.payment'
type Test2 = ParentState<'checkout.payment'>; // 'checkout'
type Test3 = ParentState<'cart'>; // never
// 判断是否是子状态
type IsChildState<S extends string> =
S extends `${string}.${string}` ? true : false;
type Test4 = IsChildState<'checkout.payment.pending'>; // true
type Test5 = IsChildState<'cart'>; // false
这个模式在处理复杂业务流程(电商订单、银行交易、审批流)时非常有用。模板字面量类型让我们能在类型层面解析和操作状态路径。
📊 四、类型级状态机 vs XState:深度对比
很多开发者会问:既然有 XState,为什么还需要类型级状态机?答案是两者解决不同层面的问题。
| 维度 | 类型级状态机 | XState |
|---|---|---|
| 检查时机 | 编译期(零运行时开销) | 运行时 |
| 性能影响 | 零(编译后完全擦除) | 有(状态机引擎 + 解释器) |
| 包体积 | 0 KB(纯类型,编译后不存在) | ~15 KB(minified) |
| 可视化 | ❌ 不支持 | ✅ Stately 可视化编辑器 |
| 副作用管理 | ❌ 需要自己实现 | ✅ 内置 Actor 模型 |
| 并行状态 | ❌ 极难实现 | ✅ 原生支持 |
| 历史状态 | 需要自己实现 | ✅ 内置 history 状态 |
| 适用场景 | 状态少、转换规则明确 | 状态多、逻辑复杂、需要可视化 |
| 学习曲线 | 高(需要理解高级类型) | 中(API 直观) |
⚡ **关键结论:**类型级状态机适合「状态少但转换规则严格」的场景(如 5-10 个状态的审批流、表单步骤)。XState 适合「状态多、逻辑复杂、需要可视化」的场景。最佳实践是两者结合:用类型级状态机做编译期安全网,用 XState 做运行时状态管理。
组合使用的最佳实践
import { createMachine, assign } from 'xstate';
// 1. 先定义类型级转换表(编译期安全)
type OrderTransitions = {
pending: 'paid' | 'cancelled';
paid: 'shipped' | 'refunded';
shipped: 'delivered';
delivered: 'returned';
cancelled: never; // 终态
refunded: never; // 终态
returned: never; // 终态
};
// 2. 用类型约束 XState 的配置(运行时引擎)
function createTypedMachine<S extends string, T extends Record<S, string>>(
config: { id: string; initial: S; states: Record<S, any> }
) {
return createMachine(config);
}
// 3. XState 负责运行时的副作用、历史、可视化
// TypeScript 类型负责编译期的转换合法性检查
⚠️ 五、避坑指南与性能考量
5.1 编译性能陷阱
类型级状态机的最大敌人是 TypeScript 编译器的性能。递归条件类型和深层嵌套的映射类型会导致编译时间指数级增长。
// ❌ 避免:无限递归的类型
type DeepTransition<S extends string, Depth extends number[] = []> =
Depth['length'] extends 10 ? S : DeepTransition<S, [...Depth, 0]>;
// ✅ 推荐:限制递归深度
type SafeTransition<S extends string, MaxDepth extends number = 5> =
// 使用尾递归优化,TypeScript 4.5+ 支持
S extends `${infer Head}.${infer Tail}`
? `${Head}.${SafeTransition<Tail>}`
: S;
⚠️ **警告:**如果状态超过 20 个或转换规则超过 50 条,类型级状态机的编译时间可能从毫秒级飙升到秒级。此时建议拆分为多个子状态机,或降级为运行时检查。
5.2 调试类型错误
类型级状态机的错误信息通常非常难以阅读:
// 当你看到这样的错误时...
// Type '"shipped"' is not assignable to type '"reviewing" | "approved"'
// 这说明:你试图从 draft 转换到 shipped,但 draft 只能转换到 reviewing
调试技巧:
// 技巧 1:用 Hover 查看推导结果
type DebugTransition = DocTransitions['draft'];
// Hover 显示: 'reviewing'
// 技巧 2:用 conditional type 做运行时断言
function assertTransition<S extends DocState>(
from: S,
to: DocTransitions[S]
): void {
// 编译期已经保证了合法性,运行时断言只是为了调试
console.assert(true, `合法转换: ${from} → ${to}`);
}
// 技巧 3:用 satisfies 关键字验证类型(TypeScript 5.0+)
const transitions = {
draft: 'reviewing',
reviewing: 'approved',
} satisfies Partial<Record<DocState, DocState>>;
5.3 与 ORM/数据库状态的集成
实际项目中,状态通常存储在数据库里。类型级状态机需要与数据库层集成:
// Drizzle ORM 的类型安全状态字段
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
const orders = pgTable('orders', {
id: text('id').primaryKey(),
status: text('status', {
enum: ['pending', 'paid', 'shipped', 'delivered', 'cancelled'],
}).notNull().default('pending'),
updatedAt: timestamp('updated_at').notNull(),
});
// 从数据库读取的状态是 string,需要类型窄化
function narrowState(raw: string): DocState {
const validStates: DocState[] = ['draft', 'reviewing', 'approved', 'rejected', 'published'];
if (validStates.includes(raw as DocState)) {
return raw as DocState;
}
throw new Error(`Invalid state: ${raw}`);
}
💡 **提示:**从数据库或 API 读取的状态值是
string类型,需要通过类型窄化(Type Narrowing)转换为具体的字面量类型。永远不要直接用as断言,要用运行时验证来保证类型安全。
✅ 六、总结与最佳实践
类型级状态机是 TypeScript 类型系统的高级应用,它把「状态转换规则」从运行时检查提升到编译期检查,实现了真正的零成本类型安全。
适用场景:
- ✅ 状态数量 ≤ 15,转换规则明确且严格
- ✅ 需要在编译期防止非法状态转换
- ✅ 不想引入 XState 等运行时依赖
- ✅ 团队有较强的 TypeScript 类型体操能力
不适用场景:
- ❌ 状态数量 > 20,转换规则频繁变更
- ❌ 需要并行状态、历史回溯、可视化
- ❌ 团队 TypeScript 水平参差不齐
- ❌ 编译性能敏感(大型 monorepo)
核心建议:
- 从简单开始 — 先用联合类型 + 索引访问类型实现基础版本,再逐步引入条件类型和模板字面量类型
- 转换表即文档 — 让类型定义成为状态转换的唯一真相来源(Single Source of Truth)
- 限制复杂度 — 如果类型推导超过 3 层嵌套,考虑拆分
- 配合运行时 — 类型级状态机解决编译期安全,运行时仍需要日志、监控和错误处理
⚡ **关键结论:**TypeScript 的类型系统不只是「给变量加类型标注」那么简单。条件类型、模板字面量类型和递归类型推导的组合,让我们能在编译期实现复杂的业务逻辑验证。类型级状态机是一个典型的应用场景——它证明了「类型即文档,类型即测试,类型即安全」的工程理念。