Every backend service I’ve ever written has a state enum in it somewhere. A connection that’s Connecting, then Open, then Draining, then Closed. A request that’s Pending, InFlight, Done, Failed. And every one of those enums comes with the same hand-rolled scaffolding: a match on the current state, a nested match on the event, an arm for every legal pair, and — if I was disciplined that day — a _ => panic!("illegal transition") at the bottom that I hope never fires.
The thing that bothers me about that pattern is that the legal transitions are knowable at compile time, and I’m checking them at runtime. The set of (state, event) pairs is finite. Whether a given pair is handled is a fact the compiler could verify. Instead I write a panic and find out in production which pair I forgot.
So Hew has a machine construct. The idea is narrow and I want to keep it narrow: a machine is a state enum where the transition table is part of the type, the compiler checks it for the things I’d otherwise check by hand, and then it lowers to exactly the tagged-union dispatch I’d have written myself — minus the panic, because the illegal pairs can’t be expressed.
Here’s the turnstile, the smallest machine that shows the shape:
machine Turnstile {
events {
Coin;
Push;
}
state Locked {
entry {
println("locked");
}
}
state Unlocked {
entry {
println("unlocked");
}
exit {
println("re-locking");
}
}
on Coin: Locked => Unlocked {
Unlocked
}
on Push: Unlocked => Locked {
Locked
}
on Coin: Unlocked => Unlocked reenter {
Unlocked
}
on Push: Locked => Locked reenter {
Locked
}
}events { … } is the input alphabet. Each state is a variant — Locked and Unlocked here carry no payload, but they could. Each on Event: Source => Target { … } is a transition: the head names the (state, event) cell, and the body is an expression that evaluates to the next state. The entry and exit blocks are state hooks — entry runs when you arrive at a state, exit when you leave it.
The reenter keyword needs explaining. on Coin: Unlocked => Unlocked is a self-transition — same source and target — and the bare keyword marks it as a deliberate Mealy re-entry rather than a typo. Without it, a transition that goes from a state back to itself reads like a mistake, and the checker treats it as one. With it, you’re saying “yes, I mean to re-enter this state on this event,” and the entry/exit hooks fire accordingly. (It’s the bare identifier reenter, not @reenter — an @-prefixed token lexes as a label, so a grammar that writes @reenter describes a machine that doesn’t parse.)
What the front end checks before emitting anything
The reason to make state a type rather than a convention is that the compiler can then check it, and it does, in the front end, before a single instruction is lowered. There are four checks I care about, and each one replaces a class of bug I used to find at runtime.
Exhaustiveness. Every (state, event) cell must be accounted for. If Turnstile declared a third event and I forgot to say what Push does from some state, the machine doesn’t compile — it’s a MachineExhaustivenessError, not a panic I’ll hit later. This is the check that earns the whole construct for me. The transition table is dense by default; a hole in it is a compile error.
No suspension points. A transition body computes the next state and cannot suspend partway through doing it. You cannot await inside one, and you cannot open a gen block inside one — both are rejected at the lowering boundary with named diagnostics (AwaitInMachineTransition, GenBlockInMachineTransition). The reasoning is that a transition has to run to completion synchronously to leave the machine in a well-defined state; a suspension point in the middle would let the machine be observed half-transitioned. So the compiler forbids the suspension rather than hoping you don’t write one.
Effect parity. This one is subtler and took me a while to articulate. A transition body can write a state’s payload fields, and so can a target state’s entry block or a source state’s exit block. If both the transition and the entry hook write the same field, the initialization order is ambiguous — who wins? Rather than pick a rule and bury it, the checker rejects the overlap as a MachineEffectParityViolation and points at the conflicting hook. The fields a transition writes and the fields its hooks write must be disjoint.
Emit-cycle freedom. A transition can emit an event (more on emit below). If an on E transition emits E, it would immediately re-trigger its own handler — an infinite loop encoded in the table. The checker walks the emit set of each transition and rejects the direct cycle:
on Tick: A => A reenter {
emit Tick {}; // rejected: emits Tick, which immediately re-triggers itself
A
}error: E_HIR: transition `on Tick` emits `Tick` which immediately re-triggers itself;
rename the emitted event or remove the emitAll four of these are things I used to verify by reading the code carefully and hoping. Now they’re boundary conditions: if the machine compiles, they hold.
Composite states and the default arm
Two more pieces of the surface, because real protocols need them.
States can nest — one level deep, no further; deeper nesting is a parse error the spec reserves for later. A composite state Connected { … } contains substates and an initial one, and a parent-level transition like on Disconnect: _ => Disconnected applies to every member. The interesting part is what that expands to: the parent rule with the _ source becomes one concrete transition per member state, never a literal _ in the flat table. The composite is a grouping for the author; the table the checker reasons about is flat and concrete. That matters because a transition body can read self.field, and a literal _ source has no fields to read — so the expansion has to commit to a concrete source before the body is checked.
And there’s the default arm. Exhaustiveness is strict, which would be miserable for a machine where most cells should just be ignored. So default { state } says “any cell I didn’t write explicitly stays in the current state.” A wildcard _-source rule can also cover cells — that’s what the composite expansion above is — but default is the blanket. Remove it and the same machine fails exhaustiveness:
machine Tank {
events {
Fill;
Drain;
}
state Filling;
state Draining;
on Drain: Filling => Draining {
Draining
}
default { state } // every other cell: stay put
}(Filling, Fill), (Draining, Drain), (Draining, Fill) are all unhandled, and default { state } makes stepping them a no-op. Without that line, those three holes are compile errors. The default is opt-in precisely because I want the strict version to be the thing you get by accident.
How a state becomes a layout
Now the part this post is really about: what the machine becomes.
A state declaration is a tagged union, and it lowers to one. For each machine, codegen builds an outer LLVM struct that’s exactly { tag, payload }:
// Outer machine struct, conceptually:
// { tag: iW, payload: [N x iA] }
// tag — discriminant; iW is i8 or i16 selected from the variant count
// payload — fixed byte array sized to the largest variant's ABI sizeThe tag is the discriminant — i8 or i16, picked by how many states there are. The payload is a fixed-size byte array sized to the largest variant. Each state with fields gets its own anonymous struct type matching its field declaration order, and accessing a state’s payload is a bitcast of the payload bytes to that variant’s struct pointer followed by a GEP.
The detail I’m proudest of getting right is the sizing. The naive thing is to sum the field widths — a state holding { tag_byte: i8; value: i64; } is “9 bytes,” right? No. On x86-64 and AArch64 that struct is 16 bytes, because value has to land on an 8-byte boundary and there are 7 bytes of padding after tag_byte. So the layout builder constructs each variant’s LLVM struct first and then asks the target’s data layout for the ABI size, padding included. The payload byte array is sized to that, and its element type is chosen so the array’s natural alignment meets the most-aligned field across all variants. A 1-byte-aligned payload would be undefined behaviour the moment you GEP’d an i64 field out of it — and Module::verify() won’t catch that, so the alignment has to be correct by construction, not by luck.
The events lower the same way: there’s a companion <Name>Event tagged union for the input alphabet, so an event with a payload (Start { handle: T }) is a tagged value just like a state.
The caller’s view: step and state_name
From outside, a machine is a value you drive. Two methods do the work:
fn main() {
var gate = Locked;
println(gate.state_name()); // Locked
gate.step(Coin);
println(gate.state_name()); // Unlocked
gate.step(Push);
println(gate.state_name()); // Locked
}machine Turnstile {
events { Coin; Push; }
state Locked { entry { println("locked"); } }
state Unlocked { entry { println("unlocked"); } exit { println("re-locking"); } }
on Coin: Locked => Unlocked { Unlocked }
on Push: Unlocked => Locked { Locked }
on Coin: Unlocked => Unlocked reenter { Unlocked }
on Push: Locked => Locked reenter { Locked }
}
fn main() {
var gate = Locked;
println(gate.state_name()); // Locked
gate.step(Coin);
println(gate.state_name()); // Unlocked
gate.step(Push);
println(gate.state_name()); // Locked
}var gate = Locked constructs the machine at the Locked state — a bare state name is a constructor for the tagged union. gate.step(Coin) is the dispatch: it reads the current tag, reads the event tag, selects the matching transition, runs it, and stores the resulting state back into gate. That store-back is the whole reason gate is a var and not a let — step mutates the machine in place. state_name() reads the current tag and indexes a static [N x ptr] table of NUL-terminated strings, one per state, so you get the human name back without a match.
Running the turnstile shows the hooks firing through the dispatch:
Locked
unlocked
Unlocked
re-locking
locked
Lockedstep(Coin) lands in Unlocked and its entry prints “unlocked.” step(Push) leaves Unlocked — its exit prints “re-locking” — and arrives at Locked, whose entry prints “locked.” One honest wrinkle you can see here: the initial state’s entry block does not fire on construction. var gate = Locked doesn’t print “locked”; only the transition into a state runs its entry. Whether construction should count as an entry is a real design question I haven’t resolved; right now the answer is no.
This is the dispatch I’d have hand-written — a switch on state, a switch on event, a store of the result — except the illegal (state, event) pairs aren’t reachable, because the table that generated the switch was checked for exhaustiveness first. There’s no _ => panic! at the bottom because there’s no cell the front end let through unhandled.
emit, and the queue behind it
A Mealy machine produces output on transitions, not just on states. In Hew that’s emit:
machine Connection {
events {
Open;
Close;
Established;
}
emits {
Established;
}
state Idle;
state Live;
on Open: Idle => Live {
emit Established {};
Live
}
on Close: Live => Idle {
Idle
}
// … reenter arms for the remaining cells …
}machine Connection {
events {
Open;
Close;
Established;
}
emits {
Established;
}
state Idle;
state Live;
on Open: Idle => Live {
emit Established {};
Live
}
on Close: Live => Idle {
Idle
}
default { state } // all other (state, event) cells stay put
}The emits { … } header is a manifest — an auditable allowlist of the events a machine is permitted to produce. If a transition emits something not in the manifest, that’s a MachineEmitNotInManifest error. I want the output vocabulary of a machine to be a declared, checkable thing, the same way the input alphabet is, rather than something you discover by reading every transition body.
Behind emit is a runtime emit queue, and it’s where I had to make some deliberately conservative calls. The queue is a single-threaded FIFO that drains at the outermost step() frame — emits don’t fire mid-transition, they queue and drain after the transition completes, so the machine is never observed half-stepped while an emit handler runs. Nested steps (an emit handler that itself steps the machine) are bounded by a reentrancy cap — 64 by default — and exceeding it raises a typed panic rather than overflowing the stack. That cap is the runtime backstop for the cycle the front end can’t catch: the direct on E emits E loop is a compile error, but an indirect cycle through several transitions can only be caught dynamically, so the depth cap is the fail-safe.
And the queue is panic-safe on drain. If an emit handler unwinds, a drop guard clears the remaining queued events so a caught panic can’t leave stale state behind for the next step. I’d rather drop pending emits on an unwind than carry corrupt queue state forward.
I’ll be straight about the limit here: today emit lowers unit events — the payload pointer is null. The emit Established {} above pushes the event onto the queue and the codegen path is exercised end-to-end, but the queue isn’t drained into a scheduler yet, and payload-carrying emits need an ownership story for the payload bytes that I haven’t built. So emit is real in the sense that it queues, checks against the manifest, and respects the cap — and not-yet-real in the sense that nobody downstream consumes the queue. That’s the next piece.
const-generic machines, and what’s only half-wired
The last surface I want to cover is the one I’m least done with, because it’s the most honest about where the construct is.
A machine can take generic parameters, including const parameters — a compile-time integer that parameterizes the machine, like a fixed buffer size:
machine RingBuffer<const N: usize = 16> {
events {
Push;
Drain;
}
state Empty;
state Filling;
on Push: Empty => Filling {
Filling
}
on Drain: Filling => Empty {
Empty
}
// … reenter arms …
}machine RingBuffer<const N: usize = 16> {
events {
Push;
Drain;
}
state Empty;
state Filling;
on Push: Empty => Filling {
Filling
}
on Drain: Filling => Empty {
Empty
}
on Push: Filling => Filling reenter { Filling }
on Drain: Empty => Empty reenter { Empty }
}<const N: usize = 16> declares a const parameter with a default. Only usize is admitted for now, type parameters must precede const parameters, and a non-usize width is a parse error. Behind the declaration sits a small constexpr sub-engine for folding compile-time integer expressions, so that an instantiation argument won’t have to be a bare literal. It handles integer literals, identifiers resolved against module-level const bindings, and the seven arithmetic and shift operators (+ - * / % << >>), every intermediate value checked for overflow, division by zero, and negative shifts. (Negation is recognized but almost never legal: the target type is usize, so any negated value except zero is rejected as overflow.) The sub-engine rejects everything outside that allow-list as a hard NotConstant rather than a silent zero.
Type parameters can carry trait bounds too — machine Bounded<T: Resource> parses, and the bound is recorded on the declaration. The bound is not enforced yet: a concrete T with no Resource impl currently passes hew check without a diagnostic. The enforcement is meant to route through the same bound-enforcement path the rest of the type system uses; today only the surface is there.
Here’s the honest part, and it’s larger than I’d like: the declaration is where it stops. The const parameter list parses and checks, and the constexpr engine exists and is tested — but its machine-side entry point has no call sites yet, because the use-site surface it would evaluate doesn’t parse. (The engine itself isn’t idle; it also folds module-level const initializers. It’s the machine-instantiation path that’s unwired.) RingBuffer<32> — even a bare literal, never mind FOO * 4 — is a parse error in a type position today. And nothing downstream consumes a const argument either: there’s no per-instantiation machine layout keyed on the const value; RingBuffer<16> and RingBuffer<32> can’t yet produce two different layouts the way they will have to. What I’ve actually built is the front-end contract — the declaration shape, the evaluation rules — sitting unwired, so that when use-site parsing and the layout pass land they have a locked, already-checked engine to consume.
Why push it into the type system at all
The argument for all of this is the same argument I’d make for any static check, and I want to state it plainly because the construct costs real compiler complexity.
A hand-rolled state enum catches an illegal transition when it executes one — in production, on the input that finally hit the unhandled cell, three weeks after I shipped the gap. A machine catches it when it compiles, on the cell I never wrote, before the binary exists. The transition table is finite and knowable; checking it at runtime is checking a fact I had at compile time and threw away. Exhaustiveness, the no-suspension rule, effect parity, and cycle-freedom are all properties I used to verify by reading code and hoping, and a _ => panic!("illegal transition") is the monument to having given up on verifying them.
What I get for the compiler complexity is that the dispatch I lower is the dispatch I’d have written by hand, with the illegal states made unrepresentable rather than guarded against. The tagged union is the same one I’d have laid out; the switch is the same switch. The difference is that the holes are compile errors instead of panics, and the output vocabulary is a declared manifest instead of a thing you reconstruct by reading every arm.
I’m not done. emit doesn’t drain into anything yet, const-generic machines stop at the declaration surface, and the initial-state entry question is unresolved. But the spine is here: state is a type, the transition table is checked before anything is emitted, and step is the tagged-union dispatch I always end up writing — this time with the compiler holding me to the table I declared.