🦀 Statum: Zero-Boilerplate Compile-Time State Machines in Rust

183 points Known_Cod8398 31 comments

Hey Rustaceans! 👋

I’ve been working on a library called Statum for creating type-safe state machines in Rust.

With Statum, invalid state transitions are caught at compile time, and the current 0.5 line also makes it possible to rebuild typed machines from persisted rows or projected event streams.

Why Use Statum?

The point is correctness. Some workflows/abstractions are best modeled as a sequence of states where only certain methods can exist in each phase. Statum lets you encode that model directly and have the API enforced at compile time.

  • Compile-Time Safety: transitions are validated at compile time, cutting out a whole class of runtime bugs.
  • Ergonomic Macros: define states, machines, transitions, and rebuilds with #[state], #[machine], #[transition], and #[validators].
  • Typed Rebuilds: rebuild typed machines from DB rows with into_machine(), .into_machines(), and .into_machines_by(...).
  • Event-Log Friendly: use statum::projection to reduce append-only event streams before typed rebuilds.
  • State-Specific Data: keep data attached only to the states where it is valid.

Quick Example

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

#[state]
pub enum ArticleState {
    Draft,
    InReview(ReviewAssignment),
    Published(PublishedReceipt),
}

pub struct ReviewAssignment {
    reviewer: String,
}

pub struct PublishedReceipt {
    published_at: String,
}

#[machine]
pub struct Article<ArticleState> {
    id: String,
    title: String,
    body: String,
}

impl Article<Draft> {
    pub fn edit_body(mut self, body: impl Into<String>) -> Self {
        self.body = body.into();
        self
    }
}

#[transition]
impl Article<Draft> {
    pub fn submit(self, reviewer: String) -> Article<InReview> {
        self.transition_with(ReviewAssignment { reviewer })
    }
}

impl Article<InReview> {
    pub fn reviewer(&self) -> &str {
        &self.state_data.reviewer
    }
}

#[transition]
impl Article<InReview> {
    pub fn approve(self, published_at: String) -> Article<Published> {
        self.transition_with(PublishedReceipt { published_at })
    }
}

impl Article<Published> {
    pub fn public_url(&self) -> String {
        format!("/articles/{}", self.id)
    }
}

fn main() {
    let draft = Article::<Draft>::builder()
        .id("post-1".to_owned())
        .title("Why Typestate Helps".to_owned())
        .body("Draft body".to_owned())
        .build()
        .edit_body("Final body".to_owned());
    // draft is Article<Draft>

    let review = draft.submit("alice".to_owned());
    // review is Article<InReview>

    let article = review.approve("2026-03-17T12:00:00Z".to_owned());
    // article is Article<Published>

    assert_eq!(article.public_url(), "/articles/post-1");
}

New Since Last Time

  • a cleaner rebuild API around into_machine(), .into_machines(), and .into_machines_by(...)
  • statum::projection for append-only / event-log workflows before typed rebuilds
  • better macro diagnostics and a fuller example set, including Axum, CLI, worker, event-log, and protocol examples
  • ergonomics fixes so normal imported or module-scoped types in #[machine] fields work as expected

How It Works

  • #[state]: turns your enum variants into marker types and the trait surface for valid states.
  • #[machine]: adds compile-time state tracking to your machine type.
  • #[transition]: defines legal edges and transition helpers like .transition() and .transition_with(...).
  • #[validators]: rebuilds typed machines from stored rows or projected event streams.

Want to dive deeper? Check out:

Feedback and contributions are MORE THAN welcome, especially on the rebuild API and persistence side. If you’ve built similar typestate workflows in Rust, I’d love to hear where this feels clean and where it still feels awkward.