状态机几乎无处不在:订单流转、支付回调、请求生命周期、登录会话等。要把这些逻辑写清楚,通常需要一张状态转移表,然后在代码里用 if-else 或 switch 去实现。问题在于,这张表格很容易只停留在文档或注释中,时间一久代码和文档就开始脱节。TypeScript 的类型级编程可以把这张表直接搬进类型系统,让编译器充当一致性检查器。通过可辨识联合先描述状态,再用映射类型和条件类型描述事件与目标状态的关系,最终可以得到一个在编码阶段就拒绝非法状态转换的模型。接下来本文会拆解这套建模方式的实现路径、优势以及代价。

可辨识联合:状态机建模的起点
可辨识联合是 TypeScript 中常见的一种建模手段。给每个状态加一个共同的字面量字段,例如 status,TypeScript 就能在 switch 或条件判断中自动收窄类型。状态机建模的第一步通常就是把所有可能状态列出来,形成一个联合类型。
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success' }
| { status: 'error' };
type Event =
| { type: 'FETCH' }
| { type: 'CANCEL' }
| { type: 'SUCCESS' }
| { type: 'ERROR' }
| { type: 'RETRY' }
| { type: 'RESET' };
事件也可以定义成带 type 字段的联合。transition 函数接收当前状态和事件,返回下一个状态。得益于可辨识联合,在 switch 中对 state.status 进行判断后,TypeScript 能自动收窄 state 的具体成员。于是我们可以按状态分别处理事件,少写很多类型断言。
function transition(state: State, event: Event): State {
switch (state.status) {
case 'idle':
return event.type === 'FETCH' ? { status: 'loading' } : state;
case 'loading':
if (event.type === 'CANCEL') return { status: 'idle' };
if (event.type === 'SUCCESS') return { status: 'success' };
if (event.type === 'ERROR') return { status: 'error' };
return state;
case 'success':
return event.type === 'RESET' ? { status: 'idle' } : state;
case 'error':
if (event.type === 'RETRY') return { status: 'loading' };
return event.type === 'RESET' ? { status: 'idle' } : state;
default: {
const exhaustiveCheck: never = state;
return exhaustiveCheck;
}
}
}
这种实现已经比把 state 和 event 都写成 string 要好很多。至少当事件类型拼错时,编译器会提示。但它的类型安全仍有明显缺口:transition 的签名接受全部 State 和全部 Event。比如在 idle 状态传入 CANCEL 是完全合法的调用,但实际上这个组合没有任何业务含义。如果开发者忘记在 idle 分支里忽略该事件,代码可能返回原状态,掩盖了本应禁止的操作。换句话说,可辨识联合解决了状态内部属性的安全访问,却没有解决状态与事件之间的合法配对问题。
类型级建模:用映射表和条件类型记录合法转移
要约束事件与状态的配对,最直接的办法是定义一张类型层面的转移表。TypeScript 的映射类型和条件类型正好适合干这件事。先不考虑对象状态,改用字符串字面量联合表示状态和事件,简化类型运算。
type State = 'idle' | 'loading' | 'success' | 'error';
type Event = 'FETCH' | 'CANCEL' | 'SUCCESS' | 'ERROR' | 'RETRY' | 'RESET';
type TransitionTable = {
idle: { FETCH: 'loading' };
loading: { CANCEL: 'idle'; SUCCESS: 'success'; ERROR: 'error' };
success: { RESET: 'idle' };
error: { RETRY: 'loading'; RESET: 'idle' };
};
type ValidEvents<S extends State> = keyof TransitionTable[S] & Event;
type NextState<S extends State, E extends ValidEvents<S>> =
TransitionTable[S][E];
TransitionTable 的结构非常像状态图中的邻接表。每个状态作为外层键,值是对象,键为该状态下可接受的事件,值为目标状态。ValidEvents 用 keyof 取出当前状态下所有事件键,再和 Event 取交集,防止额外属性混入。NextState 则通过两层索引访问得到目标状态。这样一张表既是文档,也是编译器可以检查的数据结构。
declare function transition<S extends State, E extends ValidEvents<S>>(
state: S,
event: E
): NextState<S, E>;
const next = transition('idle', 'FETCH'); // 类型为 'loading'
// 编译错误:类型 'CANCEL' 的参数不能赋给类型 'FETCH' 的参数
// transition('idle', 'CANCEL');
注意这里 event 参数的类型是 E extends ValidEvents<S>。调用 transition('idle', 'FETCH') 时,TypeScript 先推断 S 为字面量类型 'idle',然后计算 ValidEvents<'idle'> 得到 'FETCH',再检查传入的 'FETCH' 是否兼容。如果传入 CANCEL,则直接编译报错。这是类型级建模最核心的收益:非法调用在运行前就被拦住。
比手动写 if-else 更强大的是,我们还可以基于 TransitionTable 自动生成事件处理器类型。例如对于某个具体状态,用映射类型遍历 ValidEvents 来要求所有事件都有对应处理函数。这样漏写一个处理器会在类型定义处报错,而不是等到运行时才发现某个事件没有响应。映射类型和 keyof 的组合让状态机逻辑具备可扩展性,添加新状态或事件时,只需要更新 TransitionTable,所有相关的类型约束和函数签名都会自动更新。
type Handlers<S extends State> = {
[E in ValidEvents<S>]: () => NextState<S, E>;
};
const idleHandlers: Handlers<'idle'> = {
FETCH: () => 'loading',
};
const loadingHandlers: Handlers<'loading'> = {
CANCEL: () => 'idle',
SUCCESS: () => 'success',
ERROR: () => 'error',
};
类型级状态机的实际收益:穷尽检查、自动补全与自文档
类型级建模带来的第一个直接收益是穷尽检查。以 ValidEvents 和 NextState 为基础的 transition 函数,在调用侧已经可以过滤大部分非法组合。但在实现侧,我们仍需要处理运行时逻辑。可以配合 switch 和 never 类型做穷尽检查,保证每个状态和事件组合都被覆盖。如果后续新增了一个状态或事件,编译器会立即在 switch 缺分支处报错。
function execute<S extends State, E extends ValidEvents<S>>(
state: S,
event: E
): NextState<S, E> {
const table: TransitionTable = {
idle: { FETCH: 'loading' },
loading: { CANCEL: 'idle', SUCCESS: 'success', ERROR: 'error' },
success: { RESET: 'idle' },
error: { RETRY: 'loading', RESET: 'idle' },
};
const next = table[state][event];
return next as NextState<S, E>;
}
对调用方来说,execute('loading', 'SUCCESS') 返回类型是 'success',execute('loading', 'RESET') 则直接编译错误。自动补全也会比运行时字符串强很多,因为编辑器在输入第二个参数时,能根据第一个参数推断出可用的字面量联合。重构时如果要把事件名 SUCCESS 改成 DONE,只需修改 Event 和 TransitionTable,所有非法调用和缺失处理都会被编译器定位出来。
自文档是另一个常被低估的收益。TransitionTable 的结构非常直观,一眼就能看出整个状态机允许哪些路径。当新人加入项目,读这张表比在大量 if-else 分支里寻找状态跳转要高效得多。类型定义还可以作为团队协作的契约:产品经理或后端工程师并不需要读具体实现,只看类型文件就能确认前端状态机支持哪些事件。文档与实际代码不会再脱节,因为表就是类型的一部分,改了表没有改实现,编译器会毫不留情地提醒你。
类型级建模还天然适合处理协议或工作流这些领域。比如 WebSocket 连接状态从 connecting 到 open 再到 closing 和 closed,或者订单从待支付到已支付、已发货、已完成。这些场景中状态数量有限、转移规则明确,把规则固化到类型系统能显著降低回归风险。尤其是多人协作时,一个开发者如果把某个事件用在了错误状态,在提交代码前就会得到报错,而不是等测试或线上问题暴露。
代价与适用边界:别让类型系统背不必要的复杂度
当然,凡事有代价。类型级状态机的最大成本是类型复杂度。随着状态和事件数量增加,TransitionTable 会变大,ValidEvents 和 NextState 的推导链条也会变长。在一些大型状态图中,TypeScript 编译器进行条件类型计算可能变慢,错误提示也可能变得难以理解。对于刚接触 TypeScript 高级类型的开发者来说,泛型、条件类型、映射类型组合在一起确实有一定学习曲线。
另一类问题来自类型断言。很多运行时实现为了满足复杂的类型约束,不得不在内部使用 as 断言,因为 TypeScript 本身无法完全证明某个对象字面量符合动态索引访问的类型。如果断言使用过多,可能会削弱类型安全带来的信任感。因此更合适的做法是,把复杂的类型级模型封装在状态机内部,对外暴露简洁的 API。调用者只看到合法的 transition 方法,不需要关心内部如何实现。
那么什么时候该使用这套建模方式?如果状态机只有两三个状态、逻辑简单,用可辨识联合加普通 switch 就足够了,过度设计反而降低可读性。如果状态数量在五到十余个,转移规则复杂,且需要长期维护,类型级建模的优势就很明显。尤其当状态机逻辑分散在多个模块或由多人修改时,让类型系统守住规则边界能省下大量沟通和排查成本。对于超大状态图,更明智的方案可能是借助 XState 这类专门的状态图库,并用 TypeScript 类型定义与它配合,而不是完全从头手写类型级实现。
TypeScript 的类型级编程不是银弹,但在状态机建模中,它提供了一种将隐性业务规则显性化、自动化检查的有效途径。从可辨识联合出发,到映射表、条件类型和模板字面量类型,开发者可以逐步把状态转移规则搬进类型层。这样做的结果不是让代码更短,而是让错误更早暴露、规则更清晰、协作更安全。当你下一次面对一团乱麻的状态判断时,不妨把它重构成一张类型表,看看编译器能帮你抓住多少潜在的非法流转。
TypeScript类型级编程状态机建模类型安全修改时间:2026-10-01 16:38:10