Skip to content
rusthacks.dev
Architecture

State Machines in Rust With Enums and Typestate Explained

Model states as an enum when they change at run time and as types when the order is known at compile time. See both versions of a connection and which one to pick.

3 min read
Summarize in AI
Flat illustration for the post “State Machines in Rust With Enums and Typestate Explained”, composed around enums.

A document is a draft, then in review, then published. Code that checks if doc.status == "review" in forty places eventually checks it wrong in one. Rust gives two tools for making the states part of the type system. The enum version is the everyday one. The typestate version is the one that makes bugs impossible to compile. This post shows both for a connection and says when each fits. It is a design topic under organizing Rust projects.

Rust 1.90, edition 2024.

Enum states

rust
#[derive(Debug)]
pub enum Conn {
    Closed,
    Open { session: u32 },
    Failed { reason: String },
}

impl Conn {
    pub fn open(self) -> Conn {
        match self {
            Conn::Closed => Conn::Open { session: 1 },
            other => other,
        }
    }

    pub fn close(self) -> Conn {
        match self {
            Conn::Open { .. } => Conn::Closed,
            other => other,
        }
    }
}

session exists only in Open, so no code can read a session from a closed connection. Transitions take self and return the new state. The match forces handling every variant, and adding Reconnecting breaks the build wherever a case is missing. An invalid transition here is a no-op; return Result if it should be an error.

Enums are what you store and load: a column named status maps to variants, and a collection of orders in different states is Vec<Order>.

Typestate

rust
use std::marker::PhantomData;

pub struct Closed;
pub struct Open;

pub struct Socket<State> {
    addr: String,
    _state: PhantomData<State>,
}

impl Socket<Closed> {
    pub fn new(addr: &str) -> Self {
        Socket { addr: addr.to_string(), _state: PhantomData }
    }
    pub fn open(self) -> Socket<Open> {
        Socket { addr: self.addr, _state: PhantomData }
    }
}

impl Socket<Open> {
    pub fn send(&self, data: &[u8]) -> usize { data.len() }
    pub fn close(self) -> Socket<Closed> {
        Socket { addr: self.addr, _state: PhantomData }
    }
}

Socket::new("x").send(b"hi") does not compile: send is not defined for Socket<Closed>. The error is E0599, no method found, which is the compiler enforcing your protocol. PhantomData takes no space, so Socket<Open> and Socket<Closed> have the same layout.

Choosing

SituationUse
State read from a database or messageenum
Many values in different states in one collectionenum
A sequence in code: connect, authenticate, querytypestate
Builder where build needs fields set firsttypestate
Need to serialize the stateenum

The cost of typestate

Generic parameters spread through signatures, error messages get longer, and a function that returns "either state" needs an enum anyway. Use it where the bug it prevents is expensive: a protocol client that can send before authenticating, a payment flow that can capture before authorizing.

Where this stops

State machines with many transitions and guards become tables; a crate may help, but a match on (state, event) is often clearer. For the design context, see domain driven design in Rust, and for the builder application, public API design. The Rust Embedded Book describes the typestate pattern well.

Also inGenerics
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.