
Building a Typed Finite State Machine in Rust
Why a typed state machine?
A runtime state machine can reject invalid transitions only after your code runs. A typed state machine goes further: it uses distinct types for each state so the compiler can enforce the workflow.
This is useful when you want:
- Strong guarantees about lifecycle transitions
- Clear separation between states
- Safer APIs for business workflows
- Easier refactoring as the workflow grows
Examples include:
- Order processing
- Document approval
- Background job execution
- Authentication flows
- Resource provisioning
A typed design is not always the simplest option, but it pays off when invalid transitions are expensive or dangerous.
The core idea: one type per state
Instead of storing a state: Status field and checking it everywhere, we define a generic machine whose state is part of the type:
struct Machine<S> {
id: u64,
payload: String,
state: S,
}Each state is represented by a zero-sized marker type:
struct Draft;
struct Review;
struct Published;The machine can then expose methods only for the transitions that are valid from a given state. Each method consumes the current machine and returns a new one in the next state.
A practical example: article publishing workflow
Let’s model a simple publishing pipeline:
Draft: the article is being writtenReview: the article is awaiting approvalPublished: the article is public
We want these rules:
- A draft can be submitted for review
- A review can be approved or rejected
- A published article cannot transition further
- Rejected content returns to draft with feedback
Define the state types
#[derive(Debug)]
struct Draft;
#[derive(Debug)]
struct Review {
reviewer: String,
}
#[derive(Debug)]
struct Published {
published_at: String,
}Define the machine
#[derive(Debug)]
struct Article<S> {
id: u64,
title: String,
body: String,
state: S,
}Now we can implement state-specific behavior.
Implementing valid transitions
Draft to review
impl Article<Draft> {
fn new(id: u64, title: impl Into<String>, body: impl Into<String>) -> Self {
Self {
id,
title: title.into(),
body: body.into(),
state: Draft,
}
}
fn submit_for_review(self, reviewer: impl Into<String>) -> Article<Review> {
Article {
id: self.id,
title: self.title,
body: self.body,
state: Review {
reviewer: reviewer.into(),
},
}
}
}Review to published or back to draft
impl Article<Review> {
fn approve(self, published_at: impl Into<String>) -> Article<Published> {
Article {
id: self.id,
title: self.title,
body: self.body,
state: Published {
published_at: published_at.into(),
},
}
}
fn reject(self, feedback: impl Into<String>) -> Article<Draft> {
let _feedback = feedback.into();
Article {
id: self.id,
title: self.title,
body: self.body,
state: Draft,
}
}
}Read-only access across states
Some methods should be available regardless of state. Use a generic impl block:
impl<S> Article<S> {
fn id(&self) -> u64 {
self.id
}
fn title(&self) -> &str {
&self.title
}
}This gives you shared access without weakening the type safety of transitions.
Using the state machine
Here is how the workflow looks in practice:
fn main() {
let draft = Article::new(
42,
"Typed State Machines in Rust",
"This article explains how to model workflows safely.",
);
let review = draft.submit_for_review("alice");
let published = review.approve("2026-09-23T10:00:00Z");
println!(
"Published article {}: {}",
published.id(),
published.title()
);
}If you try to call approve() on a draft, the code will not compile because that method is only implemented for Article<Review>. That is the main benefit of the design.
What the compiler prevents
A typed state machine turns workflow mistakes into compile-time errors. For example:
fn invalid_flow() {
let draft = Article::new(1, "Broken flow", "...");
let _published = draft.approve("2026-09-23T10:00:00Z");
}This fails because approve() does not exist for Article<Draft>. The compiler becomes your workflow validator.
Common invalid transitions prevented by this pattern
| Mistake | Why it is blocked |
|---|---|
| Approving a draft | approve() is only implemented for Article<Review> |
| Publishing twice | Article<Published> exposes no transition methods |
| Skipping review | No direct Draft -> Published method exists |
| Reusing stale state | Transition methods consume self |
This is a major improvement over runtime checks, especially in larger codebases where workflows are easy to misuse.
Handling shared data and state-specific data
A common question is where to store data that only exists in one state. In the example above, Review stores the reviewer name, and Published stores the timestamp.
There are two common approaches:
1. Store state-specific data in the state type
This is ideal when the data is meaningful only in that state.
struct Review {
reviewer: String,
}2. Store all data in the machine and use the state as a marker
This is better when the data is shared across all states and only the behavior changes.
struct Article<S> {
id: u64,
title: String,
body: String,
state: S,
}For most workflows, a hybrid approach works best: shared data stays on the machine, and state-specific metadata lives in the state type.
Adding richer transitions
As workflows grow, transitions often need validation. For example, a review might require a minimum number of approvals.
You can encode this directly in the transition method:
impl Article<Review> {
fn approve_with_check(
self,
approved: bool,
published_at: impl Into<String>,
) -> Result<Article<Published>, Article<Review>> {
if approved {
Ok(Article {
id: self.id,
title: self.title,
body: self.body,
state: Published {
published_at: published_at.into(),
},
})
} else {
Err(self)
}
}
}This pattern is useful when a transition is possible but not guaranteed. The type system still ensures that only a review can be approved, while the method returns Result for business-rule validation.
When to use enums instead
Typed state machines are powerful, but they are not always the best choice. Sometimes a simple enum is enough.
| Approach | Best for | Tradeoff |
|---|---|---|
| Enum with runtime checks | Small workflows, flexible transitions | Invalid states are possible at runtime |
| Typed state machine | Critical workflows, strong guarantees | More types and more code |
| Hybrid design | Medium complexity systems | Requires careful API design |
Use typed states when correctness matters more than convenience. If the workflow is tiny and unlikely to evolve, an enum may be simpler.
Best practices for maintainable typed workflows
Keep transitions explicit
Avoid hidden transitions inside generic helper methods. Make state changes obvious in the API:
submit_for_review()approve()reject()
Clear naming helps developers understand the lifecycle immediately.
Consume self for transitions
Transition methods should usually take ownership of the current state and return a new one. This prevents accidental reuse of stale values.
fn approve(self, published_at: impl Into<String>) -> Article<Published>Separate state data from workflow data
If a field is only relevant in one state, keep it in that state. If it is relevant across the whole lifecycle, store it in the machine.
Use Result for business validation
The type system should enforce which transitions are possible. Business logic should enforce whether a transition is allowed right now.
Avoid overengineering
If your workflow has only two states and one transition, a typed machine may be more complexity than value. Use it where the safety benefits justify the structure.
Extending the pattern to real systems
This approach scales well to more realistic workflows. For example:
- Payment processing:
Created -> Authorized -> Captured -> Refunded - File uploads:
New -> Uploading -> Verifying -> Ready - Provisioning:
Pending -> Running -> Draining -> Terminated
The implementation pattern stays the same:
- Define a type per state
- Put shared data in the generic machine
- Implement valid transitions only on the appropriate state
- Use
Resultwhen transitions can fail for business reasons
You can also combine this with traits if multiple machines share similar lifecycle behavior, but start simple and add abstraction only when it reduces duplication.
Summary
A typed finite state machine in Rust gives you compile-time enforcement of workflow rules. By encoding state in the type parameter and exposing only valid transitions, you can prevent entire classes of bugs before the program runs.
This pattern is especially effective for business processes with strict lifecycles, such as publishing, approvals, provisioning, and job execution. It adds structure, improves API clarity, and makes invalid states harder to represent.
