State Machines
First-class state machines with compile-time validation.
Why first-class machines?
Actors without explicit state machines eventually become state machines anyway — just bad ones. Boolean flags, status fields, and nested if chains accumulate until you're maintaining a hand-rolled FSM without any of the guarantees. Hew makes state machines a language construct so the compiler can validate them.
A machine declaration defines named states, typed events, and transition rules. The compiler checks that every state handles every event — missing transitions are compile-time errors, not runtime surprises.
Declaring a machine
Use the machine keyword to declare a state machine. Inside, use state for states, event for events, and on for transition rules.
machine TrafficLight {
state Red;
state Yellow;
state Green;
event Timer;
on Timer: Red -> Green;
on Timer: Green -> Yellow;
on Timer: Yellow -> Red;
default { state }
}The default block specifies the initial state expression. state inside default refers to the current state.
States with payload fields
States can carry data. Declare fields inside the state block — they're available when the machine is in that state.
machine CircuitBreaker {
state Closed { failures: Int; }
state Open;
state HalfOpen;
event Success;
event Failure;
event Timeout;
on Failure: Closed -> Open;
on Timeout: Open -> HalfOpen;
on Success: HalfOpen -> Closed { failures: 0 }
on Failure: HalfOpen -> Open;
default { state }
}When transitioning to a state with fields, provide values in braces: -> Closed { failures: 0 }. If no values are provided, the compiler uses the previous state's field values (if the field names match) or requires explicit values.
Using machines
Machines are values — create instances, store them in variables or actor fields, and advance them with step(). Match on the current state to react to changes.
var cb = CircuitBreaker::Closed { failures: 0 };
cb.step(Failure); // Closed → Open
cb.step(Timeout); // Open → HalfOpen
cb.step(Success); // HalfOpen → Closed
match cb {
Closed { failures } => println(f"healthy, {failures} failures"),
Open => println("circuit open — failing fast"),
HalfOpen => println("testing..."),
}Guards with when
Guards constrain when a transition fires. The when clause is a boolean expression — the transition only occurs if it evaluates to true. Inside guards, state accesses the source state's fields and event accesses the event's payload.
machine TokenBucket {
state Allowing { tokens: Int; }
state Throttled;
event Request;
event Replenish;
on Request: Allowing -> Allowing when state.tokens > 1 {
tokens: state.tokens - 1
}
on Request: Allowing -> Throttled when state.tokens <= 1;
on Replenish: Throttled -> Allowing { tokens: 5 }
default { state }
}The compiler validates that guards on the same state and event don't overlap. If two guards could both be true simultaneously, the compiler reports an error rather than leaving the behaviour dependent on evaluation order.
Event payloads
Events can carry data. Declare parameters on the event — they're available inside guard expressions and transition bodies via the event keyword.
machine RateLimiter {
state Active { remaining: Int; }
state Blocked;
event Request(cost: Int);
event Reset;
on Request: Active -> Active 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 }
}Wildcard transitions
Use * as the source state to match any state. This is useful for events that should be handled regardless of the current state, like a global reset.
machine Connection {
state Idle;
state Connecting;
state Connected;
state Disconnected;
event Connect;
event Disconnect;
event Reset;
on Connect: Idle -> Connecting;
on Connect: Disconnected -> Connecting;
on Disconnect: Connected -> Disconnected;
on Reset: * -> Idle;
default { state }
}Machines inside actors
Machines are commonly used inside actors to model protocol state. The actor owns the machine as a field and steps it in response to messages.
actor Connection {
let addr: String;
let retries: Int;
machine Lifecycle {
state Idle;
state Connecting;
state Connected;
state Disconnected;
event Connect(host: String);
event Disconnect;
event HeartbeatTimeout;
on Connect: Idle -> Connecting;
on Connect: Disconnected -> Connecting when retries < 5;
on Disconnect: Connected -> Disconnected;
on HeartbeatTimeout: Connected -> Disconnected;
}
receive fn connect(host: String) {
Lifecycle.step(Connect(host: host));
}
receive fn disconnect() {
Lifecycle.step(Disconnect);
}
}The hew machine CLI
The hew machine subcommand operates on machine declarations without compiling the full program:
hew machine list <file>— list all machines declared in a filehew machine check <file>— validate the state graph: reachability, completeness, guard conflictshew machine dot <file>— export a Graphviz dot file of the state graph
# List machines in a file
hew machine list connection.hew
# Validate state graph
hew machine check connection.hew
# Export Graphviz diagram
hew machine dot connection.hew | dot -Tsvg > lifecycle.svgThe check command performs graph analysis beyond what the type checker does: it verifies that every state is reachable from the initial state, that every event is handled in at least one state, and that no two transitions from the same state on the same event have overlapping guards.
Summary
| Feature | Syntax |
|---|---|
| Declare a machine | machine Name { ... } |
| Define states | state Name; or state Name { field: Type; } |
| Define events | event Name; or event Name(field: Type); |
| Transition | on Event: Source -> Target; |
| Guarded transition | on Event: Source -> Target when condition; |
| Wildcard source | on Event: * -> Target; |
| Transition with data | on Event: Source -> Target { field: value } |
| Default state | default { state } |
| Step forward | machine.step(Event) |
| Match state | match machine { State => ... } |