State Machines

12 lines of KERN becomes 140+ lines of type-safe TypeScript — state type union, error class, transition functions, and a useReducer hook. All from one machine block.

Declaring a machine

A machine has a name, states, and transitions:

machine name=Plan
  state name=draft
  state name=approved
  state name=running
  state name=paused
  state name=completed
  state name=failed
  state name=cancelled
  transition name=approve from=draft to=approved
  transition name=start from=approved to=running
  transition name=cancel from="draft|approved|running|paused|failed" to=cancelled
  transition name=fail from="running|paused" to=failed
  transition name=pause from=running to=paused
  transition name=complete from=running to=completed

What gets generated

KERN generates 4 pieces from every machine:

1. State type union

export type PlanState = 'draft' | 'approved' | 'running'
  | 'paused' | 'completed' | 'failed' | 'cancelled';

2. Error class

export class PlanStateError extends Error {
  constructor(
    public readonly expected: string | string[],
    public readonly actual: string,
  ) {
    const expectedStr = Array.isArray(expected)
      ? expected.join(' | ') : expected;
    super(`Invalid plan state: expected ${expectedStr}, got ${actual}`);
    this.name = 'PlanStateError';
  }
}

3. Transition functions

One function per transition. Single-source transitions get a direct equality check. Multi-source transitions get an array check:

// Single source: from=draft
export function approvePlan<T extends { state: PlanState }>(entity: T): T {
  if (entity.state !== 'draft') {
    throw new PlanStateError('draft', entity.state);
  }
  return { ...entity, state: 'approved' as PlanState };
}

// Multi source: from="draft|approved|running|paused|failed"
export function cancelPlan<T extends { state: PlanState }>(entity: T): T {
  const valid: PlanState[] = ['draft', 'approved', 'running', 'paused', 'failed'];
  if (!valid.includes(entity.state)) {
    throw new PlanStateError(valid, entity.state);
  }
  return { ...entity, state: 'cancelled' as PlanState };
}

Every transition function is generic: <T extends { state: PlanState }>. Your entity can have any shape — the function only touches the state field and preserves everything else via spread.

4. useReducer hook

For React and Ink targets, KERN also generates a reducer and hook:

type PlanAction = 'approve' | 'start' | 'cancel'
  | 'fail' | 'pause' | 'complete';

function planReducer(state: PlanState, action: PlanAction): PlanState {
  const entity = { state };
  switch (action) {
    case 'approve': return approvePlan(entity).state;
    case 'start':   return startPlan(entity).state;
    case 'cancel':  return cancelPlan(entity).state;
    case 'fail':    return failPlan(entity).state;
    case 'pause':   return pausePlan(entity).state;
    case 'complete': return completePlan(entity).state;
    default: return state;
  }
}

export function usePlanReducer() {
  const [state, dispatch] = useReducer(planReducer, 'draft' as PlanState);
  return { state, dispatch } as const;
}

Custom transition handlers

Add a handler block to a transition for custom logic. The handler replaces the default { ...entity, state: 'target' } return:

transition name=start from=approved to=running
  handler <<<
    const firstPending = (entity as any).steps?.find(
      (s: any) => s.result?.state === 'pending'
    );
    return {
      ...entity,
      state: 'running' as PlanState,
      currentStepId: firstPending?.id ?? null,
      updatedAt: new Date().toISOString(),
    };
  >>>

The guard check (from validation) still runs before your custom handler — you only override the state change logic.

Properties reference

machine

  • name — machine name (becomes type prefix: PlanState, PlanStateError, approvePlan)

state

  • name — state identifier (becomes a literal in the union type)

transition

  • name — transition name (becomes function name: approvePlan)
  • from — source state(s). Single or pipe-separated: "draft|approved|running"
  • to — target state
  • handler — optional custom transition logic

Static analysis

kern review includes the machine-gap rule that catches:

  • Unreachable states — states with no incoming transition
  • Dead-end states — states with no outgoing transition (except terminal states)
  • Missing switch cases — exhaustiveness gaps when matching against machine states

Full example

A complete machine with types, states, transitions, and a custom handler:

type name=OrderStatus values="cart|checkout|paid|shipped|delivered|cancelled"

machine name=Order
  state name=cart
  state name=checkout
  state name=paid
  state name=shipped
  state name=delivered
  state name=cancelled
  transition name=beginCheckout from=cart to=checkout
  transition name=pay from=checkout to=paid
  transition name=ship from=paid to=shipped
    handler <<<
      return {
        ...entity,
        state: 'shipped' as OrderStatus,
        shippedAt: new Date().toISOString(),
      };
    >>>
  transition name=deliver from=shipped to=delivered
  transition name=cancel from="cart|checkout" to=cancelled

This generates 6 states, 5 transition functions with type guards, an error class, and a reducer hook — all type-safe, all from 17 lines of KERN.

Next: Events — typed event systems, DOM handlers, and WebSocket events.