The Rust type-state pattern allows you to represent the states of an object as distinct types, guaranteeing at compile time that an object can only perform actions valid for its current state.
Let’s see this in action. Imagine we’re building a simple network connection, which can be Disconnected, Connecting, or Connected.
use std::io;
// Define our states as distinct types
struct Disconnected;
struct Connecting;
struct Connected;
// The network connection struct, parameterized by its current state
struct NetworkConnection<State> {
state: State,
// Other connection-specific data could go here
}
impl NetworkConnection<Disconnected> {
// Constructor for a new, disconnected connection
fn new() -> Self {
NetworkConnection { state: Disconnected }
}
// Method to initiate a connection
fn connect(self) -> NetworkConnection<Connecting> {
println!("Initiating connection...");
// In a real scenario, this would involve network operations
NetworkConnection { state: Connecting }
}
}
impl NetworkConnection<Connecting> {
// Method to confirm a successful connection
fn confirm_connection(self) -> NetworkConnection<Connected> {
println!("Connection confirmed.");
NetworkConnection { state: Connected }
}
// Method to handle a connection failure
fn handle_failure(self) -> NetworkConnection<Disconnected> {
println!("Connection failed. Resetting.");
NetworkConnection { state: Disconnected }
}
}
impl NetworkConnection<Connected> {
// Method to send data over a connected socket
fn send_data(&self, data: &str) -> io::Result<()> {
println!("Sending data: {}", data);
// Actual data sending logic
Ok(())
}
// Method to close the connection
fn disconnect(self) -> NetworkConnection<Disconnected> {
println!("Disconnecting.");
NetworkConnection { state: Disconnected }
}
}
fn main() {
// Start in the Disconnected state
let mut conn = NetworkConnection::new();
// Attempting to send data while disconnected will not compile:
// conn.send_data("hello"); // Error: no method named `send_data` found for type `NetworkConnection<Disconnected>`
// Initiate connection
let conn = conn.connect();
// Attempting to confirm connection before it's established will not compile:
// let conn = conn.confirm_connection(); // Error: no method named `confirm_connection` found for type `NetworkConnection<Connecting>`
// Simulate connection establishment
let conn = conn.confirm_connection();
// Now we can send data
conn.send_data("Hello, server!").unwrap();
// And disconnect
let conn = conn.disconnect();
// Trying to send data again after disconnecting will fail to compile.
// conn.send_data("goodbye"); // Error: no method named `send_data` found for type `NetworkConnection<Disconnected>`
}
This pattern solves the problem of managing complex state machines where invalid state transitions can lead to runtime errors or unpredictable behavior. By encoding states as types, the Rust compiler enforces valid transitions and method calls, shifting potential errors from runtime to compile time. Each impl block defines the methods available for a specific state, and the generic NetworkConnection<State> struct ensures that the State type parameter is always one of our defined state types. The act of calling a method often consumes the current state object (self) and returns a new object representing the next state, effectively moving the connection through its lifecycle.
The real power of this pattern emerges when you consider that the state types themselves can carry data relevant only to that state. For example, a Connected state might hold a TcpStream or Socket object, while Disconnected would hold nothing. This means you don’t need to use Option or enum flags within a single struct to track state and associated data; the type system handles it. The transitions become explicit value transformations, moving from NetworkConnection<Disconnected> to NetworkConnection<Connecting>, and so on.
Consider a more advanced scenario where a Connected state might hold a WebSocket object, but if the connection is lost, it transitions to Disconnected and the WebSocket object is dropped. The type system ensures that you can only call send_data or disconnect on a NetworkConnection<Connected>, and these methods return a new NetworkConnection in a different state, potentially discarding the WebSocket object implicitly as the old struct is consumed. This makes reasoning about resource management and state correctness significantly easier and more robust.
You might not realize that the self parameter in the impl blocks is crucial. When you call conn.connect(), the conn variable (which is of type NetworkConnection<Disconnected>) is moved into the connect method. The connect method then returns a new NetworkConnection<Connecting>, and you reassign it back to conn. This ownership transfer is how the state is guaranteed to change. If a method took &self or &mut self for a state transition, it would imply that the state could change in place without a new type being returned, which is generally not how the strict type-state pattern works for enforcing immutable state transitions.
Next, you’ll likely explore how to implement more complex state transitions, perhaps involving conditional logic or error handling that can lead back to previous states.