8 Rust Typestate Patterns That Turn Illegal States Into Compiler Errors
Learn how to build type-safe state machines in Rust using 8 proven patterns — from typestate to const generics. Stop runtime bugs before they start. Read the full guide.
Imagine you are opening a door. You cannot close it if it is already closed. You cannot open it if it is already open. That is the simplest state machine you will ever see. When I first started writing Rust, I did not trust the compiler to help me with rules like this. I used boolean flags. I had a bool for is_open, and I wrote code that checked it. Then I forgot one check. A user closed a closed door and the system did something silly. The bug was small, but it taught me a lesson. State should live in types, not in booleans.
A state machine is just a collection of states and allowed moves. You are in one state at a time. You can only move along an allowed edge. If a move is not allowed, the program should refuse before it runs. Rust can do this because the type system can represent the state as part of the type. If you try to make an illegal transition, the compiler stops you. This is often called making illegal states unrepresentable. It is one of my favorite Rust ideas.
In this article, I will show you eight patterns for building type-safe state machines in Rust. I will keep things simple. Each pattern builds on the same idea: a state is not a value you check. A state is a type you live in.
The first pattern is the typestate pattern. You create one struct and give it a type parameter that represents its state. The state itself is an empty marker type. The struct uses PhantomData to remember that state without storing it in memory. Let me show you.
use std::marker::PhantomData;
struct Door<State> {
is_locked: bool,
_state: PhantomData<State>,
}
struct Closed;
struct Open;
impl Door<Closed> {
fn new() -> Self {
Door {
is_locked: true,
_state: PhantomData,
}
}
fn open(self) -> Door<Open> {
Door {
is_locked: false,
_state: PhantomData,
}
}
}
impl Door<Open> {
fn close(self) -> Door<Closed> {
Door {
is_locked: true,
_state: PhantomData,
}
}
}
The Door<Closed> type is different from Door<Open>. open only exists on Door<Closed>. close only exists on Door<Open>. If you write this code, it will not compile.
let door = Door::new();
door.open().open();
The compiler will say something like “no method named open found for Door<Open>”. That is exactly what we want. The second open is an illegal move, and it failed at compile time.
The PhantomData part is important. The State type parameter is not used inside the struct definition. Rust wants us to either use it or mark it as phantom. We use it only to tell the compiler that the type depends on the state. It costs zero bytes at runtime.
The second pattern is consuming self. This is the heart of many Rust state machines. A transition method takes self by value, not by reference. When you call it, the old state is moved into the method and destroyed. You cannot use the old state afterwards. This is called linear typing in the wild, but in Rust it just means ownership.
I first used this pattern when I was building a small network connection. I had two states: disconnected and connected. send only made sense after connecting. I wanted to make it impossible to send before connecting. Here is how I did it.
use std::marker::PhantomData;
struct Disconnected;
struct Connected;
struct NetworkConnection<State> {
address: String,
_state: PhantomData<State>,
}
impl NetworkConnection<Disconnected> {
fn new(address: String) -> Self {
NetworkConnection {
address,
_state: PhantomData,
}
}
fn connect(self) -> NetworkConnection<Connected> {
println!("Connecting to {}", self.address);
NetworkConnection {
address: self.address,
_state: PhantomData,
}
}
}
impl NetworkConnection<Connected> {
fn send(&self, data: &[u8]) {
println!("Sending {:?}", data);
}
fn disconnect(self) -> NetworkConnection<Disconnected> {
NetworkConnection {
address: self.address,
_state: PhantomData,
}
}
}
The method connect takes self and returns a new type. The old disconnected object is gone. After disconnect, you get a connected object back? No, wait. You get a disconnected object after calling disconnect on a connected object. This makes the allowed moves clear. You cannot call send on a disconnected object because send does not exist on that type.
This pattern is so simple that it can feel strange at first. Why would you want to destroy the old object? Because that is exactly what a state transition is. The old state no longer exists. The new state is a different thing.
The third pattern is trait-based transitions. Sometimes you want a more general way to describe what a transition does. You can define a trait with an associated type for the next state. Then each state implements that trait.
Let me show you with a document workflow. A draft can become a reviewed document. A reviewed document can become a published document. A draft cannot become published directly.
use std::marker::PhantomData;
struct Draft;
struct Reviewed;
struct Published;
struct Article<State> {
title: String,
content: String,
_state: PhantomData<State>,
}
trait Transition {
type NextState;
fn transition(self) -> Article<Self::NextState>;
}
impl Transition for Article<Draft> {
type NextState = Reviewed;
fn transition(self) -> Article<Reviewed> {
Article {
title: self.title,
content: self.content,
_state: PhantomData,
}
}
}
impl Transition for Article<Reviewed> {
type NextState = Published;
fn transition(self) -> Article<Published> {
Article {
title: self.title,
content: self.content,
_state: PhantomData,
}
}
}
Now you can write generic functions that accept any article that can transition. For example, any Article<State> where State: Transition can be transitioned. But the type system still prevents a draft from becoming published directly. There is no Transition implementation for Article<Published>, so a published article cannot transition at all.
This trait pattern is useful when you have several state machines with the same shape. You can write one function that works for all of them as long as they implement the same transition trait.
The fourth pattern is sealing states with a private module. Sometimes you want the state types to be public, but you do not want outside code to create them. You can create a state machine module and keep the state constructors private. This way no one can start in an invalid state.
Let me show you a simple version.
mod door {
use std::marker::PhantomData;
pub struct Door<State> {
_state: PhantomData<State>,
}
pub mod states {
pub struct Closed(());
pub struct Open(());
impl Closed {
pub(crate) fn new() -> Self {
Closed(())
}
}
impl Open {
pub(crate) fn new() -> Self {
Open(())
}
}
}
impl Door<states::Closed> {
pub fn new() -> Self {
Door {
_state: PhantomData,
}
}
pub fn open(self) -> Door<states::Open> {
Door {
_state: PhantomData,
}
}
}
impl Door<states::Open> {
pub fn close(self) -> Door<states::Closed> {
Door {
_state: PhantomData,
}
}
}
}
The states module is public, so you can name the state types in function signatures. But the Closed and Open structs have private fields. Outside code cannot write Closed(()). Only the module can create a state. This is a strong seal. It means every legal state machine must begin by calling Door::new(). There is no other way in.
I love this pattern for libraries. It gives you the compile-time safety of typestate without leaking constructors into the public API. If you ever see a state type that can be created freely, the state machine is not truly sealed.
The fifth pattern is state-dependent APIs. In Rust, you can use traits to give methods to only certain states. This is different from typestate. Typestate uses a struct with a type parameter. State-dependent APIs use trait implementations to decide which methods exist.
Here is an example. A coffee machine can be on or off. When it is on, you can brew coffee. When it is off, you can turn it on. But you cannot brew while it is off.
use std::marker::PhantomData;
struct On;
struct Off;
struct CoffeeMachine<State> {
beans: u32,
_state: PhantomData<State>,
}
trait CanBrew {
fn brew(&self, cups: u32);
}
impl CanBrew for CoffeeMachine<On> {
fn brew(&self, cups: u32) {
println!("Brewing {} cups with {} beans", cups, self.beans);
}
}
impl CoffeeMachine<Off> {
fn turn_on(self) -> CoffeeMachine<On> {
CoffeeMachine {
beans: self.beans,
_state: PhantomData,
}
}
}
impl CoffeeMachine<On> {
fn turn_off(self) -> CoffeeMachine<Off> {
CoffeeMachine {
beans: self.beans,
_state: PhantomData,
}
}
}
If a function only wants to accept a machine that can brew, it can take &impl CanBrew. That function can only receive a machine in the On state. This is a simple way to make state-dependent behavior reusable. You do not have to put every method in one giant impl block. You can put shared behavior in traits and implement those traits only for valid states.
I use this pattern when I want to keep my impl blocks small. It also makes the code easier to read. When I see a trait named CanBrew, I immediately know what rules apply.
The sixth pattern is using Option and take to move data out of a borrowed machine. Sometimes you cannot consume self because the state machine lives inside a collection. You only have &mut self. In that case, you need a way to move the current state out, change it, and put a new state back.
Let me look at a worker that is either idle or busy. If it is busy, it has a job id. You want to reset it to idle. You cannot move the whole worker because it is inside a vector. You can use Option to hold the internal state and use take to remove it.
struct Worker {
name: String,
slot: Slot,
}
enum Slot {
Empty,
Idle,
Busy { job_id: u32 },
}
impl Worker {
fn new(name: String) -> Self {
Worker {
name,
slot: Slot::Idle,
}
}
fn start_job(&mut self, job_id: u32) {
let old = std::mem::replace(&mut self.slot, Slot::Empty);
match old {
Slot::Idle => self.slot = Slot::Busy { job_id },
other => self.slot = other,
}
}
fn finish_job(&mut self) -> Option<u32> {
let old = std::mem::replace(&mut self.slot, Slot::Empty);
match old {
Slot::Busy { job_id } => {
self.slot = Slot::Idle;
Some(job_id)
}
other => {
self.slot = other;
None
}
}
}
}
This pattern is a little less strict because the internal state is an enum. You can still check illegal transitions. But you are not using the compiler to prevent them. That is okay. Sometimes ergonomics matter more than absolute compile-time proof. You can hide this enum inside a private module and expose only methods. Then the outside world still has a safe API.
I would not start with this pattern. Use it only when you must update the state in place, for example inside a Vec, a HashMap, or a callback context. The important part is that you use mem::replace to avoid leaving an invalid state behind. If a transition fails, you put the old state back.
The seventh pattern is type-level state machines with const generics. This is a more advanced pattern. Instead of using empty types like Closed and Open, you use an integer constant to represent the state. This can be useful when you have a small set of states and you want to write generic code across all of them.
Here is a simple light switch with three states: off, dim, and bright. I will use a const STATE parameter.
struct Light<const STATE: u8>;
impl Light<0> {
fn turn_on(self) -> Light<1> {
Light
}
}
impl Light<1> {
fn turn_up(self) -> Light<2> {
Light
}
fn turn_off(self) -> Light<0> {
Light
}
}
impl Light<2> {
fn turn_down(self) -> Light<1> {
Light
}
}
Now Light<0> is off, Light<1> is dim, and Light<2> is bright. The compiler knows the state because it is part of the type. You cannot call turn_up on Light<2> because that method only exists on Light<1>. You cannot call turn_off on Light<2> if you want to go directly to off. You must go through the dim state first.
Const generics are useful when you have many states and you do not want to write a separate struct for each one. They are also good when you want to compare states at compile time. I have used this pattern for protocol parsers where each byte moves you from one state number to another.
One warning: const generics can make error messages harder to read. If you make a mistake, the compiler may tell you that Light<2> does not have a method named turn_up, but it will not tell you the real problem. That is why I only use this pattern for small state machines.
The eighth pattern is nested state machines. Sometimes one state machine is not enough. You need two state machines working side by side. For example, a network connection may have a connection state and an authentication state. Both must be valid before you can send a message.
You can represent this by giving your struct two type parameters.
use std::marker::PhantomData;
struct Anonymous;
struct Authenticated;
struct NoTls;
struct TlsUp;
struct Session<Auth, Tls> {
address: String,
_auth: PhantomData<Auth>,
_tls: PhantomData<Tls>,
}
impl Session<Anonymous, NoTls> {
fn new(address: String) -> Self {
Session {
address,
_auth: PhantomData,
_tls: PhantomData,
}
}
fn start_tls(self) -> Session<Anonymous, TlsUp> {
Session {
address: self.address,
_auth: PhantomData,
_tls: PhantomData,
}
}
fn login(self) -> Session<Authenticated, NoTls> {
Session {
address: self.address,
_auth: PhantomData,
_tls: PhantomData,
}
}
}
impl Session<Authenticated, TlsUp> {
fn send(&self, message: &str) {
println!("Sending {} to {}", message, self.address);
}
}
This type says: Session<Authenticated, TlsUp> is the only state where sending is allowed. You must be authenticated and TLS must be up. A Session<Authenticated, NoTls> cannot send. A Session<Anonymous, TlsUp> cannot send either. The compiler checks both dimensions at once.
Nested state machines are powerful because they grow without multiplying your code. You do not need to write separate impl blocks for every combination. You just write one method for each transition. The type system keeps track of the combination.
Now let me show you how these patterns fit together in a real story. I once wrote a payment system. A payment starts as pending. A pending payment can be authorized. An authorized payment can be captured. A captured payment can be refunded. I used the typestate pattern and consuming self for every transition. The result was clean and simple.
The old code used a single enum with a state field. Every method matched on the state and returned an error if the state was wrong. I had to write tests for every illegal transition. After I changed to type-safe state machines, many of those tests became unnecessary. The compiler would not let me write the illegal code at all.
That is the real gift of Rust. You do not need to remember every rule. You write the rules once in the type system, and the compiler remembers them forever. If you move a payment from Pending to Authorized, the old Pending object is gone. You cannot accidentally use it again. This is not a runtime guard. It is a compile-time guarantee.
Let me give you one more practical example. Imagine a login form with two states: empty and ready. You can only submit when the form is ready. But a form becomes ready only after you provide a username and a password. The typestate pattern makes this obvious.
use std::marker::PhantomData;
struct Empty;
struct Ready;
struct LoginForm<State> {
username: String,
password: String,
_state: PhantomData<State>,
}
impl LoginForm<Empty> {
fn new() -> Self {
LoginForm {
username: String::new(),
password: String::new(),
_state: PhantomData,
}
}
fn fill_username(mut self, username: &str) -> Self {
self.username = username.to_string();
self
}
fn fill_password(mut self, password: &str) -> Self {
self.password = password.to_string();
self
}
fn submit(self) -> Result<LoginForm<Ready>, String> {
if self.username.is_empty() || self.password.is_empty() {
Err("missing field".to_string())
} else {
Ok(LoginForm {
username: self.username,
password: self.password,
_state: PhantomData,
})
}
}
}
impl LoginForm<Ready> {
fn send(self) {
println!("Logging in as {}", self.username);
}
}
Notice that fill_username returns Self, which is still LoginForm<Empty>. So you can fill the username and password, but the form is still empty until you call submit. submit checks the fields and returns a LoginForm<Ready>. Once it is ready, you can send it. You cannot send an empty form because send only exists on LoginForm<Ready>.
This is a simple pattern, but it changes the way you think about code. Instead of asking “what should I do if the state is wrong?”, you ask “how can I make the wrong state impossible to express?” That question is more useful than a thousand runtime checks.
Let me walk through the eight patterns one more time in plain words. The typestate pattern uses marker types and PhantomData. Consuming self destroys the old state. Trait-based transitions allow generic state changes. Sealed states hide constructors behind a private module. State-dependent APIs use traits to expose methods only to valid states. Option and take help you move state inside borrowed structs. Const generics let you encode state as a number. Nested state machines combine multiple independent state machines in one type.
Which pattern should you choose? Start with typestate and consuming self. They are the easiest to understand and give you the strongest guarantees. Add sealed states when you build a library that other people will use. Add trait-based transitions when you want to write generic code. Use Option and take only when you really need to mutate a state in place. Use const generics for very small, regular state machines. Use nested state machines when your problem has two independent conditions.
The most important thing is to start with the simplest pattern that works. You do not need to build a complicated type-level tower for every problem. A simple state machine with two types is better than a flexible state machine that no one can read. I have made that mistake too. I once built a state machine so clever that I could not understand it the next day. The compiler could prove everything, but the code was useless because no one could maintain it.
So keep the state machine as simple as possible. Use the type system to express the rules you care about. Let the compiler do the heavy lifting. Your future self will thank you when a new developer asks, “Can I call this method here?” and you can answer, “Try it. The compiler will tell you.”
That is the beauty of type-safe state machines in Rust. They turn runtime crashes into compile-time errors. They turn impossible states into impossible code. And they turn a scary bug class into a small nuisance at the terminal. Every time I see a red squiggly under an illegal transition, I smile. The compiler just saved me from another late-night debugging session.