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.

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

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

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

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

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

Wildcard source state
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 with machine
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 file
  • hew machine check <file> — validate the state graph: reachability, completeness, guard conflicts
  • hew machine dot <file> — export a Graphviz dot file of the state graph
Machine CLI
# 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.svg

The 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

FeatureSyntax
Declare a machinemachine Name { ... }
Define statesstate Name; or state Name { field: Type; }
Define eventsevent Name; or event Name(field: Type);
Transitionon Event: Source -> Target;
Guarded transitionon Event: Source -> Target when condition;
Wildcard sourceon Event: * -> Target;
Transition with dataon Event: Source -> Target { field: value }
Default statedefault { state }
Step forwardmachine.step(Event)
Match statematch machine { State => ... }