PhantomData and Marker Types

Builder Pattern and Typestate encoded a builder's progress into the type system so that calling .build() too early wouldn't compile. The machinery that makes that work, type parameters that exist purely at compile time and vanish at runtime, is the subject here. A marker type is a zero-sized type that carries information for the compiler but occupies no memory; PhantomData<T> is the tool that lets a struct "use" a type parameter it doesn't actually store. Together they let you attach compile-time guarantees, states, units, ownership semantics, to a type at zero runtime cost.


Zero-Sized Types: Information Without Memory

A struct with no fields, or only other zero-sized fields, occupies zero bytes. It exists entirely at the type level: you can pass it, return it, and bound on it, but it compiles away to nothing. This is what makes markers free, distinguishing two types costs nothing at runtime.

struct Meters;       // zero-sized: a name the compiler knows, no runtime footprint
struct Feet;

assert_eq!(std::mem::size_of::<Meters>(), 0);

These empty types are useless alone, their value comes from using them as type parameters to tag an otherwise-identical value, so the compiler treats Distance<Meters> and Distance<Feet> as incompatible. But a naive attempt to do that hits an immediate wall.


The Problem: "Unused" Type Parameters Don't Compile

Try to tag a wrapper with a unit type and the compiler rejects it, a generic parameter must actually appear in the struct's fields:

// ERROR: parameter `Unit` is never used
struct Distance<Unit> {
    value: f64,
}
error[E0392]: parameter `Unit` is never used
help: consider removing `Unit`, referring to it in a field, or using a marker such as `PhantomData`

The compiler insists every type parameter be used, because whether a parameter affects layout, variance, and drop behavior matters for soundness. But you don't want to store a Unit, the whole point is that Meters is zero-sized and carries no data. PhantomData is the resolution: it tells the compiler "pretend this struct contains a T" without actually storing one.


PhantomData<T>: Claiming a Type Without Storing It

std::marker::PhantomData<T> is a zero-sized type that makes a struct act as though it holds a T, satisfying the "parameter must be used" rule while adding zero bytes. It's the standard way to carry a type parameter that exists only for compile-time tagging:

use std::marker::PhantomData;

struct Distance<Unit> {
    value: f64,
    _unit: PhantomData<Unit>,   // zero bytes; satisfies "Unit must be used"
}

impl<Unit> Distance<Unit> {
    fn new(value: f64) -> Self {
        Distance { value, _unit: PhantomData }
    }
}

let a = Distance::<Meters>::new(100.0);
let b = Distance::<Feet>::new(100.0);
// a and b are DIFFERENT types — you can't add them or pass one where the other is expected

Distance<Meters> and Distance<Feet> have identical runtime representation (one f64), but the compiler treats them as unrelated types. A function taking Distance<Meters> rejects a Distance<Feet> at compile time, so a units mix-up (the kind that crashed real spacecraft) becomes a type error. The PhantomData field costs nothing: size_of::<Distance<Meters>>() is still 8 bytes, the same as a bare f64.


Marker Types as State: Compile-Time State Machines

The same mechanism powers the typestate pattern from the builder tutorial: use a marker parameter to represent a state, and make each method available only in the right state. Because the marker is zero-sized, the entire state machine exists at compile time with no runtime tag to check.

use std::marker::PhantomData;

struct Locked;
struct Unlocked;

struct Door<State> {
    _state: PhantomData<State>,
}

impl Door<Locked> {
    fn unlock(self) -> Door<Unlocked> {     // only a Locked door can be unlocked
        Door { _state: PhantomData }
    }
}

impl Door<Unlocked> {
    fn open(self) {                          // only an Unlocked door can be opened
        println!("opened");
    }
}

door.open() only compiles when door is a Door<Unlocked>; trying to open a Door<Locked> is a compile error, not a runtime check. This is the "make illegal states unrepresentable" principle from the newtype and TryFrom tutorials, lifted to whole-object states: the invalid call doesn't exist in the type system, so there's no runtime branch and no way to forget the check.

Gotcha: PhantomData<T> isn't always interchangeable, the T you pick signals ownership and variance to the compiler, which affects drop-checking and lifetime soundness. For pure markers like units or states, PhantomData<Unit> is fine. But if your struct holds a raw pointer to a T it logically owns (common in FFI/unsafe code, from Unsafe Rust Basics), use PhantomData<T> so the compiler knows you own a T and runs drop-check correctly; for a borrowed *const T, PhantomData<&'a T> conveys the lifetime. Choosing the wrong phantom type can silently weaken the guarantees the compiler would otherwise enforce.


When to Reach for a Marker Type

GoalPattern
Distinguish same-shaped values (units, IDs, currencies)marker type parameter + PhantomData
Enforce valid state transitions at compile timetypestate: marker per state, methods per impl
"Use" a type/lifetime param a struct doesn't storePhantomData<T> / PhantomData<&'a T>
Signal ownership of a T behind a raw pointerPhantomData<T> (for correct drop-check)
A simple runtime-checked statea plain enum field — not a marker

The judgment call: markers move a check from runtime to compile time, which is a real win for correctness-critical distinctions (units, protocol states, capability tokens) but adds type-level complexity. If a state genuinely needs to be inspected or changed at runtime (read from input, toggled in a loop), a plain enum field (Pattern Matching Deep Dive) is the right tool, not a phantom marker. Reach for markers when the distinction should be impossible to get wrong, not merely tracked.


Key Takeaways

  • A zero-sized marker type (an empty struct) carries information for the compiler but occupies no memory, so distinguishing types with it is free at runtime.
  • Rust rejects an "unused" type parameter; PhantomData<T> satisfies the rule by making a struct act as if it holds a T while adding zero bytes, the standard way to carry a compile-time-only tag.
  • Tagging values with a marker parameter (Distance<Meters> vs Distance<Feet>) makes same-shaped values into incompatible types, turning unit/ID mix-ups into compile errors at no runtime cost.
  • The typestate pattern uses a marker per state with methods on each impl, so an invalid operation (opening a locked door) doesn't compile, the runtime check disappears entirely.
  • The T in PhantomData<T> signals ownership and variance, not just "used"; pick it carefully (PhantomData<T> for owned, PhantomData<&'a T> for borrowed) in unsafe/FFI code so drop-check stays sound.
  • Use markers for correctness-critical distinctions that should be impossible to get wrong; use a plain enum field when the state must be inspected or changed at runtime.