How to Use the Typestate Pattern for Compile-Time State Machines

Implement the typestate pattern in Rust by using enums to represent states and restricting method access to valid state transitions.

When runtime checks aren't enough

You are building a wrapper around a low-level file API. The C library demands you call open before read, and close after you are done. If you call read on a closed file, the program crashes. If you forget close, you leak a file descriptor. You could add a boolean flag and check it at runtime, but that adds overhead and still allows bugs to slip through until the test suite catches them. Rust lets you move those checks to compile time. The type system itself becomes the state machine.

The idea: types as states

The typestate pattern ties behavior to type. An object in the "Open" state has a different type than the same object in the "Closed" state. The compiler sees the types and rejects invalid transitions. You cannot call read on a ClosedFile because the method simply does not exist on that type.

Think of a physical key card for a hotel room. The card works to open the door. Once you check out, the card stops working. You cannot use the card to open the door after checkout. The card's ability to open the door is tied to its state. In Rust, the type is the key card. When the state changes, the type changes, and the old methods vanish.

This is a zero-cost abstraction. The compiler generates the same code as if you had written the checks manually, but the checks happen before the binary runs. There is no runtime overhead. The state machine lives entirely in the type checker.

Minimal example

Here is a file wrapper that enforces the lifecycle: New to Open to Closed.

use std::marker::PhantomData;

/// Marker type for the initial state.
/// Empty structs are sufficient for markers.
struct New;

/// Marker type for the open state.
struct Open;

/// Marker type for the closed state.
struct Closed;

/// A file wrapper that tracks state via type parameters.
/// T represents the current state of the file.
struct File<T> {
    /// The path to the file.
    path: String,
    /// PhantomData tells the compiler that T is used in the type,
    /// even though we do not store a value of type T.
    /// This prevents the compiler from dropping the type parameter.
    _state: PhantomData<T>,
}

/// Implementation for the New state.
/// Only `open` is available here.
impl File<New> {
    /// Create a new file wrapper in the New state.
    fn new(path: &str) -> Self {
        File {
            path: path.to_string(),
            _state: PhantomData,
        }
    }

    /// Open the file.
    /// Consumes the New file and returns an Open file.
    /// The type changes, so the old variable is gone.
    fn open(self) -> File<Open> {
        println!("Opening {}", self.path);
        File {
            path: self.path,
            _state: PhantomData,
        }
    }
}

/// Implementation for the Open state.
/// `read` is available. `close` transitions to Closed.
impl File<Open> {
    /// Read data from the file.
    /// Only available when the file is open.
    fn read(&self) -> String {
        format!("Reading data from {}", self.path)
    }

    /// Close the file.
    /// Consumes the Open file and returns a Closed file.
    fn close(self) -> File<Closed> {
        println!("Closing {}", self.path);
        File {
            path: self.path,
            _state: PhantomData,
        }
    }
}

/// Implementation for the Closed state.
/// No methods are available. You cannot read or open a closed file.
impl File<Closed> {
    // Intentionally empty. The type exists to represent the state,
    // but no operations are valid.
}

fn main() {
    // Create a file in the New state.
    let f = File::new("data.txt");

    // This would fail to compile:
    // f.read();
    // Error[E0599]: no method named `read` found for struct `File<New>`

    // Open the file. Type changes to File<Open>.
    let f = f.open();

    // Now read works.
    let data = f.read();
    println!("{}", data);

    // Close the file. Type changes to File<Closed>.
    let f = f.close();

    // This would fail to compile:
    // f.read();
    // Error[E0599]: no method named `read` found for struct `File<Closed>`
}

How the compiler enforces the rules

The magic happens in the impl blocks. Each block is scoped to a specific type parameter. impl File<New> defines methods only for the New state. When you call f.open(), the function consumes self (the File<New>) and returns a File<Open>. The variable f is reassigned to the new type. The old type is gone. You cannot call open again because File<Open> does not have an open method.

The compiler error you see when you violate the state machine is E0599 (no method named ... found for struct ...). The error message includes the full type, including the state parameter. Learn to read the type in the error message. It tells you exactly where you are in the state machine and what you are trying to do.

Convention aside: Always use PhantomData for marker types. The compiler will warn you if you leave a type parameter unused, and it may drop the parameter entirely, breaking your type safety. PhantomData signals that the type parameter is logically used, even if no value of that type is stored. It also informs the compiler about variance and drop checks. Without PhantomData, the type system cannot track the state correctly.

Realistic example: Socket lifecycle

Typestate shines when wrapping resources with strict lifecycles. Here is a socket that must be bound before listening, and can only accept connections while listening.

use std::marker::PhantomData;

/// States for the socket lifecycle.
struct Unbound;
struct Bound;
struct Listening;

/// A socket that tracks its state via type parameters.
struct Socket<T> {
    /// The address the socket is bound to, if any.
    address: Option<String>,
    /// PhantomData keeps the state type alive for the compiler.
    _state: PhantomData<T>,
}

/// Implementation for the Unbound state.
/// Only `bind` is available.
impl Socket<Unbound> {
    /// Create a new unbound socket.
    fn new() -> Self {
        Socket {
            address: None,
            _state: PhantomData,
        }
    }

    /// Bind the socket to an address.
    /// Returns a Bound socket.
    fn bind(self, addr: &str) -> Socket<Bound> {
        Socket {
            address: Some(addr.to_string()),
            _state: PhantomData,
        }
    }
}

/// Implementation for the Bound state.
/// `listen` is available. `accept` is not.
impl Socket<Bound> {
    /// Start listening for connections.
    /// Requires the socket to be bound first.
    fn listen(self) -> Socket<Listening> {
        println!("Listening on {}", self.address.as_ref().unwrap());
        Socket {
            address: self.address,
            _state: PhantomData,
        }
    }
}

/// Implementation for the Listening state.
/// `accept` is available.
impl Socket<Listening> {
    /// Accept a connection.
    /// Only available when listening.
    fn accept(&self) -> String {
        format!("Accepted connection on {:?}", self.address)
    }
}

fn main() {
    let sock = Socket::new();
    // sock.listen(); // Error[E0599]: no method `listen` on `Socket<Unbound>`

    let sock = sock.bind("127.0.0.1:8080");
    // sock.accept(); // Error[E0599]: no method `accept` on `Socket<Bound>`

    let sock = sock.listen();
    let conn = sock.accept();
    println!("{}", conn);
}

The structure scales to more complex machines. You can add states like Paused or Error, and define transitions between them. Each transition is a function that consumes the current type and returns the next type. The compiler ensures you follow the graph.

Pitfalls and conventions

Typestate is powerful, but it has trade-offs.

State explosion is the biggest risk. If you have ten states and twenty transitions, you will have dozens of impl blocks. The code becomes hard to navigate. Typestate shines for small, critical state machines with three to five states. If the machine grows large, consider a runtime enum with validation logic.

The state disappears at runtime. The type parameter T is erased. You cannot write if file.state == Open because the state is not stored. If you need to log the state or serialize the object, you must carry a runtime enum alongside the typestate. This adds redundancy but is necessary for runtime inspection.

Error messages can be dense. When you call the wrong method, you get E0599. The error tells you the type, which tells you the state. The message might look like no method named read found for struct File<Closed>. The key is File<Closed>. The compiler is telling you the file is closed. Learn to parse these errors. They are precise.

Convention aside: Keep marker types in a module or a submodule. Group them together so readers can see all valid states at a glance. Name them clearly. New, Open, Closed is standard. Avoid cryptic names like S1, S2. The names should document the state machine.

Trust the type system. If the code compiles, the state is valid. Period. You do not need runtime assertions for transitions that the compiler has already verified.

Decision matrix

Use the typestate pattern when you need to enforce a strict sequence of operations and invalid sequences should never compile. Use the typestate pattern when the cost of runtime checks is unacceptable in a performance-critical path. Use the typestate pattern when you are wrapping unsafe code and want to prove safety invariants to the compiler.

Reach for a runtime enum when the state can change dynamically based on user input or network events that the compiler cannot predict. Reach for a runtime enum when you need to inspect or serialize the state at runtime. Reach for a simple boolean flag when the state is binary and the penalty for a wrong transition is a recoverable error rather than a crash.

Reach for Option<T> when the state is simply "present" or "absent". Reach for Result<T, E> when the state is "success" or "error" and you want to propagate failures. Reach for a builder pattern when you are constructing a complex object and want to enforce required fields. The builder often uses typestate internally to track which fields have been set.

The typestate pattern turns the compiler into your state machine simulator. If the code compiles, the state is correct. Let the compiler do the work.

Where to go next