The Problem with Boolean Flags and Enums

I've spent a lot of time maintaining legacy systems where the state of an object is tracked via a handful of booleans or a single enum. You've seen it: if self.is_initialized && !self.is_closed { ... }. The problem here is that the compiler has no idea that certain methods should only be called in specific states. You end up writing a lot of runtime checks that return Result::Err or panic! because a developer called send_data() before connect().

In Rust, we can move these runtime failures to compile-time using the Typestate Pattern. Instead of checking the state at runtime, we represent each state as a distinct type. This allows us to use Rust's ownership system to ensure that a transition to a new state consumes the previous one, making it physically impossible to call a method in the wrong order.

Real-World Scenario: A Secure Connection Pipeline

Imagine you're building a network client. The lifecycle is strict: DisconnectedConnectingAuthenticatedConnected. If you try to send a payload while still in the Connecting state, the system should fail. Rather than returning an error at runtime, we want the code to simply fail to compile.

The Implementation

// Define marker structs for our states.
// We keep these empty because they only exist to provide type information.
pub struct Disconnected;
pub struct Connecting;
pub struct Authenticated;
pub struct Connected;

// The main state machine. 
// The generic parameter 'S' represents the current state.
pub struct Connection<S> {
    id: u32,
    state: S,
}

impl Connection<Disconnected> {
    pub fn new(id: u32) -> Connection<Disconnected> {
        Connection {
            id,
            state: Disconnected,
        }
    }

    // This method consumes the Disconnected state and returns a Connecting state.
    pub fn connect(self) -> Connection<Connecting> {
        println!("Connecting connection {}...", self.id);
        Connection {
            id: self.id,
            state: Connecting,
        }
    }
}

impl Connection<Connecting> {
    pub fn authenticate(self, token: &str) -> Connection<Authenticated> {
        println!("Authenticating with token: {}", token);
        Connection {
            id: self.id,
            state: Authenticated,
        }
    }
}

impl Connection<Authenticated> {
    pub fn finalize(self) -> Connection<Connected> {
        println!("Finalizing connection...");
        Connection {
            id: self.id,
            state: Connected,
        }
    }
}

impl Connection<Connected> {
    pub fn send_data(&self, data: &str) {
        println!("Sending data over connection {}: {}", self.id, data);
    }
}

fn main() {
    let conn = Connection::new(101);
    
    // This sequence works perfectly.
    let conn = conn.connect().authenticate("secret_token").finalize();
    conn.send_data("Hello, Rust!");

    // UNCOMMENT the line below to see the compiler stop you:
    // let broken_conn = Connection::new(102);
    // broken_conn.send_data("This won't compile!"); 
}

Why This Works

The magic here is the combination of Generics and Ownership. Notice that each transition method (like connect or authenticate) takes self by value. This consumes the original object.

  • Zero Runtime Overhead: Since the state markers are empty structs (Zero Sized Types), they disappear during compilation. The resulting binary is just as efficient as if you used an integer flag.
  • Impossible States: You cannot call send_data on a Connection<Disconnected> because that method is only implemented for Connection<Connected>.
  • Linear Logic: The API guides the user. The IDE's autocomplete will only suggest methods that are valid for the current state.
Pro Tip: If you need to share the connection across threads or store it in a collection, you might need to wrap the state machine in an Enum at the very top level, but for the internal logic of a process, Typestates are vastly superior.

When to Use This Technique

I typically reach for the Typestate pattern when I'm designing Builders or Drivers. If you find yourself writing if self.state != State::Ready { return Err(Error::InvalidState); } more than twice in a module, you're dealing with a state machine. Stop fighting the runtime and start leveraging the type system. It moves the cognitive load from the developer's memory to the compiler's logic, which is exactly why we use Rust in the first place.