Statum
Statum is a zero-boilerplate Rust toolkit for ergonomic typestate builders and protocol-safe APIs, built on finite-state modeling with compile-time transition validation.
Why Use Statum?
- Compile-Time Safety: State transitions are validated at compile time, ensuring no invalid transitions.
- Ergonomic Macros: Define typestate lifecycles with minimal boilerplate.
- State-Specific Data: Add and access data tied to individual states easily.
- Persistence-Friendly: Reconstruct typed machines seamlessly from external data sources.
Table of Contents
- Quick Start
- Additional Features & Examples
- Typestate Builder Design Playbook
- Examples
- Patterns & Guidance
- API Rules (Current)
- Common Errors and Tips
- API Reference
Quick Start
To start, it provides three attribute macros:
#[state]for defining states (as enums).#[machine]for creating a state machine struct that tracks which state you are in at compile time.#[transition]for validating transition method signatures.
Here is the simplest usage of Statum without any extra features:
use ;
// 1. Define your states as an enum.
// 2. Define your machine with the #[machine] attribute.
// 3. Implement transitions for each state.
Example: statum-examples/src/examples/example_01_setup.rs.
How It Works
#[state]transforms your enum, generating one struct per variant (likeOffandOn), plus a traitLightState.#[machine]injects extra fields (marker,state_data) to track which state you are in, letting you define transitions that change the state at the type level.#[transition]validates method signatures and ties them to a concrete next state.
That is it. You now have a compile-time guaranteed state machine where invalid transitions are impossible.
Additional Features & Examples
1. Adding Debug, Clone, or Other Derives
By default, you can add normal Rust derives on your enum and struct. For example:
Important: If you place #[derive(...)] above #[machine], you may see an error like:
error[E0063]: missing fields `marker` and `state_data` in initializer of `Light<_>`
|
14 | #[derive(Debug, Clone)]
| ^ missing `marker` and `state_data`
To avoid this, put #[machine] above the derive(s).
// ❌ This will NOT work
// note the position of the derive
;
// ✅ This will work
;
Example: statum-examples/src/examples/03-derives.rs.
2. Complex Transitions & Data-Bearing States
Defining State Data
States can hold data. For example:
Note: We use
self.transition_with(data)instead ofself.transition()to transition to a state that carries data.
Accessing State Data
State data is available as self.state_data in the concrete machine type:
Examples: statum-examples/src/examples/07-state-data.rs, statum-examples/src/examples/08-transition-with-data.rs.
3. Reconstructing State Machines from Persistent Data
State machines often need to persist their state. Saving to and loading from external storage like databases should be both robust and type-safe. Statum's #[validators] macro simplifies this process, ensuring seamless integration between your persistent data and state machine logic.
The key pieces are:
#[validators]macro on your data type impl block.into_machine()generated on the data type to reconstruct the machine (withmachine_builder()kept for compatibility).
Example
use ;
Examples: statum-examples/src/examples/09-persistent-data.rs, statum-examples/src/examples/10-persistent-data-vecs.rs.
4. Typestate Builder Ergonomics
The validators macro also generates a machine-scoped module that exposes a short alias:
You can use it to shorten matches without introducing collisions:
let row = DbData ;
match rebuild_task?
Tested in statum-examples/tests/patterns.rs.
Typestate Builder Design Playbook
If an abstract entity moves through meaningful stages with real ordering constraints, it is a strong typestate candidate.
Applied well, this approach should improve readability, modularity, extensibility, expressiveness, idiomaticity, and correctness.
In practice, design in this order:
- Identify the staged entity and write its lifecycle in plain language.
- Define finite states first with
#[state](and attach phase-specific data only where it is truly invariant). - Define machine context with
#[machine](long-lived dependencies, IDs, shared context). - Encode legal transitions with
#[transition]on concrete source states. - Keep runtime branching in normal methods, then dispatch into explicit transition methods.
- Use
#[validators]to safely rehydrate from persistence back into typed machine states. - Keep a hybrid boundary: stable protocol core in typestate, highly dynamic edges in runtime validation.
Use this compact checklist to decide fit quickly:
- Finite states exist.
- Legal transitions are mostly compile-time known.
- Invalid transitions are expensive.
- API behavior differs by state.
- Some data is only valid in specific states.
- Workflow is stable enough to justify type-level encoding.
Interpretation:
- 5-6 yes: strong typestate candidate.
- 3-4 yes: hybrid approach.
- 0-2 yes: runtime model is likely better for now.
Quick quality check:
- Readability + expressiveness: state names and method availability make lifecycle intent obvious.
- Modularity + extensibility: most changes are localized to a specific state impl, and adding a stable new state does not cause broad rewrites.
- Idiomaticity + correctness: ownership is straightforward and illegal protocol paths are blocked at compile time where possible.
Manual Typestate Builder vs Statum Ergonomics
If you have seen this walkthrough (YouTube), the core idea is:
- move missing required fields (like URL or method) from runtime checks into compile-time typestate constraints.
- expose
build()only when the builder is in a "ready" type state.
That pattern works great, but a manual implementation usually requires:
- Multiple marker/data state types (
NoUrl,Url,NoMethod,Method,Sealed,NotSealed). - A generic builder with a type matrix (for example
Builder<U, M, S>). - Several specialized impl blocks to control method availability.
PhantomDatamarker fields when a state generic carries no runtime data.
Statum keeps the same compile-time guarantees and removes most of that boilerplate.
In practice, that means cleaner code, easier extension points, and more explicit protocol intent.
Manual approach -> Statum equivalent:
- Hand-written marker types ->
#[state]generates variant marker types and state trait wiring. - Generic matrix (
Builder<U, M, S>) ->#[machine]gives a single machine type parameterized by current state. - Manual method gating -> Write methods in concrete
impl Machine<StateX>blocks. - Manual transition plumbing ->
#[transition]validates transition signatures and target states. - Manual
PhantomDatamarker plumbing ->#[machine]handles state marker tracking for you. - Manual rehydration checks ->
#[validators]maps persisted data back into typed machine states.
Minimal shape in Statum:
use ;
You still get compile-time errors when calling methods in the wrong state. The difference is that Statum lets you focus on lifecycle design instead of generic/marker plumbing.
Full guide: Typestate Builder Design Playbook (Step-by-Step).
Examples
See statum-examples/src/examples/ for the full suite of examples.
Patterns & Guidance
Decision guide: Typestate Builder Design Playbook (Step-by-Step)
Conditional transitions (branching decisions)
Transition methods must return a single next state. Put branching logic in a normal method and call explicit transition methods:
Tested in statum-examples/tests/patterns.rs (event-driven transitions).
Tip: Keep the decision enum variant names aligned with your #[state] variants. It makes matches read the same way while still carrying typed machines.
Event-driven transitions
Model events as an enum and route them to explicit transition methods:
Tested in statum-examples/tests/patterns.rs (event-driven transitions).
Guarded transitions
Keep preconditions in a guard method and return a Result before transitioning:
Tested in statum-examples/tests/patterns.rs (guarded transitions).
Hierarchical machines (state data as a nested machine)
Use a nested machine as state data to model parent and child flows:
Tested in statum-examples/tests/patterns.rs (hierarchical machines). Example: statum-examples/src/examples/11-hierarchical-machines.rs.
State snapshots (carry previous state data forward)
Capture the prior state's data inside the next state to keep history:
Tested in statum-examples/tests/patterns.rs (state snapshots).
Rollbacks / undo transitions
Model rollbacks by returning to a previous state explicitly, often with stored state data:
Tested in statum-examples/tests/patterns.rs (rollbacks). Example: statum-examples/src/examples/12-rollbacks.rs.
Async transitions (side effects before transition)
Keep side effects in async methods and call a sync transition at the end:
Tested in statum-examples/tests/patterns.rs (async side-effects). Example: statum-examples/src/examples/06-async-transitions.rs.
Rehydration with extra fetch
Use machine fields inside validators to fetch extra data for state reconstruction:
Tested in statum-examples/tests/patterns.rs (rehydration with fetch).
Persistent batches
When reconstructing many rows, use the batch builder on collections:
Tested in statum-examples/tests/patterns.rs (parallel reconstruction, batch builder). Example: statum-examples/src/examples/10-persistent-data-vecs.rs.
let results = rows
.machines_builder
.client
.build;
If you want a plain statum::Result<Vec<Machine>> without skipping invalid rows, map and collect:
let machines: Result = rows
.into_iter
.map
.collect;
Parallel reconstruction (async validators)
If validators are async, the batch builder returns results in parallel:
Tested in statum-examples/tests/patterns.rs (parallel reconstruction).
let results = rows
.machines_builder
.tenant
.build
.await;
Type-erased storage (collecting superstates)
Store *SuperState values in a collection and match later:
Tested in statum-examples/tests/patterns.rs (type-erased storage).
let items: = vec!;
for item in items
API Rules (Current)
#[state]
- Must be an enum.
- Must have at least one variant.
- Variants must be unit or single-field tuple variants.
- Generics on the enum are not supported.
#[machine]
- Must be a struct.
- First generic parameter must match the
#[state]enum name. - Derives on
#[state]are propagated to generated variant types. - Prefer
#[machine]above#[derive(..)]to avoid derive ordering surprises.
#[transition]
- Must be applied to
impl Machine<State>blocks. - Methods must take
selformut selfas the first argument. - Return type must be
Machine<NextState>orOption<Result<...>>wrappers. - Data-bearing states must use
transition_with(data).
#[validators]
- Use
#[validators(Machine)]on animplblock for your persistent data type. - Must define an
is_{state}method for every state variant (snake_case). - Each method returns
statum::Result<()>for unit states orstatum::Result<StateData>for data states. - Async validators are supported; if any validator is async, the generated builder is async.
- The
{Machine}SuperStateenum and machine-scoped module alias are generated by#[machine], and validators return that type for reconstruction (typestate builder pattern).
Common Errors and Tips
-
missing fields marker and state_data- Usually means your derive macros (e.g.,
CloneorDebug) expanded before Statum could inject those fields. Move#[machine]above your derives, or remove them.
- Usually means your derive macros (e.g.,
-
cannot find type X in this scope- Ensure that you define your
#[machine]struct before you reference it inimplblocks or function calls.
- Ensure that you define your
-
Invalid transition return type- Transition methods must return
Machine<NextState>(optionally wrapped inOptionorResult).
- Transition methods must return
API Reference
Core Macros
| Macro | Description | Example Usage |
|---|---|---|
#[state] |
Defines states as an enum. Each variant becomes its own struct implementing the State trait. |
#[state] pub enum LightState { Off, On } |
#[machine] |
Defines a state machine struct and injects fields for state tracking and transitions. | #[machine] pub struct Light<LightState> { name: String } |
#[transition] |
Validates transition methods and generates the proper transition helpers. | #[transition] impl Light<Off> { fn on(self)->Light<On>{...} } |
#[validators] |
Defines validation methods to map persistent data to specific states. | #[validators(TaskMachine)] |
State Machine Methods / Fields
| Item | Description | Example Usage |
|---|---|---|
.builder() |
Builds a new machine in a specific state. | LightSwitch::<Off>::builder().name("lamp").build() |
.transition() |
Transitions to a unit state. | let light = light.switch_on(); |
.transition_with(data) |
Transitions to a state that carries data. | self.transition_with(data) |
.state_data |
Accesses the data of the current state (if available). | let notes = &self.state_data.notes; |
Validators Output
| Item | Description | Example Usage |
|---|---|---|
{Machine}SuperState |
Wrapper enum for all machine states, used for matching. | match machine { task_machine::State::Draft(m) => ... } |
into_machine() |
Builder generated on the data type to reconstruct a machine from stored data. | row.into_machine().client(c).build() |
Type Aliases
statum::Result<T> is a convenience alias for Result<T, statum::Error>.