874-phantom-types — Phantom Types
Tutorial
The Problem
Type safety is most powerful when it prevents entire classes of bugs at compile time. Phantom types are a technique where a type parameter appears in a struct definition but carries no data — it exists solely to encode information in the type system. The classic applications are units of measure (preventing meters from being added to seconds), state machines (preventing unlocked-door operations when the door is locked), and access levels (preventing unauthorized API calls). F#'s units of measure, Haskell's phantom type pattern, and OCaml's typed phantom parameters all implement this idea. Rust uses PhantomData<T> to hold the phantom type without adding runtime overhead.
🎯 Learning Outcomes
PhantomData<T> to encode compile-time invariants without runtime costPhantomData is needed in Rust (variance, drop check)Code Example
struct Meters;
struct Seconds;
struct Quantity<Unit> {
value: f64,
_unit: PhantomData<Unit>,
}
impl<U> std::ops::Add for Quantity<U> {
type Output = Self;
fn add(self, rhs: Self) -> Self { Quantity::new(self.value + rhs.value) }
}Key Differences
PhantomData<Unit> because unused type parameters are rejected; OCaml type parameters are allowed to be phantom implicitly.PhantomData<T> is zero-sized; OCaml phantom types also add zero runtime cost.Door<Unlocked> has an open method via separate impl blocks; OCaml uses module signatures to hide invalid methods.PhantomData also controls variance (covariant/contravariant) in Rust; OCaml handles variance through its type system automatically.OCaml Approach
OCaml phantom types use type parameters that are never instantiated: type 'unit quantity = { value: float }. The type meters quantity and seconds quantity are distinct despite having the same runtime representation. OCaml's type checker enforces the distinction. State machines use type unlocked door and type locked door — different phantom instantiations of the same runtime struct. The OCaml approach is more concise but requires discipline since the compiler cannot guarantee exhaustiveness of state transitions.
Full Source
#![allow(clippy::all)]
// Example 080: Phantom Types
// Compile-time safety with phantom type parameters
use std::marker::PhantomData;
// === Approach 1: Units of measure ===
struct Meters;
struct Seconds;
struct MetersPerSecond;
#[derive(Debug, Clone, Copy)]
struct Quantity<Unit> {
value: f64,
_unit: PhantomData<Unit>,
}
impl<U> Quantity<U> {
fn new(value: f64) -> Self {
Quantity {
value,
_unit: PhantomData,
}
}
fn scale(self, factor: f64) -> Self {
Quantity::new(self.value * factor)
}
}
// Same-unit addition
impl<U> std::ops::Add for Quantity<U> {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Quantity::new(self.value + rhs.value)
}
}
fn speed(distance: Quantity<Meters>, time: Quantity<Seconds>) -> Quantity<MetersPerSecond> {
Quantity::new(distance.value / time.value)
}
// === Approach 2: State machine with phantom types ===
struct Locked;
struct Unlocked;
struct Door<State> {
name: String,
_state: PhantomData<State>,
}
impl Door<Unlocked> {
fn new(name: &str) -> Self {
Door {
name: name.to_string(),
_state: PhantomData,
}
}
fn lock(self) -> Door<Locked> {
Door {
name: self.name,
_state: PhantomData,
}
}
fn walk_through(&self) -> String {
format!("Walked through {}", self.name)
}
}
impl Door<Locked> {
fn unlock(self) -> Door<Unlocked> {
Door {
name: self.name,
_state: PhantomData,
}
}
// Cannot walk_through a locked door — method doesn't exist!
}
// === Approach 3: Validated data ===
struct Unvalidated;
struct Validated;
struct Email<State> {
address: String,
_state: PhantomData<State>,
}
impl Email<Unvalidated> {
fn new(address: &str) -> Self {
Email {
address: address.to_string(),
_state: PhantomData,
}
}
fn validate(self) -> Result<Email<Validated>, String> {
if self.address.contains('@') {
Ok(Email {
address: self.address,
_state: PhantomData,
})
} else {
Err(format!("Invalid email: {}", self.address))
}
}
}
impl Email<Validated> {
fn send(&self) -> String {
format!("Sent to {}", self.address)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quantity_addition() {
let a = Quantity::<Meters>::new(10.0);
let b = Quantity::<Meters>::new(20.0);
let c = a + b;
assert!((c.value - 30.0).abs() < 1e-10);
}
#[test]
fn test_speed_calculation() {
let d = Quantity::<Meters>::new(100.0);
let t = Quantity::<Seconds>::new(10.0);
let s = speed(d, t);
assert!((s.value - 10.0).abs() < 1e-10);
}
#[test]
fn test_scale() {
let d = Quantity::<Meters>::new(5.0);
let d2 = d.scale(3.0);
assert!((d2.value - 15.0).abs() < 1e-10);
}
#[test]
fn test_door_state_machine() {
let door = Door::<Unlocked>::new("test");
assert_eq!(door.walk_through(), "Walked through test");
let locked = door.lock();
let unlocked = locked.unlock();
assert_eq!(unlocked.walk_through(), "Walked through test");
}
#[test]
fn test_valid_email() {
let email = Email::<Unvalidated>::new("a@b.com");
let valid = email.validate().unwrap();
assert_eq!(valid.send(), "Sent to a@b.com");
}
#[test]
fn test_invalid_email() {
let email = Email::<Unvalidated>::new("nope");
assert!(email.validate().is_err());
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quantity_addition() {
let a = Quantity::<Meters>::new(10.0);
let b = Quantity::<Meters>::new(20.0);
let c = a + b;
assert!((c.value - 30.0).abs() < 1e-10);
}
#[test]
fn test_speed_calculation() {
let d = Quantity::<Meters>::new(100.0);
let t = Quantity::<Seconds>::new(10.0);
let s = speed(d, t);
assert!((s.value - 10.0).abs() < 1e-10);
}
#[test]
fn test_scale() {
let d = Quantity::<Meters>::new(5.0);
let d2 = d.scale(3.0);
assert!((d2.value - 15.0).abs() < 1e-10);
}
#[test]
fn test_door_state_machine() {
let door = Door::<Unlocked>::new("test");
assert_eq!(door.walk_through(), "Walked through test");
let locked = door.lock();
let unlocked = locked.unlock();
assert_eq!(unlocked.walk_through(), "Walked through test");
}
#[test]
fn test_valid_email() {
let email = Email::<Unvalidated>::new("a@b.com");
let valid = email.validate().unwrap();
assert_eq!(valid.send(), "Sent to a@b.com");
}
#[test]
fn test_invalid_email() {
let email = Email::<Unvalidated>::new("nope");
assert!(email.validate().is_err());
}
}
Deep Comparison
Comparison: Phantom Types
Units of Measure
OCaml:
type meters
type seconds
type 'a quantity = { value : float }
let meters v : meters quantity = { value = v }
let seconds v : seconds quantity = { value = v }
let add_same (a : 'a quantity) (b : 'a quantity) : 'a quantity =
{ value = a.value +. b.value }
Rust:
struct Meters;
struct Seconds;
struct Quantity<Unit> {
value: f64,
_unit: PhantomData<Unit>,
}
impl<U> std::ops::Add for Quantity<U> {
type Output = Self;
fn add(self, rhs: Self) -> Self { Quantity::new(self.value + rhs.value) }
}
State Machine
OCaml:
type unlocked
type locked
type 'state door = { name : string }
let lock (d : unlocked door) : locked door = { name = d.name }
let walk_through (d : unlocked door) = Printf.sprintf "Walked through %s" d.name
Rust:
struct Door<State> { name: String, _state: PhantomData<State> }
impl Door<Unlocked> {
fn lock(self) -> Door<Locked> { Door { name: self.name, _state: PhantomData } }
fn walk_through(&self) -> String { format!("Walked through {}", self.name) }
}
// Door<Locked> has no walk_through — won't compile!
Validated Data
OCaml:
type 'a email = Email of string
let validate_email (Email s : unvalidated email) : validated email option =
if String.contains s '@' then Some (Email s) else None
let send_email (Email s : validated email) = Printf.sprintf "Sent to %s" s
Rust:
impl Email<Unvalidated> {
fn validate(self) -> Result<Email<Validated>, String> { /* ... */ }
}
impl Email<Validated> {
fn send(&self) -> String { format!("Sent to {}", self.address) }
}
Exercises
Kilograms unit and implement force(mass: Quantity<Kilograms>, acceleration: Quantity<MetersPerSecond>) -> Quantity<Newtons>.Ajar state to the door state machine and define legal transitions: Unlocked -> Ajar -> Locked.ApiKey<Permission> where ReadOnly and ReadWrite permissions grant different method sets.