Approach Comparison: Dynamic State vs. the Typestate Pattern
Most business workflows follow strict lifecycles. An e-commerce order starts as Draft, moves to Paid, and finishes as Shipped. A network socket starts at Disconnected and transitions to Connected. When modeling these lifecycles, developers usually take one of two paths.
1. The Traditional Runtime Flag Approach
In many codebases, state lives as an enum field inside a monolithic struct, guarded by runtime checks:
enum OrderStatus {
Draft,
Paid,
Shipped,
}
struct Order {
id: u64,
status: OrderStatus,
items: Vec<String>,
}
impl Order {
fn ship(&mut self) -> Result<(), String> {
match self.status {
OrderStatus::Paid => {
self.status = OrderStatus::Shipped;
Ok(())
}
_ => Err("Cannot ship an unpaid or already shipped order".into()),
}
}
}
What happens when someone calls ship() on a draft order? The compiler says nothing. The code compiles cleanly, and the mistake slips quietly into production until a customer triggers an unexpected 500 error.
2. The Typestate Pattern
Typestates convert dynamic lifecycle stages into distinct compile-time types. Instead of storing a mutable enum flag, your struct carries a zero-sized marker type representing its current stage. Transition methods then consume self by value and return an entirely new type:
// Only Order<Paid> implements the ship() method
let paid_order: Order<Paid> = draft_order.pay();
let shipped_order: Order<Shipped> = paid_order.ship("DHL-8841");
Try calling ship() on a draft_order now. The code fails to compile immediately. The compiler rejects the binary outright because Order<Draft> does not even have a ship() method in scope.
Pros and Cons: Evaluating Typestate for Your Codebase
Moving invariants from runtime guards to compile-time guarantees provides immense confidence. Still, like any design pattern, it comes with practical engineering trade-offs.
The Advantages
- Impossible Invalid States: Business rules become physical type constraints. You cannot accidentally charge a canceled order or dispatch an unpaid invoice.
- Zero Runtime Overhead: In 64-bit systems,
std::mem::size_of::<Order<Draft>>()equals exactly 32 bytes (8 bytes for theu64ID plus 24 bytes for theVec). Rust’s zero-sized types (ZSTs) andPhantomDatavanish entirely during LLVM optimization. You get ironclad safety without burning extra CPU cycles or allocating heap memory. - Ownership Prevents Stale References: Because transitions take
selfby value, Rust consumes the previous state. Calling methods on the old state triggers compiler errorE0382: use of moved value. - Focused IDE Autocomplete: Engineers see only the methods valid for an object’s current state. This cuts onboarding friction and stops misconfigurations before code review.
The Trade-offs
- Boilerplate: Defining marker traits, generic structs, and state-specific implementations requires more upfront code than adding an enum variant.
- Persistence Boundaries: Relational databases store status columns as flat strings or integers (e.g.,
status = "PAID"). You must write an explicit factory or parser at the boundary to rehydrate those rows into concrete typestates. - Heterogeneous Collections: You cannot store an
Order<Draft>and anOrder<Paid>in a standardVec<Order<S>>. You must wrap them in an outer enum or use dynamic trait objects.
Architecture: Three Core Building Blocks
To implement an idiomatic, production-ready typestate in Rust, build on three foundational rules:
- Zero-Sized Structs (ZSTs): Define empty marker structs like
pub struct Draft;that occupy zero bytes in memory. - A Sealed Marker Trait: Bind states to a trait backed by a private module. This prevents external crates from attaching unauthorized states to your domain pipeline.
- Value-Consuming Transitions (
self): Always take ownership ofselfrather than borrowing via&mut self. This guarantees the previous state is destroyed when the next begins.
We deployed this pattern across a payment service handling roughly 40,000 checkout events daily. In two years of production, we saw zero invalid transition regressions in those critical pipelines.
Implementation Guide: Building a Reliable Order Pipeline
Here is how to construct a robust order processing workflow step-by-step.
Step 1: Define Marker Types and a Sealed Trait
Begin by creating empty types for each state. Use a sealed trait pattern so outside code cannot implement arbitrary states:
use std::marker::PhantomData;
// Prevent external crates from implementing this trait
mod private {
pub trait Sealed {}
}
pub struct Draft;
pub struct Paid;
pub struct Shipped;
impl private::Sealed for Draft {}
impl private::Sealed for Paid {}
impl private::Sealed for Shipped {}
pub trait OrderState: private::Sealed {}
impl OrderState for Draft {}
impl OrderState for Paid {}
impl OrderState for Shipped {}
// Core struct parameterized by state S
pub struct Order<S: OrderState> {
pub id: u64,
pub items: Vec<String>,
_state: PhantomData<S>,
}
Step 2: Implement Shared Behaviors
Implement properties common across every state—like reading the order ID or inspecting the item list—using an unconstrained generic block:
impl<S: OrderState> Order<S> {
pub fn id(&self) -> u64 {
self.id
}
pub fn items(&self) -> &[String] {
&self.items
}
}
Step 3: Implement State-Specific Methods and Transitions
Now restrict operations to individual states. Notice how each transition consumes self to hand over ownership cleanly:
impl Order<Draft> {
pub fn new(id: u64) -> Self {
Self {
id,
items: Vec::new(),
_state: PhantomData,
}
}
pub fn add_item(&mut self, item: String) {
self.items.push(item);
}
// Transition: Draft -> Paid
pub fn pay(self) -> Order<Paid> {
Order {
id: self.id,
items: self.items,
_state: PhantomData,
}
}
}
impl Order<Paid> {
// Transition: Paid -> Shipped
pub fn ship(self, tracking_number: &str) -> (Order<Shipped>, String) {
let receipt = format!("Order {} dispatched via {}", self.id, tracking_number);
let shipped = Order {
id: self.id,
items: self.items,
_state: PhantomData,
};
(shipped, receipt)
}
}
impl Order<Shipped> {
pub fn tracking_status(&self) -> &'static str {
"In transit with courier"
}
}
Step 4: Putting It to the Test
Let’s observe how the compiler enforces this pipeline in action:
fn main() {
let mut draft = Order::new(101);
draft.add_item("Ergonomic Keyboard".to_string());
draft.add_item("USB-C Cable".to_string());
// Valid transition: Draft consumes itself and returns Order<Paid>
let paid = draft.pay();
// COMPILE ERROR [E0382]: use of moved value: `draft`
// draft.add_item("Desk Mat".to_string());
// COMPILE ERROR: no method named `ship` found for type `Order<Draft>`
// draft.ship("DHL-9921");
let (shipped, receipt) = paid.ship("DHL-9921");
println!("{}", receipt);
println!("Current status: {}", shipped.tracking_status());
}
Handling Real-World Persistence
When loading rows from PostgreSQL or SQLite, wrap the typestates in an outer domain enum right at your repository boundary:
pub enum AnyOrder {
Draft(Order<Draft>),
Paid(Order<Paid>),
Shipped(Order<Shipped>),
}
impl AnyOrder {
pub fn from_db_record(id: u64, status: &str, items: Vec<String>) -> Result<Self, String> {
match status {
"DRAFT" => Ok(AnyOrder::Draft(Order { id, items, _state: PhantomData })),
"PAID" => Ok(AnyOrder::Paid(Order { id, items, _state: PhantomData })),
"SHIPPED" => Ok(AnyOrder::Shipped(Order { id, items, _state: PhantomData })),
other => Err(format!("Unrecognized status in database: {}", other)),
}
}
}
Keep validation logic quarantined at your I/O borders. Once data crosses into your domain core, typestates guarantee that invalid state transitions become impossible to compile.

