What the type-state pattern solves

Many systems have objects that are only valid in certain states:

  • A database connection must be opened before queries run.
  • A file builder must be configured before it can be finalized.
  • A network handshake must complete before encrypted messages are sent.
  • A job workflow may require approval before execution.

A traditional Rust API might represent this with a struct and a state: enum field. That works, but every method must validate the current state at runtime. The type-state pattern instead uses the type itself to encode the state.

Core idea

Each state gets its own zero-sized marker type, and the main struct is parameterized over that state:

struct Connection<State> {
    socket: TcpStream,
    state: State,
}

Methods then consume one state and return another, making legal transitions explicit.


A practical example: a connection handshake

Suppose we are building a client that must:

  1. create a socket,
  2. perform a handshake,
  3. send authenticated requests.

We want to prevent send_request() from being called before the handshake completes.

Define state markers

use std::marker::PhantomData;

struct Disconnected;
struct Connected;
struct Authenticated;

These marker types carry no runtime data. They exist only to distinguish states at compile time.

Model the connection

use std::net::TcpStream;
use std::io::{self, Write};

struct Client<State> {
    stream: TcpStream,
    _state: PhantomData<State>,
}

impl Client<Disconnected> {
    fn connect(addr: &str) -> io::Result<Client<Connected>> {
        let stream = TcpStream::connect(addr)?;
        Ok(Client {
            stream,
            _state: PhantomData,
        })
    }
}

impl Client<Connected> {
    fn authenticate(mut self, token: &str) -> io::Result<Client<Authenticated>> {
        self.stream.write_all(token.as_bytes())?;
        self.stream.write_all(b"\n")?;

        Ok(Client {
            stream: self.stream,
            _state: PhantomData,
        })
    }
}

impl Client<Authenticated> {
    fn send_request(mut self, request: &str) -> io::Result<()> {
        self.stream.write_all(request.as_bytes())?;
        self.stream.write_all(b"\n")?;
        Ok(())
    }
}

Using it

fn main() -> std::io::Result<()> {
    let client = Client::<Disconnected>::connect("127.0.0.1:8080")?;
    let client = client.authenticate("secret-token")?;
    client.send_request("GET /v1/status")?;
    Ok(())
}

If you try to call send_request() before authenticate(), the code will not compile. That is the key benefit: invalid usage is rejected early and clearly.


Why this is better than runtime state checks

Here is a comparison of common approaches:

ApproachProsCons
Runtime enum stateSimple to understandEvery method must validate state at runtime
Type-state patternInvalid transitions are compile-time errorsMore types and more generic code
Builder with runtime validationFlexible APIErrors may appear only at build() time
Dynamic state machineGood for plugin-like behaviorHarder to optimize and reason about

The type-state pattern is most valuable when the lifecycle is stable and the valid transitions are known in advance.


Designing state transitions cleanly

A good type-state API should make transitions obvious and cheap.

Prefer consuming self for transitions

When moving from one state to another, take ownership of the previous state and return the next one:

impl Client<Connected> {
    fn authenticate(self, token: &str) -> io::Result<Client<Authenticated>> {
        // ...
        Ok(Client {
            stream: self.stream,
            _state: PhantomData,
        })
    }
}

This prevents accidental reuse of an object after it has transitioned. It also matches the semantics of “this object is no longer in the old state.”

Keep shared data in the main struct

Only the state marker should change. The actual resource, such as a socket or file handle, should remain in the same struct and be moved forward through transitions. This avoids duplication and keeps the API efficient.

Use private fields to enforce invariants

If callers can construct arbitrary states manually, the abstraction weakens. Make the marker field private and expose only valid constructors and transitions.

pub struct Client<State> {
    stream: TcpStream,
    _state: PhantomData<State>,
}

A second example: a file builder with compile-time phases

Builders are a natural fit for type-state when some configuration is mandatory and some is optional.

Imagine a report file that must have a title before it can be finalized.

use std::fs::File;
use std::io::{self, Write};
use std::marker::PhantomData;

struct NoTitle;
struct HasTitle;

struct ReportBuilder<State> {
    file: File,
    title: Option<String>,
    _state: PhantomData<State>,
}

impl ReportBuilder<NoTitle> {
    fn new(path: &str) -> io::Result<Self> {
        Ok(Self {
            file: File::create(path)?,
            title: None,
            _state: PhantomData,
        })
    }

    fn title(mut self, title: impl Into<String>) -> ReportBuilder<HasTitle> {
        self.title = Some(title.into());
        ReportBuilder {
            file: self.file,
            title: self.title,
            _state: PhantomData,
        }
    }
}

impl ReportBuilder<HasTitle> {
    fn write_line(mut self, line: &str) -> io::Result<Self> {
        writeln!(self.file, "{line}")?;
        Ok(self)
    }

    fn finish(mut self) -> io::Result<()> {
        if let Some(title) = self.title.take() {
            writeln!(self.file, "# {title}")?;
        }
        Ok(())
    }
}

This design ensures that finish() is unavailable until a title has been set. The compiler enforces the workflow.


When to use type-state

Use the type-state pattern when:

  • the object has a small number of well-defined states,
  • transitions are predictable,
  • invalid usage should be impossible rather than merely handled,
  • API consumers benefit from strong guidance.

It is especially effective for:

  • protocol clients,
  • parsers with staged initialization,
  • builders with required steps,
  • hardware drivers,
  • authentication and authorization flows.

When not to use it

Avoid it when:

  • the state machine is highly dynamic or user-defined,
  • there are many states and transitions, making types explode combinatorially,
  • runtime flexibility matters more than static guarantees,
  • the API would become too complex for consumers.

In those cases, a runtime enum or a simpler builder may be more maintainable.


Common implementation techniques

1. Marker types with PhantomData

Most type-state implementations use PhantomData to associate a state type without storing it.

use std::marker::PhantomData;

struct Session<State> {
    id: u64,
    _state: PhantomData<State>,
}

This tells the compiler that Session<LoggedOut> and Session<LoggedIn> are distinct types, even though the runtime representation is the same.

2. Separate impl blocks per state

Put methods only on the states where they make sense.

impl Session<LoggedOut> {
    fn login(self) -> Session<LoggedIn> {
        Session {
            id: self.id,
            _state: PhantomData,
        }
    }
}

This keeps the API discoverable and prevents accidental misuse.

3. Transition methods consume ownership

State changes usually mean the old state should no longer be used. Taking self helps encode that rule directly.

4. Hide constructors for invalid states

If users can create Session<LoggedIn> directly, the guarantee is gone. Keep constructors private or expose only valid entry points.


Trade-offs and best practices

Type-state improves correctness, but it is not free. Use it intentionally.

Benefits

  • Compile-time enforcement of lifecycle rules
  • Better API documentation through types
  • Fewer runtime checks
  • Stronger refactoring safety

Costs

  • More generic code and more types
  • Longer type signatures
  • Potentially steeper learning curve for API users
  • More complex error messages if overused

Best practices

  • Keep the number of states small and meaningful.
  • Name state types clearly, such as Disconnected, Connected, Ready, or Finalized.
  • Use transitions that mirror real workflow steps.
  • Avoid exposing internal state markers publicly unless necessary.
  • Consider helper constructors to reduce verbosity.
  • Add examples in documentation showing the intended sequence.

Combining type-state with builders

A common pattern is to use type-state for mandatory setup and a conventional builder for optional configuration.

For example:

  • new() creates a Draft state,
  • set_endpoint() transitions to Configured,
  • build() becomes available only in Configured,
  • optional methods like timeout() remain available in both states if they do not affect validity.

This hybrid design keeps the API ergonomic while preserving strong guarantees where they matter most.


Practical guidance for library authors

If you are designing a public Rust library, ask these questions:

  1. Can invalid usage be detected at compile time?
  2. If yes, type-state may be a good fit.

  1. Are the states stable and few in number?
  2. If not, a runtime model may be simpler.

  1. Will consumers understand the workflow from the types alone?
  2. If the answer is yes, the API will feel natural.

  1. Can you provide a minimal happy path?
  2. A good type-state API should make the correct sequence easy to follow.

A well-designed type-state API often reads like a protocol specification. That is a sign you are using the pattern effectively.


Summary

The type-state pattern lets Rust encode object lifecycles in the type system. By representing each valid phase as a distinct type, you can make illegal transitions unrepresentable and move entire classes of bugs from runtime to compile time.

Use it when your API has a clear sequence of states and correctness depends on following that sequence. Keep the design small, explicit, and ergonomic, and it becomes a powerful tool for building safe, self-documenting abstractions.

Learn more with useful resources