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 written
  • Review: the article is awaiting approval
  • Published: 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

MistakeWhy it is blocked
Approving a draftapprove() is only implemented for Article<Review>
Publishing twiceArticle<Published> exposes no transition methods
Skipping reviewNo direct Draft -> Published method exists
Reusing stale stateTransition 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.

ApproachBest forTradeoff
Enum with runtime checksSmall workflows, flexible transitionsInvalid states are possible at runtime
Typed state machineCritical workflows, strong guaranteesMore types and more code
Hybrid designMedium complexity systemsRequires 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:

  1. Define a type per state
  2. Put shared data in the generic machine
  3. Implement valid transitions only on the appropriate state
  4. Use Result when 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.

Learn more with useful resources