类型安全状态机:合法转换只有编译器知道
第25章:类型安全状态机:合法转换只有编译器知道
理解类型安全状态机是掌握 TypeScript 类型系统的关键一步。
本章核心问题:如何在实际项目中正确使用合法转换只有编译器知道?关键的设计决策和陷阱是什么?
读完本章你将理解:
- 问题的根源:运行时状态机的类型漏洞
- 方法一:判别联合 + 穷举检查
- 方法二:幻影类型状态机
Level 1 · 你需要知道的(1-3年经验)
问题的根源:运行时状态机的类型漏洞
大多数状态机的实现是这样的:
// 典型的"弱类型"状态机
type Status = "idle" | "loading" | "success" | "error";
interface Store {
status: Status;
data: any;
error: Error | null;
}
function transition(store: Store, action: string) {
if (action === "fetch" && store.status === "idle") {
store.status = "loading";
} else if (action === "resolve" && store.status === "loading") {
store.status = "success";
store.data = fetchData(); // data 的类型不确定
}
// 编译器完全不知道:
// 1. "loading" 状态下 data 是否存在
// 2. "success" 状态下 error 是否可以是 null
// 3. 从 "idle" 直接转到 "error" 是否合法
}
这段代码能编译,但类型系统给不了任何保护。你可以在 status === "idle" 时访问 data,或者从 success 直接转到 loading——编译器不会报错。
本章介绍两种让编译器真正理解状态机语义的方法,以及如何选择。
方法一:判别联合 + 穷举检查
建模状态
每个状态是一个独立的对象类型,用字面量类型做判别字段:
// HTTP 请求生命周期
interface IdleState {
status: "idle";
}
interface LoadingState {
status: "loading";
startedAt: number;
}
interface SuccessState<T> {
status: "success";
data: T;
completedAt: number;
}
interface ErrorState {
status: "error";
error: Error;
failedAt: number;
}
type RequestState<T> =
| IdleState
| LoadingState
| SuccessState<T>
| ErrorState;
这里的关键设计决策:每个状态只携带该状态下有意义的字段。IdleState 没有 data,SuccessState 没有 error。这不只是文档,是类型约束。
转换函数:入参类型锁定前置状态
function startLoading(state: IdleState): LoadingState {
return {
status: "loading",
startedAt: Date.now(),
};
}
// 只接受 LoadingState,不接受 IdleState 或 SuccessState
function resolveRequest<T>(state: LoadingState, data: T): SuccessState<T> {
return {
status: "success",
data,
completedAt: Date.now(),
};
}
function rejectRequest(state: LoadingState, error: Error): ErrorState {
return {
status: "error",
error,
failedAt: Date.now(),
};
}
function reset(_state: SuccessState<any> | ErrorState): IdleState {
return { status: "idle" };
}
// 编译时拦截非法转换
const idle: IdleState = { status: "idle" };
const loading = startLoading(idle);
// 错误:Argument of type 'IdleState' is not assignable to parameter of type 'LoadingState'
// resolveRequest(idle, { id: 1 }); // ← 编译错误!✓
穷举检查(Exhaustiveness Check)
function renderRequest<T>(state: RequestState<T>): string {
switch (state.status) {
case "idle":
return "Ready";
case "loading":
return `Loading... (started ${state.startedAt})`;
case "success":
return `Data: ${JSON.stringify(state.data)}`;
case "error":
return `Error: ${state.error.message}`;
default:
// 如果将来添加新状态而忘记在这里处理,编译器报错
const _exhaustive: never = state;
throw new Error(`Unhandled state: ${_exhaustive}`);
}
}
将来添加 CancelledState 时,如果忘记加 case "cancelled":,state 在 default 分支会变成 CancelledState,不能赋给 never,编译失败。这就是穷举检查。
类型守卫辅助
function isSuccess<T>(state: RequestState<T>): state is SuccessState<T> {
return state.status === "success";
}
function isLoading<T>(state: RequestState<T>): state is LoadingState {
return state.status === "loading";
}
// 使用
function getDataOrThrow<T>(state: RequestState<T>): T {
if (isSuccess(state)) {
return state.data; // 类型缩窄到 SuccessState<T>,data 可访问 ✓
}
throw new Error("Not in success state");
}
Level 2 · 它是怎么运行的(3-5年经验)
方法二:幻影类型状态机
幻影类型(Phantom Type)是指在运行时不存在,只用于类型层面携带信息的类型参数。
核心设计
// State 是幻影类型参数——运行时 Machine 对象上没有 _state 属性
// readonly _state!: S 使用感叹号表示"这个属性不会在运行时存在,只是类型占位符"
declare class Machine<S extends string> {
readonly _state!: S; // 幻影属性,永远不实际存在
private constructor();
}
// 工厂函数
function createMachine(): Machine<"idle"> {
return {} as Machine<"idle">;
}
HTTP 请求状态机:幻影类型版
type HttpState = "idle" | "loading" | "success" | "error";
// 结合泛型存储数据
type MachineWithData<S extends HttpState, D = never> = {
readonly _state: S;
readonly _data: D;
};
function start(m: MachineWithData<"idle">): MachineWithData<"loading"> {
console.log("Starting request...");
return m as any;
}
function succeed<T>(
m: MachineWithData<"loading">,
data: T
): MachineWithData<"success", T> & { data: T } {
return { ...m, data } as any;
}
function fail(
m: MachineWithData<"loading">,
error: Error
): MachineWithData<"error"> & { error: Error } {
return { ...m, error } as any;
}
function reset(
m: MachineWithData<"success", any> | MachineWithData<"error">
): MachineWithData<"idle"> {
return m as any;
}
// 使用
const idle = {} as MachineWithData<"idle">;
const loading = start(idle);
const success = succeed(loading, { id: 1, name: "Alice" });
// 编译错误:Argument of type 'MachineWithData<"idle">' is not assignable
// to parameter of type 'MachineWithData<"loading">'
// succeed(idle, { id: 1 }); // ← 编译拦截!✓
// 编译错误:不能从 success 直接 fail
// fail(success, new Error("x")); // ← 编译拦截!✓
实战:表单验证状态机
表单验证是状态机最常见的应用场景之一:
type FormState = "pristine" | "dirty" | "validating" | "valid" | "invalid";
interface FormMachine<S extends FormState> {
readonly _state: S;
}
interface FormData {
username: string;
email: string;
}
// pristine → dirty(用户开始输入)
function touch(
form: FormMachine<"pristine">,
data: Partial<FormData>
): FormMachine<"dirty"> & { data: Partial<FormData> } {
return { _state: "dirty", data } as any;
}
// dirty → validating(提交时)
function validate(
form: FormMachine<"dirty"> & { data: Partial<FormData> }
): FormMachine<"validating"> & { data: Partial<FormData> } {
return { ...form, _state: "validating" } as any;
}
// validating → valid(验证通过)
function markValid(
form: FormMachine<"validating"> & { data: Partial<FormData> }
): FormMachine<"valid"> & { data: FormData } {
// 断言 data 是完整的 FormData(应在 validate 过程中确认)
return { ...form, _state: "valid" } as any;
}
// validating → invalid(验证失败)
function markInvalid(
form: FormMachine<"validating"> & { data: Partial<FormData> },
errors: Record<keyof FormData, string>
): FormMachine<"invalid"> & { errors: typeof errors } {
return { _state: "invalid", data: form.data, errors } as any;
}
// 只有 valid 状态才能提交
function submit(
form: FormMachine<"valid"> & { data: FormData }
): Promise<void> {
return fetch("/api/submit", {
method: "POST",
body: JSON.stringify(form.data),
}).then(() => {});
}
// 编译拦截:不能提交未验证的表单
// submit({ _state: "dirty", data: { username: "a" } } as FormMachine<"dirty"> & { data: FormData });
// 错误:Argument of type 'FormMachine<"dirty"> & ...' is not assignable to parameter of type 'FormMachine<"valid"> & ...'
XState 集成:TypeScript 驱动的状态机库
XState 是目前最成熟的状态机库,v5 版本在 TypeScript 类型推断上大幅改进:
import { createMachine, assign } from "xstate";
// XState v5 的类型推断方式
const requestMachine = createMachine({
id: "request",
initial: "idle",
types: {} as {
context: { data: unknown; error: Error | null };
events:
| { type: "FETCH" }
| { type: "RESOLVE"; data: unknown }
| { type: "REJECT"; error: Error };
},
context: {
data: null,
error: null,
},
states: {
idle: {
on: {
FETCH: "loading",
},
},
loading: {
on: {
RESOLVE: {
target: "success",
actions: assign({ data: ({ event }) => event.data }),
},
REJECT: {
target: "error",
actions: assign({ error: ({ event }) => event.error }),
},
},
},
success: {
on: { FETCH: "loading" },
},
error: {
on: { FETCH: "loading" },
},
},
});
// XState 推断出合法的事件类型
// send({ type: "RESOLVE" }) 在非 loading 状态会被忽略(运行时安全)
// 但类型层面还是允许任意发送——这是 XState 的局限
XState 提供的是运行时的状态机安全,类型推断保证事件和 context 的一致性,但不能像幻影类型那样完全封锁非法转换路径。
两种方法的对比
| 维度 | 判别联合 | 幻影类型 |
|---|---|---|
| 非法转换拦截 | ✓ 函数签名层面 | ✓ 函数签名层面 |
| 状态携带的数据 | ✓ 每个状态独立字段 | ✓ 通过泛型参数 |
| 运行时开销 | 无 | 无(幻影属性不存在) |
| 可读性 | 更直观,常规 TS 代码 | 需要了解幻影类型概念 |
| 状态数量多时 | 联合类型变长,可管理 | 泛型参数组合可能复杂 |
| 与 XState 集成 | 自然 | 可以结合,但有冗余 |
| 适合场景 | 状态有不同的数据形状 | 状态主要控制操作权限 |
选择原则
- 用判别联合:当不同状态携带完全不同的数据(
IdleState没有data,SuccessState有);当需要在switch里进行穷举检查;当团队不熟悉幻影类型。 - 用幻影类型:当你需要限制操作权限而不只是数据形状(只有
loading状态的机器可以调用succeed());当状态数量多但数据结构相似;当你在做资源、锁、连接这类对象的生命周期管理。
Level 3 · 规范怎么定义的(资深)
类型安全状态机有两种实现路径:判别联合让每个状态携带不同的数据形状,通过 switch + assertNever 实现穷举检查;幽灵类型通过函数签名中的类型参数约束限制合法的状态转换路径。两者的共同目标是让编译器在类型层面理解状态机语义。XState v5 的 types 属性让 TypeScript 推断出合法的事件和 context 类型,但不能完全在编译时封锁非法转换——这是运行时状态机库的固有限制。
Level 4 · 边界与陷阱(所有人)
反模式
| 反模式 | 问题 |
|---|---|
用 any 作为状态类型 |
完全失去类型保护 |
转换函数接受 RequestState<T>(所有状态联合)而不是具体状态 |
放弃了前置状态的约束 |
| 在状态对象里放所有可能的字段(全部可选) | data?: T; error?: Error 让你在任何状态都能访问任何字段 |
| 不做穷举检查 | 添加新状态后忘记处理,运行时意外行为 |
幻影类型用 as any 滥用 |
会绕过所有类型保护,幻影类型的设计需要在边界处谨慎使用 as |
总结
| 技术 | 核心价值 | 主要限制 |
|---|---|---|
| 判别联合 | 每状态独立数据形状 + 穷举检查 | 状态很多时联合类型冗长 |
| 幻影类型 | 函数签名级别的转换约束 | 运行时需要 as any 穿越边界 |
| XState + TypeScript | 运行时状态机 + 事件/context 类型安全 | 无法完全封锁编译时的非法转换 |
| 类型守卫 | 在联合类型中安全缩窄 | 需要手动编写每个守卫函数 |