statum-core 0.3.12

Compile-time state machine magic for Rust: Zero-boilerplate typestate patterns with automatic transition validation
Documentation

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

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 statum::{machine, state, transition};

// 1. Define your states as an enum.
#[state]
pub enum LightState {
    Off,
    On,
}

// 2. Define your machine with the #[machine] attribute.
#[machine]
pub struct LightSwitch<LightState> {
    name: String, // Contextual, machine-wide fields go here.
}

// 3. Implement transitions for each state.
#[transition]
impl LightSwitch<Off> {
    pub fn switch_on(self) -> LightSwitch<On> {
        self.transition()
    }
}

#[transition]
impl LightSwitch<On> {
    pub fn switch_off(self) -> LightSwitch<Off> {
        self.transition()
    }
}

fn main() {
    // 4. Create a machine with the "Off" state.
    let light = LightSwitch::<Off>::builder()
        .name("desk lamp".to_owned())
        .build();

    // 5. Transition from Off -> On, On -> Off, etc.
    let light = light.switch_on(); // type: LightSwitch<On>
    let _light = light.switch_off(); // type: LightSwitch<Off>
}

Example: statum-examples/src/examples/example_01_setup.rs.

How It Works

  • #[state] transforms your enum, generating one struct per variant (like Off and On), plus a trait LightState.
  • #[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:

#[state]
#[derive(Debug, Clone)]
pub enum LightState {
    Off,
    On,
}

#[machine]
#[derive(Debug, Clone)]
pub struct LightSwitch<LightState> {
    name: String,
}

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
#[derive(Debug)] // note the position of the derive
#[machine]
pub struct LightSwitch<LightState>;

// ✅ This will work
#[machine]
#[derive(Debug)]
pub struct LightSwitch<LightState>;

Example: statum-examples/src/examples/03-derives.rs.


2. Complex Transitions & Data-Bearing States

Defining State Data

States can hold data. For example:

#[state]
pub enum ReviewState {
    Draft,
    InReview(ReviewData), // State data
    Published,
}

pub struct ReviewData {
    reviewer: String,
    notes: Vec<String>,
}

#[machine]
pub struct Document<ReviewState> {
    id: String,
}

#[transition]
impl Document<Draft> {
    pub fn submit_for_review(self, reviewer: String) -> Document<InReview> {
        let data = ReviewData { reviewer, notes: vec![] };
        self.transition_with(data)
    }
}

Note: We use self.transition_with(data) instead of self.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:

impl Document<InReview> {
    fn add_note(&mut self, note: String) {
        self.state_data.notes.push(note);
    }

    fn approve(self) -> Document<Published> {
        self.transition()
    }
}

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 (with machine_builder() kept for compatibility).

Example

use statum::{machine, state, validators};

#[state]
pub enum TaskState {
    Draft,
    InReview(ReviewData),
    Published,
}

pub struct ReviewData {
    reviewer: String,
}

#[machine]
struct TaskMachine<TaskState> {
    client: String,
    name: String,
    priority: u8,
}

enum Status {
    Draft,
    InReview,
    Published,
}

struct DbData {
    state: Status,
}

#[validators(TaskMachine)]
impl DbData {
    fn is_draft(&self) -> statum::Result<()> {
        match self.state {
            Status::Draft => {
                // Note: machine fields are available here (client, name, priority).
                println!("Client: {}, Name: {}, Priority: {}", client, name, priority);
                Ok(())
            }
            _ => Err(statum::Error::InvalidState),
        }
    }

    fn is_in_review(&self) -> statum::Result<ReviewData> {
        match self.state {
            Status::InReview => Ok(ReviewData { reviewer: "sam".into() }),
            _ => Err(statum::Error::InvalidState),
        }
    }

    fn is_published(&self) -> statum::Result<()> {
        match self.state {
            Status::Published => Ok(()),
            _ => Err(statum::Error::InvalidState),
        }
    }
}

fn main() {
    let db_data = DbData { state: Status::InReview };

    let machine = db_data
        .into_machine()
        .client("acme".to_owned())
        .name("doc".to_owned())
        .priority(1)
        .build()
        .unwrap();

    match machine {
        task_machine::State::Draft(_draft_machine) => { /* ... */ }
        task_machine::State::InReview(_in_review_machine) => { /* ... */ }
        task_machine::State::Published(_published_machine) => { /* ... */ }
    }
}

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:

pub mod task_machine {
    pub type State = TaskMachineSuperState;
}

You can use it to shorten matches without introducing collisions:

fn rebuild_task(row: &DbData) -> statum::Result<task_machine::State> {
    row.into_machine()
        .client("acme".to_owned())
        .name("doc".to_owned())
        .priority(1)
        .build()
}

let row = DbData { state: Status::Draft };

match rebuild_task(&row)? {
    task_machine::State::Draft(m) => { /* ... */ }
    task_machine::State::InReview(m) => { /* ... */ }
    task_machine::State::Published(m) => { /* ... */ }
}

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:

  1. Identify the staged entity and write its lifecycle in plain language.
  2. Define finite states first with #[state] (and attach phase-specific data only where it is truly invariant).
  3. Define machine context with #[machine] (long-lived dependencies, IDs, shared context).
  4. Encode legal transitions with #[transition] on concrete source states.
  5. Keep runtime branching in normal methods, then dispatch into explicit transition methods.
  6. Use #[validators] to safely rehydrate from persistence back into typed machine states.
  7. Keep a hybrid boundary: stable protocol core in typestate, highly dynamic edges in runtime validation.

Use this compact checklist to decide fit quickly:

  1. Finite states exist.
  2. Legal transitions are mostly compile-time known.
  3. Invalid transitions are expensive.
  4. API behavior differs by state.
  5. Some data is only valid in specific states.
  6. 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:

  1. Multiple marker/data state types (NoUrl, Url, NoMethod, Method, Sealed, NotSealed).
  2. A generic builder with a type matrix (for example Builder<U, M, S>).
  3. Several specialized impl blocks to control method availability.
  4. PhantomData marker 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 PhantomData marker 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 statum::{machine, state, transition};

struct UrlOnly {
    url: String,
}

struct MethodOnly {
    method: String,
}

struct ReadyData {
    url: String,
    method: String,
}

#[state]
enum RequestState {
    MissingBoth,
    HasUrl(UrlOnly),
    HasMethod(MethodOnly),
    Ready(ReadyData),
}

#[machine]
struct RequestBuilder<RequestState> {
    headers: Vec<(String, String)>,
}

#[transition]
impl RequestBuilder<HasUrl> {
    fn set_method(self, method: String) -> RequestBuilder<Ready> {
        let url = self.state_data.url.clone();
        self.transition_with(ReadyData { url, method })
    }
}

impl RequestBuilder<Ready> {
    fn build(self) -> Request {
        // build is only available in Ready state
        Request {}
    }
}

struct Request {}

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.

#[transition]
impl ProcessMachine<Init> {
    fn to_next(self) -> ProcessMachine<NextState> {
        self.transition()
    }

    fn to_other(self) -> ProcessMachine<OtherState> {
        self.transition()
    }
}

enum Decision {
    Next(ProcessMachine<NextState>),
    Other(ProcessMachine<OtherState>),
}

impl ProcessMachine<Init> {
    fn decide(self, event: u8) -> Decision {
        if event == 0 {
            Decision::Next(self.to_next())
        } else {
            Decision::Other(self.to_other())
        }
    }
}

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).

enum Event {
    Go,
    Alternative,
}

enum Decision {
    Next(ProcessMachine<NextState>),
    Other(ProcessMachine<OtherState>),
}

impl ProcessMachine<Init> {
    fn handle_event(self, event: Event) -> Decision {
        match event {
            Event::Go => Decision::Next(self.to_next()),
            Event::Alternative => Decision::Other(self.to_other()),
        }
    }
}

Guarded transitions

Keep preconditions in a guard method and return a Result before transitioning:

Tested in statum-examples/tests/patterns.rs (guarded transitions).

impl Machine<Pending> {
    fn can_activate(&self) -> bool {
        self.allowed
    }

    fn try_activate(self) -> statum::Result<Machine<Active>> {
        if self.can_activate() {
            Ok(self.activate())
        } else {
            Err(statum::Error::InvalidState)
        }
    }
}

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]
enum SubState {
    Idle,
    Running,
}

#[machine]
struct SubMachine<SubState> {}

#[state]
enum ParentState {
    NotStarted,
    InProgress(SubMachine<Running>),
    Done,
}

#[machine]
struct ParentMachine<ParentState> {}

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).

#[transition]
impl Machine<Draft> {
    fn publish(self) -> Machine<Published> {
        let previous = self.state_data.clone();
        self.transition_with(PublishData { previous })
    }
}

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.

#[transition]
impl Document<Published> {
    fn rollback(self) -> Document<Draft> {
        self.transition()
    }
}

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.

#[transition]
impl Job<Queued> {
    fn start(self) -> Job<Running> {
        self.transition()
    }
}

impl Job<Queued> {
    async fn start_with_effects(self) -> Job<Running> {
        do_io().await;
        self.start()
    }
}

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).

#[validators(TaskMachine)]
impl DbData {
    fn is_in_review(&self) -> statum::Result<ReviewData> {
        match self.state {
            Status::InReview => Ok(ReviewData { reviewer: fetch_reviewer(client) }),
            _ => Err(statum::Error::InvalidState),
        }
    }
}

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(client)
    .build();

If you want a plain statum::Result<Vec<Machine>> without skipping invalid rows, map and collect:

let machines: statum::Result<Vec<task_machine::State>> = rows
    .into_iter()
    .map(|row| {
        row.into_machine()
            .client(client.clone())
            .name(name.clone())
            .priority(priority)
            .build()
    })
    .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(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<task_machine::State> = vec![machine];
for item in items {
    match item {
        task_machine::State::Draft(m) => { /* ... */ }
        _ => {}
    }
}

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 self or mut self as the first argument.
  • Return type must be Machine<NextState> or Option<Result<...>> wrappers.
  • Data-bearing states must use transition_with(data).

#[validators]

  • Use #[validators(Machine)] on an impl block 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 or statum::Result<StateData> for data states.
  • Async validators are supported; if any validator is async, the generated builder is async.
  • The {Machine}SuperState enum and machine-scoped module alias are generated by #[machine], and validators return that type for reconstruction (typestate builder pattern).

Common Errors and Tips

  1. missing fields marker and state_data

    • Usually means your derive macros (e.g., Clone or Debug) expanded before Statum could inject those fields. Move #[machine] above your derives, or remove them.
  2. cannot find type X in this scope

    • Ensure that you define your #[machine] struct before you reference it in impl blocks or function calls.
  3. Invalid transition return type

    • Transition methods must return Machine<NextState> (optionally wrapped in Option or Result).

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