
Rust Type-State Pattern: Encoding Valid Object Lifecycles in the Type System
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:
- create a socket,
- perform a handshake,
- 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:
| Approach | Pros | Cons |
|---|---|---|
| Runtime enum state | Simple to understand | Every method must validate state at runtime |
| Type-state pattern | Invalid transitions are compile-time errors | More types and more generic code |
| Builder with runtime validation | Flexible API | Errors may appear only at build() time |
| Dynamic state machine | Good for plugin-like behavior | Harder 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, orFinalized. - 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 aDraftstate,set_endpoint()transitions toConfigured,build()becomes available only inConfigured,- 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:
- Can invalid usage be detected at compile time?
If yes, type-state may be a good fit.
- Are the states stable and few in number?
If not, a runtime model may be simpler.
- Will consumers understand the workflow from the types alone?
If the answer is yes, the API will feel natural.
- Can you provide a minimal happy path?
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.
