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=completedWhat 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 statehandler— 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=cancelledThis 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.