Chapter 25

Type-Safe State Machines: Only the Compiler Knows Valid Transitions

The Problem: Type Holes in Runtime State Machines

Most state machine implementations look like this:

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();
  }
}

This compiles. The type system provides zero protection. You can read data when status === "idle", or transition from success back to loading by accident—no compiler error either way.

Two approaches make the compiler enforce state machine semantics.


Approach 1: Discriminated Union + Exhaustiveness Check

Modeling States

Each state is its own type, discriminated by a literal type field:

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;

The critical design decision: each state carries only the fields that make sense for that state. IdleState has no data. SuccessState has no error. This isn't just documentation—it's a type constraint.

Transition Functions: Lock the Precondition in the Signature

function startLoading(state: IdleState): LoadingState {
  return {
    status: "loading",
    startedAt: Date.now(),
  };
}

// Only accepts LoadingState — not IdleState, not 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" };
}

// Compile-time rejection of illegal transitions
const idle: IdleState = { status: "idle" };
const loading = startLoading(idle);

// Error: Argument of type 'IdleState' is not assignable to parameter of type 'LoadingState'
// resolveRequest(idle, { id: 1 });  // ← Compile error!  ✓

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:
      // If a new state is added and this switch isn't updated, compile fails here
      const _exhaustive: never = state;
      throw new Error(`Unhandled state: ${_exhaustive}`);
  }
}

When a CancelledState is added later, state in the default branch becomes CancelledState, which is not assignable to never, and the build fails. This is the exhaustiveness check.

Type Guard Helpers

function isSuccess<T>(state: RequestState<T>): state is SuccessState<T> {
  return state.status === "success";
}

function getDataOrThrow<T>(state: RequestState<T>): T {
  if (isSuccess(state)) {
    return state.data;  // Narrowed to SuccessState<T> here — data is accessible  ✓
  }
  throw new Error("Not in success state");
}

Approach 2: Phantom Type State Machine

A phantom type is a type parameter that carries information at the type level but does not exist at runtime.

Core Design

// _state is a phantom property — it never actually exists at runtime
// The ! (definite assignment assertion) signals this
declare class Machine<S extends string> {
  readonly _state!: S;
  private constructor();
}

function createMachine(): Machine<"idle"> {
  return {} as Machine<"idle">;
}

HTTP Request Lifecycle: Phantom Type Version

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;
}

// Usage
const idle = {} as MachineWithData<"idle">;
const loading = start(idle);
const success = succeed(loading, { id: 1, name: "Alice" });

// Compile error: Argument of type 'MachineWithData<"idle">' is not assignable
// to parameter of type 'MachineWithData<"loading">'
// succeed(idle, { id: 1 });  // ← Blocked at compile time  ✓

// Compile error: cannot fail a success state
// fail(success, new Error("x"));  // ← Blocked at compile time  ✓

Form Validation State Machine

type FormState = "pristine" | "dirty" | "validating" | "valid" | "invalid";

interface FormMachine<S extends FormState> {
  readonly _state: S;
}

interface FormData {
  username: string;
  email: string;
}

// pristine → dirty (user starts typing)
function touch(
  form: FormMachine<"pristine">,
  data: Partial<FormData>
): FormMachine<"dirty"> & { data: Partial<FormData> } {
  return { _state: "dirty", data } as any;
}

// dirty → validating (on submit)
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 } {
  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;
}

// Only a valid-state form can be submitted
function submit(
  form: FormMachine<"valid"> & { data: FormData }
): Promise<void> {
  return fetch("/api/submit", {
    method: "POST",
    body: JSON.stringify(form.data),
  }).then(() => {});
}

// Compile error: cannot submit a dirty form
// const dirtyForm = touch({} as FormMachine<"pristine">, { username: "a" });
// submit(dirtyForm);  // ← Compile error  ✓

XState Integration

XState v5 significantly improved TypeScript inference:

import { createMachine, assign } from "xstate";

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 gives runtime state machine safety. Type inference ensures event shapes and context are consistent. But it doesn't block illegal transitions at compile time the way phantom types do—you can still send RESOLVE while in the idle state (it's ignored at runtime, not rejected at compile time).


Comparison

Dimension Discriminated Union Phantom Type
Illegal transition blocking ✓ Function signature ✓ Function signature
Per-state data shapes ✓ Independent fields per state ✓ Via generic parameters
Runtime overhead None None (phantom props don't exist)
Readability Straightforward, conventional TS Requires understanding phantom types
Many states Union grows, still manageable Generic combinations can get complex
XState integration Natural Works, but some redundancy
Best for States with different data shapes Restricting what operations are available

When to Use Which

Use discriminated unions when different states carry fundamentally different data (IdleState has no data, SuccessState has no error), when you want exhaustiveness checking in switches, or when the team is unfamiliar with phantom types.

Use phantom types when you need to restrict operations not just data shapes (only a loading machine can call succeed()), when managing resource lifecycles (connections, locks, file handles), or when states have similar data structures but different permission sets.


Anti-Patterns

Anti-Pattern Problem
any as the state type Zero type protection
Transition functions accepting the full union RequestState<T> Abandons precondition enforcement
All fields optional on a single state object (data?: T; error?: Error) Every field accessible in every state
No exhaustiveness check Adding states silently breaks behavior
Overusing as any in phantom type transitions Bypasses all protection; use only at implementation boundaries

Summary

Technique Core Value Main Limitation
Discriminated union Per-state data shapes + exhaustiveness check Verbose union type with many states
Phantom type Function-signature-level transition constraints Requires as any at runtime boundaries
XState + TypeScript Runtime machine + typed events/context Cannot fully block illegal transitions at compile time
Type guards Safe narrowing inside union types Must write a guard function for each state
Rate this chapter
4.7  / 5  (5 ratings)

💬 Comments