Rust developers already accept one important move: if something matters to correctness, it should be visible in the type system.
That is what Option does for absence. That is what Result does for failure. They do not eliminate every bug, but they eliminate a specific class of bugs by making an important distinction impossible to ignore.
Workflow state often deserves the same treatment.
A draft is not the same thing as a published document. An unauthenticated connection is not the same thing as an authenticated one. A projected row from storage is not the same thing as a machine that has already proved it belongs to one legal state.
And yet ordinary code often collapses those distinctions into a status enum, a few optional fields, and comments about what "should" be true.
Statum exists to stop doing that.
The Real Goal: Representational Correctness
Statum is a Rust typestate framework, but the more useful description is simpler: it is a tool for representational correctness.
That is just a question of how accurately code models the thing it claims to model.
If a workflow has legally different phases, those phases should not look like the same value plus conventions. They should look different in the type system too.
A common model looks like this:
enum Status {
Draft,
InReview,
Published,
}
struct Article {
status: Status,
reviewer: Option<String>,
published_at: Option<String>,
}
This is compact, but it can represent combinations that should never exist:
- a draft with
published_at - a published article with no publication data
- an in-review article with no reviewer
The type system cannot help much because all of those states have the same shape.
Statum takes the opposite approach. If a state is legally different, it should be a different type.
What That Looks Like
use statum::{machine, state, transition};
#[state]
enum ArticleState {
Draft,
InReview(ReviewAssignment),
Published(PublishedReceipt),
}
struct ReviewAssignment {
reviewer: String,
}
struct PublishedReceipt {
published_at: String,
}
#[machine]
struct Article<ArticleState> {
id: String,
title: String,
body: String,
}
#[transition]
impl Article<Draft> {
fn submit(self, reviewer: String) -> Article<InReview> {
self.transition_with(ReviewAssignment { reviewer })
}
}
#[transition]
impl Article<InReview> {
fn approve(self, published_at: String) -> Article<Published> {
self.transition_with(PublishedReceipt { published_at })
}
}
This changes the shape of the API in useful ways:
-
Article<Draft>andArticle<Published>are different types -
submit()only exists where it is legal -
approve()only exists where it is legal - review data only exists during review
- publication data only exists after publication
That is the real payoff. Legal states stop looking like raw data plus comments. They become distinct types with distinct operations and distinct valid data.
Where Statum Fits
Statum is a good fit when:
- methods should only exist in some phases
- some data is valid in exactly one state
- transitions should be explicit
- correctness depends on distinguishing legal, illegal, and not-yet-validated states
Statum is a bad fit when the state label is mostly descriptive and does not carry real behavioral or data-shape constraints. Not every status enum should be turned into typestate.
The Unusual Part: Typed Rehydration
The most interesting part of Statum is what happens when the state does not start life as a freshly built machine.
Real systems have database rows, append-only event streams, and projected snapshots. Those inputs are raw facts, not typed machines.
Statum keeps that distinction sharp.
With #[validators], a persisted type can prove which state it belongs to, and only then does it rebuild into a typed machine. Raw rows stay raw until they have earned that upgrade.
That matters because the most dangerous bugs often happen at the boundaries:
- deserializing persisted rows
- replaying events
- reconstructing workflows from storage
- assuming a status label is enough to trust the rest of the payload
That boundary is why the rebuild API exists. Today the crate supports:
into_machine()into_machines()into_machines_by(...)- event projection helpers in
statum::projection
The rule is simple: a projected row or event-derived snapshot should not become a typed workflow value until the data has proved which legal state it belongs to.
The Mental Model
#[state] -> lifecycle phases
#[machine] -> durable machine context
#[transition] -> legal edges
#[validators] -> typed rehydration from stored data
That is most of the model.
If the workflow shape fits, the API stays small and the safety payoff is real.
If You Want To Evaluate Statum
These are the best entry points:
- GitHub: https://github.com/eboody/statum
- Docs: https://docs.rs/statum
- Start here: https://github.com/eboody/statum/blob/main/docs/start-here.md
- Event-log case study: https://github.com/eboody/statum/blob/main/docs/case-study-event-log-rebuild.md
The question I would start with is this:
Does this workflow have legal states that should be impossible to misrepresent in code?
If the answer is yes, typestate can be worth the extra explicitness.
If you have built similar Rust workflows, I would be most interested in two things: where this feels simpler than handwritten typestate, and where it still feels heavier than it should.
Top comments (0)