When building software that transitions through a sequence of steps, you usually reach for the state pattern. In most languages, this means checking state conditions at runtime. You write code that checks if a connection is open before sending data, or if an invoice is unpaid before applying a discount. If someone calls a method at the wrong time, your system throws a runtime error or panics. This approach shifts the burden of validation to your test suite and your users. In resource-constrained environments, such as bare-metal operating system design, runtime panics are unacceptable.
Rust offers a better path. By combining the newtype pattern with generics, you can turn runtime state validation into compile-time guarantees. This design pattern, known as the typestate pattern, makes invalid transitions physically impossible to compile. You do not need runtime checks, and you do not pay a performance penalty.
The Problem with Runtime State Checks
To see why compile-time safety matters, let's look at how we traditionally build state machines. The standard approach uses an enum to represent the possible states of a system.
Here is a typical implementation of a network connection:
pub enum ConnectionState {
Disconnected,
Connecting,
Connected,
}
pub struct Connection {
state: ConnectionState,
socket: Option<TcpStream>,
}
impl Connection {
pub fn new() -> Self {
Connection {
state: ConnectionState::Disconnected,
socket: None,
}
}
pub fn connect(&mut self) -> Result<(), std::io::Error> {
match self.state {
ConnectionState::Disconnected => {
// Perform connection logic
self.state = ConnectionState::Connected;
Ok(())
}
_ => Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"Connection already active or in progress",
)),
}
}
pub fn send(&mut self, data: &[u8]) -> Result<(), std::io::Error> {
match self.state {
ConnectionState::Connected => {
// Send data
Ok(())
}
_ => Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"Cannot send data on a disconnected socket",
)),
}
}
}This code works, but it has three distinct flaws.
First, you must wrap fields like the socket in an Option. Because the socket only exists when the connection is active, you are forced to call .unwrap() or match on the option in every method. This adds runtime overhead and introduces potential panics.
Second, your public API lies. The send method is visible on the connection object from the moment it is created, even though calling it on a disconnected socket is an error.
Third, you write repetitive boilerplate to handle invalid states. If you forget to check the state in a new method, you introduce silent bugs.
The Foundation: The Newtype Pattern
Before fixing the state machine, we need to understand the newtype pattern. In Rust, this means wrapping an existing type in a tuple struct to create a new type.
pub struct UserId(pub u64);
pub struct OrderId(pub u64);At runtime, UserId and OrderId are just 64-bit integers. But to the compiler, they are completely different types. You cannot accidentally pass an OrderId to a function that expects a UserId.
fn fetch_user_profile(id: UserId) {
// ...
}
let order_id = OrderId(42);
// fetch_user_profile(order_id); // This will not compileThis pattern costs nothing at runtime. The compiler strips away the wrapper and uses the raw integer in the final binary. We can use this same type-level separation to represent the states of our state machine.
The Core: The Typestate Pattern
The typestate pattern combines the newtype pattern with generics. Instead of storing the state as a runtime value inside an enum, we encode the state as a type parameter on the struct.
Let's design a file uploader that must go through three phases:
Configured(contains the target URL and API key)Connected(contains the active session token)Uploaded(contains the server receipt hash)
First, we define empty structs for each state. These structs act as markers:
pub struct Configured;
pub struct Connected;
pub struct Uploaded;Next, we define our main struct. We make it generic over the state:
pub struct Uploader<State> {
state_data: State,
}Wait, this structure looks different from our enum example. Instead of putting all data in one struct and marking the state with an enum, we put the state-specific data directly inside the state marker structs.
Let's redefine our states with their specific data:
pub struct Configured {
target_url: String,
api_key: String,
}
pub struct Connected {
target_url: String,
session_token: String,
}
pub struct Uploaded {
receipt_hash: String,
}Now, the Uploader struct only holds the data relevant to its current state. An Uploader<Configured> does not have a session token or a receipt hash. Those fields do not exist yet.
Implementing State Transitions
To define methods that only apply to specific states, we implement them on concrete versions of our generic struct. We do not write a generic impl<State> Uploader<State>. Instead, we write specific blocks.
Let's implement the initialization and connection logic:
impl Uploader<Configured> {
pub fn new(target_url: String, api_key: String) -> Self {
Uploader {
state_data: Configured {
target_url,
api_key,
},
}
}
pub fn connect(self) -> Uploader<Connected> {
let session_token = mock_handshake(&self.state_data.target_url, &self.state_data.api_key);
Uploader {
state_data: Connected {
target_url: self.state_data.target_url,
session_token,
},
}
}
}Look closely at the connect signature. It takes self by value.
This is the key to the entire pattern. By taking ownership of self, the connect method consumes the Uploader<Configured> instance. It returns a new type, Uploader<Connected>.
Because the old instance is consumed, you cannot use it again. If you try to call connect twice, the compiler stops you:
let uploader = Uploader::new("https://api.example.com".to_string(), "key".to_string());
let connected_uploader = uploader.connect();
// This will fail to compile:
// let failed_attempt = uploader.connect();The compiler output will tell you that you are trying to use a moved value. You get compile-time protection against double-connection bugs.
Now, let's implement the upload method, which is only valid when we are connected:
impl Uploader<Connected> {
pub fn upload(self, payload: Vec<u8>) -> Uploader<Uploaded> {
let receipt_hash = mock_upload(
&self.state_data.target_url,
&self.state_data.session_token,
payload,
);
Uploader {
state_data: Uploaded { receipt_hash },
}
}
}And finally, we implement the method to read the receipt, which only makes sense after a successful upload:
impl Uploader<Uploaded> {
pub fn receipt_hash(&self) -> &str {
&self.state_data.receipt_hash
}
}If you try to call receipt_hash on a Connected uploader, the compiler will tell you that the method does not exist on Uploader<Connected>.
Managing Shared Context
In our uploader example, we had to copy the target_url from Configured to Connected during the transition. If you have many shared configuration fields, copying them between states becomes tedious.
To fix this, you can split your struct into static context and dynamic state.
pub struct UploadContext {
pub target_url: String,
pub timeout_seconds: u32,
}
pub struct Uploader<State> {
context: UploadContext,
state_data: State,
}Now, the transition methods only need to transform the state_data field while passing the context along:
impl Uploader<Configured> {
pub fn connect(self) -> Uploader<Connected> {
let session_token = mock_handshake(&self.context.target_url, &self.state_data.api_key);
Uploader {
context: self.context,
state_data: Connected { session_token },
}
}
}This separation keeps your state transitions clean and prevents unnecessary cloning of static data.
Recovering from Failures
In the real world, operations fail. What happens if our connection attempt fails?
If our connect method returns a Result<Uploader<Connected>, std::io::Error>, we have a problem. Because connect takes self by value, a failure means the original Uploader<Configured> is consumed and lost. The caller cannot retry the connection because they no longer own the uploader.
To solve this, we return the original state back to the caller inside the error variant.
pub struct ConnectionError {
pub error: std::io::Error,
pub uploader: Uploader<Configured>,
}
impl Uploader<Configured> {
pub fn connect(self) -> Result<Uploader<Connected>, ConnectionError> {
match mock_handshake(&self.context.target_url, &self.state_data.api_key) {
Ok(session_token) => Ok(Uploader {
context: self.context,
state_data: Connected { session_token },
}),
Err(err) => Err(ConnectionError {
error: err,
uploader: self, // Return ownership here
}),
}
}
}This pattern allows the caller to handle the error and retry using the recovered uploader:
let mut uploader = Uploader::new("https://api.example.com".to_string(), "key".to_string());
loop {
match uploader.connect() {
Ok(connected) => {
let finished = connected.upload(data);
break;
}
Err(err) => {
println!("Connection failed: {}", err.error);
uploader = err.uploader; // Recover the state for the next loop run
}
}
}Bridging the Gap: Runtime Input
Compile-time types are great, but applications must interact with the outside world. If you load an uploader's state from a database or an API request, you do not know its state at compile time. While Python applications might use resilient Pydantic v2 schemas to parse and validate incoming data, Rust allows us to map this dynamic data directly into our typestate pipeline.
To bridge this gap, you use a standard enum at the boundary of your system. This enum wraps the different typestate configurations.
pub enum AnyUploader {
Configured(Uploader<Configured>),
Connected(Uploader<Connected>),
Uploaded(Uploader<Uploaded>),
}When you load data from a database, you map the stored status string to the correct enum variant:
pub fn load_uploader(row: DbRow) -> AnyUploader {
match row.status.as_str() {
"configured" => AnyUploader::Configured(Uploader::from_raw_parts(row.url, row.api_key)),
"connected" => AnyUploader::Connected(Uploader::from_raw_parts_connected(row.url, row.token)),
_ => panic!("Unknown state"),
}
}Once the database loader returns AnyUploader, the caller matches on the variant to enter the type-safe pipeline:
match load_uploader(row) {
AnyUploader::Configured(uploader) => {
let connected = uploader.connect().unwrap();
connected.upload(data);
}
AnyUploader::Connected(uploader) => {
uploader.upload(data);
}
AnyUploader::Uploaded(_) => {
println!("Upload already completed.");
}
}This pattern isolates the dynamic, unsafe runtime parsing to the edge of your application. Inside your business logic, you retain full compile-time safety.
Zero-Cost Mechanics
You might worry that adding all these types will bloat your compiled binary.
In Rust, empty structs are Zero-Sized Types (ZSTs). They occupy exactly zero bytes of memory at runtime.
If we use empty marker structs for our states:
pub struct ConfiguredState;
pub struct ConnectedState;
pub struct Uploader<State> {
url: String,
_marker: std::marker::PhantomData<State>,
}The PhantomData marker tells the compiler that the struct acts as if it owns a State type, but it does not actually store one. At runtime, the size of Uploader<ConfiguredState> is exactly the same as the size of a single String. The compiler completely removes the state tracking types. They only exist to guide the compiler's borrow checker and type checker.
Trade-offs and Limits
The typestate pattern is not a universal solution. It comes with real trade-offs.
First, it increases API complexity. If you have ten states, you will have ten different types. This makes your documentation harder to read and increases the learning curve for other developers. To manage this complexity in large projects, using tools for fast codebase inspection can help developers navigate the relationships between these state types.
Second, you cannot easily store different states in a collection. You cannot create a Vec<Uploader<State>> because each element in a vector must have the exact same type. To store them together, you must wrap them in a runtime enum, which defeats some of the benefits of the typestate pattern.
Third, it makes dynamic state transitions difficult. If your state transitions depend on complex runtime conditions (like user input directing a workflow to step A, B, or C dynamically), writing the typestate code becomes verbose and hard to maintain.
If your state machine is simple, linear, and critical to your system's safety, the typestate pattern is an excellent choice. It guarantees that your code behaves correctly before it ever runs.



