Zum Hauptinhalt springen

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.

Basic machine
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.

States with data
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.

Stepping and matching
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.

Guarded transitions
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.

Events with data
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.

Wildcard source state
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).

Actor stepping a machine
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 events
  • hew machine diagram <file> β€” emit a Mermaid state diagram (add --dot for Graphviz)
Machine CLI
# 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.svg

Summary

FeatureSyntax
Declare a machinemachine Name { ... }
Event vocabularyevents { Name; Name { field: Type; } }
Define statesstate Name; or state Name { field: Type; }
Transitionon Event: Source => Target { body }
Guarded transitionon Event: Source => Target when condition { body }
Self re-entryon Event: S => S reenter { body }
Wildcard sourceon Event: _ => Target { body }
Unhandled staysdefault { state }
Step forwardmachine.step(.Event).expect("machine step failed")
Match statematch machine { State => ... }