PhantomData and the Type-State Pattern: Protocol States the Compiler Enforces
PhantomData and the Type-State Pattern: Protocol States the Compiler Enforces
Thesis
A lot of APIs have an order to them: connect, then log in, then query. Most libraries check that order at runtime, with a state field and an Err(NotConnected) when it’s wrong. Rust can check it at compile time instead. Make the state a type parameter, give each state its own impl block, and have every transition take self by value and return the next state. Calling a method in the wrong state is then a type error. Reusing an old state is a use-after-move error.
The part that makes this work is std::marker::PhantomData<S>. It is a zero-sized field that “uses” the type parameter without storing anything. This chapter builds a small type-state connection, shows the compiler errors it produces, measures what it costs at runtime (nothing), and covers the one decision you have to get right: which PhantomData.
All listings were compiled with rustc 1.99.1, edition 2024.
Why you need PhantomData at all
Rust rejects a type parameter that no field mentions:
struct Conn<S> { host: String }error[E0392]: type parameter `S` is never used
--> bad3.rs:1:13
|
1 | struct Conn<S> { host: String }
| ^ unused type parameter
|
= help: consider removing `S`, referring to it in a field, or using a marker such as `PhantomData`
= help: if you intended `S` to be a const parameter, use `const S: /* Type */` instead
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0392`.
The error is deliberate. The compiler needs to know how Conn<S> relates to S for variance, drop checking and auto traits (Send/Sync). PhantomData<T> answers that question by saying “act as if this struct holds a T” (std docs), while taking zero bytes.
The type-state connection
The states are empty structs that are never constructed. They exist only as type arguments.
use std::marker::PhantomData;
use std::mem::size_of;
// States are zero-sized marker types. They never exist at runtime.
pub struct Idle;
pub struct Connected;
pub struct Authed;
pub struct Conn<S> {
host: String,
log: Vec<String>,
_state: PhantomData<S>,
}
impl Conn<Idle> {
pub fn new(host: &str) -> Self {
Conn { host: host.to_string(), log: vec![], _state: PhantomData }
}
pub fn connect(mut self) -> Conn<Connected> {
self.log.push(format!("connect {}", self.host));
Conn { host: self.host, log: self.log, _state: PhantomData }
}
}
impl Conn<Connected> {
pub fn login(mut self, user: &str) -> Result<Conn<Authed>, Conn<Connected>> {
if user.is_empty() {
return Err(self); // stay in Connected, caller keeps the value
}
self.log.push(format!("login {user}"));
Ok(Conn { host: self.host, log: self.log, _state: PhantomData })
}
}
impl Conn<Authed> {
pub fn query(&mut self, q: &str) -> usize {
self.log.push(format!("query {q}"));
q.len()
}
pub fn close(self) -> Vec<String> {
self.log
}
}
// Methods every state shares go on the generic impl.
impl<S> Conn<S> {
pub fn host(&self) -> &str { &self.host }
}Three design choices carry the pattern:
- Transitions take
selfby value.connect(self) -> Conn<Connected>consumes the idle connection, so the old state can’t be used again. - Fallible transitions return the old state on failure.
loginreturnsResult<Conn<Authed>, Conn<Connected>>. A failed login hands back the still-connected value instead of dropping it. - Shared behaviour goes on
impl<S> Conn<S>.host()works in every state, so it isn’t copied three times.
The driver:
fn main() {
let c = Conn::new("db.internal");
println!("size_of::<Conn<Idle>>() = {}", size_of::<Conn<Idle>>());
println!("size_of::<Conn<Authed>>() = {}", size_of::<Conn<Authed>>());
println!("size_of::<PhantomData<Authed>>() = {}", size_of::<PhantomData<Authed>>());
let c = c.connect();
let c = match c.login("") {
Ok(_) => unreachable!(),
Err(still_connected) => { println!("empty user rejected, still on {}", still_connected.host()); still_connected }
};
let mut c = c.login("kanthi").ok().expect("login");
let n = c.query("SELECT 1");
println!("query returned {n}");
for line in c.close() { println!("log: {line}"); }
}size_of::<Conn<Idle>>() = 48
size_of::<Conn<Authed>>() = 48
size_of::<PhantomData<Authed>>() = 0
empty user rejected, still on db.internal
query returned 8
log: connect db.internal
log: login kanthi
log: query SELECT 1
Conn<Idle> and Conn<Authed> are both 48 bytes (a String and a Vec, 24 bytes each on x86-64), and PhantomData<Authed> is 0. The state costs nothing at runtime: there’s no tag byte and no branch. Each impl block is monomorphised separately, so query only exists for Conn<Authed>.
What the compiler catches
Querying before login:
let c = Conn::new("db.internal").connect();
c.query("SELECT 1"); // not logged in yeterror[E0599]: no method named `query` found for struct `Conn<Connected>` in the current scope
--> bad1.rs:67:7
|
9 | pub struct Conn<S> {
| ------------------ method `query` not found for this struct
...
67 | c.query("SELECT 1"); // not logged in yet
| ^^^^^ method not found in `Conn<Connected>`
|
= note: the method was found for `Conn<Authed>`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0599`.
The note the method was found for Conn<Authed> points straight at the missing transition.
Reusing a state after it has been consumed:
let c = Conn::new("db.internal");
let a = c.connect();
let b = c.connect(); // reuse the Idle valueerror[E0382]: use of moved value: `c`
--> bad2.rs:68:13
|
66 | let c = Conn::new("db.internal");
| - move occurs because `c` has type `Conn<Idle>`, which does not implement the `Copy` trait
67 | let a = c.connect();
| --------- `c` moved due to this method call
68 | let b = c.connect(); // reuse the Idle value
| ^ value used here after move
|
note: `Conn::<Idle>::connect` takes ownership of the receiver `self`, which moves `c`
--> bad2.rs:19:24
|
19 | pub fn connect(mut self) -> Conn<Connected> {
| ^^^^
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0382`.
A runtime state machine would catch neither mistake until the code ran, if at all.
Choosing the right PhantomData
PhantomData<S> says “I own an S”, so Conn<S> takes on S’s auto traits. That matters if a state marker happens to be !Send, even though no S value ever exists:
use std::marker::PhantomData;
use std::rc::Rc;
// A state marker that happens to be !Send (it is never constructed).
#[allow(dead_code)]
struct Local(Rc<()>);
struct Owns<S>(PhantomData<S>); // "acts like it owns an S"
struct Tag<S>(PhantomData<fn() -> S>); // "only names S": Send + Sync, covariant
fn assert_send<T: Send>() {}
fn main() {
assert_send::<Tag<Local>>();
assert_send::<Owns<Local>>();
}error[E0277]: `Rc<()>` cannot be sent between threads safely
--> auto.rs:15:19
|
15 | assert_send::<Owns<Local>>();
| ^^^^^^^^^^^ `Rc<()>` cannot be sent between threads safely
|
= help: within `Owns<Local>`, the trait `Send` is not implemented for `Rc<()>`
note: required because it appears within the type `Local`
--> auto.rs:6:8
|
6 | struct Local(Rc<()>);
| ^^^^^
note: required because it appears within the type `PhantomData<Local>`
--> /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/marker.rs:811:11
note: required because it appears within the type `Owns<Local>`
--> auto.rs:8:8
|
8 | struct Owns<S>(PhantomData<S>); // "acts like it owns an S"
| ^^^^
note: required by a bound in `assert_send`
--> auto.rs:11:19
|
11 | fn assert_send<T: Send>() {}
| ^^^^ required by this bound in `assert_send`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0277`.
Tag<Local> compiles and Owns<Local> does not. The Rustonomicon’s PhantomData table lists the options:
| Marker | Variance in T |
Send/Sync |
T may dangle in drop glue? |
|---|---|---|---|
PhantomData<T> |
covariant | inherited from T |
no (“owns T”) |
PhantomData<&'a T> |
covariant | Send + Sync require T: Sync |
yes |
PhantomData<*const T> |
covariant | !Send + !Sync |
yes |
PhantomData<fn() -> T> |
covariant | Send + Sync |
yes |
PhantomData<fn(T)> |
contravariant | Send + Sync |
yes |
PhantomData<fn(T) -> T> |
invariant | Send + Sync |
yes |
For type-state markers, where S is only a label, PhantomData<fn() -> S> is usually the most accurate choice. The struct never owns an S, so it shouldn’t inherit S’s thread-safety or drop behaviour. Use plain PhantomData<S> when the struct really does own S values in a way the compiler can’t see, such as a raw-pointer collection, because that is what keeps drop checking sound.
When not to use type-state
- The state comes from runtime data. If the server decides whether you’re authenticated, you still need
Resultat the boundary, asloginshows. You can’t hold aVecof connections in different states without an enum or a trait object, so mixed collections go back to runtime state. - There are many states and many shared methods. Every state you add means another
implblock and more generic signatures. More than four or five states is usually a sign you want an enum. - The API is public and stable. Each state type becomes part of your public API, and renaming one is a breaking change.
Takeaways
- A type parameter plus
PhantomDataturns protocol order into type checking, with zero runtime cost (48 bytes in every state, measured). - Transitions consume
self. A failed transition returns the old state inErr. - Out-of-order calls become E0599 and reuse becomes E0382, both at compile time.
- Pick the marker on purpose:
PhantomData<fn() -> S>for pure labels,PhantomData<S>only when you logically own anS.
Sources
- Rust standard library,
std::marker::PhantomData - The Rustonomicon, PhantomData (variance, auto-trait and drop-check table)
- Rust compiler error index, E0392, E0599, E0382
- The Rust Reference, Subtyping and Variance