3 ms·
Statum: Zero-Boilerplate Compile-Time State Machines in Rust
- eboody 2y agoHey Rustaceans! I've created a library called *Statum* for building type-safe state machines in Rust. It ensures invalid state transitions are caught at *compile time*, giving you extra safety with minimal boilerplate. --- ## Quick Example (Minimal) ```rust use statum::{state, machine}; #[state] pub enum TaskState { New, InProgress, Complete, } #[machine] struct Task<S: TaskState> { id: String, name: String, } impl Task<New> { fn start(self) -> Task<InProgress> { self.transition() } } impl Task<InProgress> { fn complete(self) -> Task<Complete> { self.transition() } } fn main() { let task = Task::new("task-1".to_owned(), "Important Task".to_owned()) .start() .complete(); } ``` ### How it works - *`#[state]`* transforms your enum variants into separate structs while generating a trait to represent the states. - *`#[machine]`* injects some extra fields to track which state your machine is in, at the type level. --- ## States with Data Need states to carry extra information? ```rust #[state] pub enum DocumentState { Draft, Review(ReviewData), // State with associated data Published, } struct ReviewData { reviewer: String, comments: Vec<String>, } impl Document<Draft> { fn submit_for_review(self, reviewer: String) -> Document<Review> { // Transition to `Review` with data: self.transition_with(ReviewData { reviewer, comments: vec![], }) } } ``` ### Accessing State Data When your state has associated data, use `.get_state_data()` or `.get_state_data_mut()`: ```rust impl Document<Review> { fn add_comment(&mut self, comment: String) { if let Some(review_data) = self.get_state_data_mut() { review_data.comments.push(comment); } } fn get_reviewer(&self) -> Option<&str> { self.get_state_data().map(|data| data.reviewer.as_str()) } fn approve(self) -> Document<Published> { self.transition() } } ``` --- ## Why Statum? Many Rust state machine libraries need: - Complex trait impls - Manual checks for transitions - Runtime guards for state data *Statum* does all of this at compile time with a straightforward API. --- ## Gotchas: Attribute Ordering - *`#[machine]`* must come before any user `#[derive(...)]` on the same struct, or you may see: ``` error[E0063]: missing fields `marker` and `state_data` ``` This is because the derive macros expand before Statum can inject its hidden fields. --- ## Feedback Welcome! I'm actively developing Statum and would love community input: - Which patterns do you commonly use for state machines? - Features you’d like to see added? - Ideas to make the API smoother? --- ## Installation ```bash cargo add statum ``` Check it out on [GitHub](https://github.com/eboody/statum https://github.com/eboody/statum) or [Crates.io](https://crates.io/crates/statum https://crates.io/crates/statum). Let me know what you think!
- bfrog 2y agoOne of the issue with type states if you aren’t aware is the state of each type needs to then be a type parameter of the parent type. Meaning creating a dynamic number of these state machines would be frustrating. Do you have some thoughts or samples of say a Vec of Lights as an extension of the sample tou have?
- eboody 2y agoGreat question! If i understood you correctly, you’re asking about creating a `Vec` of state machines and operating on them. With the type-state pattern, this can be tricky because each state is a distinct type (`Light<Off>` and `Light<On>`), and `Vec` requires all elements to have the same type. To directly manage a `Vec` of state machines, you’d need dynamic dispatch (e.g., `Box<dyn ...>`), but as you mentioned, dynamic dispatch can be a giant pain in the ass. Instead of directly managing a `Vec` of state machines (`Vec<Light<Box<dyn ...>>>`), my approach is to work with a dynamic collection of raw data (e.g., `Vec<DbData>`) and reconstruct state machines on the fly using the `to_machine` method generated by `#[validators]`. Why? 1. You don’t need to store state machines directly in a collection. 2. The `to_machine` method dynamically reconstructs the appropriate state machine from raw data at runtime. 3.You still retain `Statum`’s compile-time guarantees for state transitions once the machine is reconstructed. Here’s what i mean: use statum::{machine, state, validators}; #[state] pub enum LightState { Off, On, } #[machine] pub struct LightMachine<S: LightState> { name: String, } impl LightMachine<Off> { pub fn switch_on(self) -> LightMachine<On> { self.transition() } } impl LightMachine<On> { pub fn switch_off(self) -> LightMachine<Off> { self.transition() } } // Represents dynamic data #[derive(Clone)] pub struct DbData { id: String, state: String, } #[validators(state = LightState, machine = LightMachine)] impl DbData { fn is_off(&self) -> Result<(), statum::Error> { if self.state == "off" { Ok(()) } else { Err(statum::Error::InvalidState) } } fn is_on(&self) -> Result<(), statum::Error> { if self.state == "on" { Ok(()) } else { Err(statum::Error::InvalidState) } } } fn main() { // raw data collection let db_data = vec![ DbData { id: "1".to_owned(), state: "off".to_owned(), }, DbData { id: "2".to_owned(), state: "on".to_owned(), }, ]; // do stuff with vec // dynamically reconstruct and operate on state machines for data in db_data { let machine = data .to_machine("Light".to_owned()) // Pass shared data here .unwrap(); // Handle errors if needed match machine { LightMachineState::Off(light) => handle_off_light(light), LightMachineState::On(light) => handle_on_light(light), } } } fn handle_off_light(light: LightMachine<Off>) { println!("Turning light on: {}", light.name); } fn handle_on_light(light: LightMachine<On>) { println!("Turning light offf: {}", light.name); } This approach is kind of indirect but it solves the underlying need: operating on many state machines dynamically without sacrificing `Statum`’s type safety. Did this address your question? If so, I’ll add it to the README—this is a great point worth documenting!