第 25 章

类型安全状态机:合法转换只有编译器知道

第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 没有 dataSuccessState 没有 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":statedefault 分支会变成 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 集成 自然 可以结合,但有冗余
适合场景 状态有不同的数据形状 状态主要控制操作权限

选择原则


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 类型安全 无法完全封锁编译时的非法转换
类型守卫 在联合类型中安全缩窄 需要手动编写每个守卫函数
本章评分
4.7  / 5  (5 评分)

💬 留言讨论