State Machines
First-class state machines with compile-time validation.
Why first-class machines?
Actors without explicit state machines tend to grow implicit ones. Boolean flags, status fields, and nested if chains accumulate until you're maintaining a hand-rolled FSM without compiler support. Hew makes state machines a language construct so the compiler can validate them.
A machine is a value type — a tagged union with named states, an events { ... } vocabulary, and transition rules. The compiler checks that every (state, event) pair is handled: an uncovered cell is a compile-time error, not a runtime surprise.
Declaring a machine
Use the machine keyword. Declare the input events up front in an events { ... } header, then the states, then the transitions. A transition routes with => and a body that constructs the target state value.
machine TrafficLight {
events { Timer; }
state Red;
state Yellow;
state Green;
on Timer: Red => Green ;
on Timer: Green => Yellow ;
on Timer: Yellow => Red ;
}Every state declares its own variant; the transition body is an expression that evaluates to a value of the target state. When the machine covers every (state, event) cell explicitly — as TrafficLight does — no default arm is needed.
The default arm
A machine must cover every cell of its states × events matrix. When you don't want to write a rule for every combination, end the body with default { state }: any otherwise-uncovered (state, event) pair becomes a no-op that stays in the current state. state is the implicit binding for the whole current machine value.
The machine's start state is not declared inside the machine — the caller constructs it (for example var cb = CircuitBreaker.Closed { failures: 0 }). default is about unhandled transitions, not the initial state.
States with payload fields
States can carry data. Declare fields inside the state block; inside a transition whose source is that state, read them through state.field.
machine CircuitBreaker {
events { Success; Failure; Timeout; }
state Closed { failures: i64; }
state Open;
state HalfOpen;
on Failure: Closed => Open ;
on Timeout: Open => HalfOpen ;
on Success: HalfOpen => Closed { failures: 0 }
on Failure: HalfOpen => Open ;
default { state }
}The transition body constructs the full target state value. For a state with fields, supply them: Closed { failures: 0 }. There is no partial fill — every field of the target variant must be provided.
Using machines
Machines are values. Construct the start state (qualified with the machine name from outside its body), advance it with step(), and match on the current state. Because step() mutates in place, the receiver must be a var. For CircuitBreaker, it returns Result<CircuitBreakerStep, MachineStepError>: handle the error or use .expect("machine step failed"). A successful outcome carries the transition disposition and any emitted outputs.
machine CircuitBreaker {
events { Success; Failure; Timeout; }
state Closed { failures: i64; }
state Open;
state HalfOpen;
on Failure: Closed => Open ;
on Timeout: Open => HalfOpen ;
on Success: HalfOpen => Closed { failures: 0 }
on Failure: HalfOpen => Open ;
default { state }
}
fn main() {
var cb = CircuitBreaker.Closed { failures: 0 };
_ = cb.step(.Failure).expect("machine step failed"); // Closed -> Open
_ = cb.step(.Timeout).expect("machine step failed"); // Open -> HalfOpen
_ = cb.step(.Success).expect("machine step failed"); // HalfOpen -> Closed
match cb {
.Closed { failures } => println(f"healthy, {failures} failures"),
.Open => println("circuit open — failing fast"),
.HalfOpen => println("testing..."),
}
}Guards with when
A guard constrains when a transition fires. The when clause is a boolean expression evaluated against the source state and event. Inside it, state.field reads the source state and event.field reads the event payload. A self-transition that should re-run entry/exit is marked reenter.
machine TokenBucket {
events { Request; Replenish; }
state Allowing { tokens: i64; }
state Throttled;
on Request: Allowing => Allowing reenter when state.tokens > 1 { tokens: state.tokens - 1 }
on Request: Allowing => Throttled when state.tokens <= 1 ;
on Replenish: Throttled => Allowing { tokens: 5 }
default { state }
}Guards do not contribute to exhaustiveness on their own — keep a fallback so every cell is still covered. Here the two Request guards partition the Allowing state, and default covers the rest.
Event payloads
Events can carry data. Declare the fields in the events header; read them in a guard or body via event.field, or name them at the rule site with an on Event(field): head binding.
machine RateLimiter {
events { Request { cost: i64; } Reset; }
state Active { remaining: i64; }
state Blocked;
on Request: Active => Active reenter when state.remaining > event.cost { remaining: state.remaining - event.cost }
on Request: Active => Blocked when state.remaining <= event.cost ;
on Reset: Blocked => Active { remaining: 100 }
default { state }
}The compiler generates a companion enum RateLimiter.Event you can name in signatures and construct with RateLimiter.Event.Request { cost: 5 }.
Wildcard transitions
Use _ as the source (and, for a passthrough, the target) to match any state. This is useful for an event that should be handled regardless of the current state, like a global reset. An explicit cell rule always wins over a wildcard.
machine Connection {
events { Connect; Disconnect; Reset; }
state Idle;
state Connecting;
state Connected;
state Disconnected;
on Connect: Idle => Connecting ;
on Connect: Disconnected => Connecting ;
on Disconnect: Connected => Disconnected ;
on Reset: _ => Idle ;
default { state }
}Driving a machine from an actor
A machine is data, not an actor — drive it from a local var, a free-function parameter, or as an actor message payload. The actor keeps the machine value in a local var, steps it, and reacts in the surrounding handler (side effects live in the actor, not in the pure transition body).
machine Lifecycle {
events { Connect; Disconnect; }
state Idle;
state Connecting;
state Connected;
on Connect: Idle => Connecting ;
on Disconnect: Connected => Idle ;
default { state }
}
// A free function can take and return a machine by value.
fn after_connect(phase: Lifecycle) -> Lifecycle {
var local = phase;
_ = local.step(.Connect).expect("machine step failed");
local
}
actor Conn {
var connected: i64 = 0;
receive fn connect() {
var phase = Lifecycle.Idle;
_ = phase.step(.Connect).expect("machine step failed");
// side effects happen here, in the actor — not in the machine
match phase {
.Connecting => { connected = 1; println("connecting"); }
_ => {}
}
}
}The hew machine CLI
The hew machine subcommand operates on machine declarations without compiling the full program:
hew machine list <file>— list all machines with their states and eventshew machine diagram <file>— emit a Mermaid state diagram (add--dotfor Graphviz)
# List machines in a file
hew machine list connection.hew
# Emit a Mermaid state diagram
hew machine diagram connection.hew
# Export a Graphviz diagram
hew machine diagram connection.hew --dot | dot -Tsvg > lifecycle.svgSummary
| Feature | Syntax |
|---|---|
| Declare a machine | machine Name { ... } |
| Event vocabulary | events { Name; Name { field: Type; } } |
| Define states | state Name; or state Name { field: Type; } |
| Transition | on Event: Source => Target { body } |
| Guarded transition | on Event: Source => Target when condition { body } |
| Self re-entry | on Event: S => S reenter { body } |
| Wildcard source | on Event: _ => Target { body } |
| Unhandled stays | default { state } |
| Step forward | machine.step(.Event).expect("machine step failed") |
| Match state | match machine { State => ... } |