066 — Phantom Types (Type-Safe Units)
Tutorial Video
Text description (accessibility)
This video demonstrates the "066 — Phantom Types (Type-Safe Units)" functional Rust example. Difficulty level: Advanced. Key concepts covered: Functional Programming. Phantom types use type parameters that carry no runtime data — they exist only to prevent mixing incompatible values at compile time. Key difference from OCaml: 1. **`PhantomData`**: Rust requires explicit `PhantomData<U>` to avoid "type parameter U is never used" errors. OCaml's phantom parameters are allowed without any placeholder — the type checker tracks the phantom directly.
Tutorial
The Problem
Phantom types use type parameters that carry no runtime data — they exist only to prevent mixing incompatible values at compile time. Quantity<Meters> and Quantity<Seconds> have the same runtime representation (f64) but are different types. Accidentally adding meters to seconds is a compile-time error, not a runtime error.
This technique prevented the Mars Climate Orbiter crash (1999) — a $327M mission failed because one system output pound-force-seconds while another expected newton-seconds. Phantom types enforce unit correctness statically. They appear in type-safe API design (typed IDs, state machines), dimensional analysis libraries (uom crate), and authentication tokens (typed permissions).
🎯 Learning Outcomes
PhantomData<U> to attach a type parameter that carries no runtime datastruct Meters; struct Seconds;Add for Quantity<U> to allow adding same-unit quantitiesPhantomData<U> informs the compiler about type variancePhantomData<T> with zero runtime overhead to add a phantom type parameter to a structCode Example
#![allow(clippy::all)]
/// # Phantom Types — Type-Safe Units
///
/// Phantom type parameters exist only at the type level — they carry no runtime data
/// but prevent mixing incompatible values (e.g., meters + seconds) at compile time.
use std::marker::PhantomData;
use std::ops::Add;
/// Unit marker types — zero-sized, exist only for the type system.
#[derive(Clone, Copy)]
pub struct Meters;
#[derive(Clone, Copy)]
pub struct Seconds;
/// A quantity tagged with a phantom unit type.
/// `PhantomData<U>` tells the compiler we "use" U without storing it.
#[derive(Debug, Clone, Copy)]
pub struct Quantity<U> {
value: f64,
_unit: PhantomData<U>,
}
impl<U> Quantity<U> {
pub fn new(value: f64) -> Self {
Quantity {
value,
_unit: PhantomData,
}
}
pub fn value(&self) -> f64 {
self.value
}
/// Scale by a dimensionless factor — preserves the unit type.
pub fn scale(&self, factor: f64) -> Self {
Quantity::new(self.value * factor)
}
}
/// Addition is only defined for quantities of the SAME unit.
/// Trying to add Quantity<Meters> + Quantity<Seconds> is a compile error!
impl<U> Add for Quantity<U> {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Quantity::new(self.value + rhs.value)
}
}
/// Convenience constructors
pub fn meters(v: f64) -> Quantity<Meters> {
Quantity::new(v)
}
pub fn seconds(v: f64) -> Quantity<Seconds> {
Quantity::new(v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_same_units() {
let d1 = meters(100.0);
let d2 = meters(50.0);
let total = d1 + d2;
assert!((total.value() - 150.0).abs() < f64::EPSILON);
}
#[test]
fn test_scale() {
let t = seconds(3.0);
let doubled = t.scale(2.0);
assert!((doubled.value() - 6.0).abs() < f64::EPSILON);
}
#[test]
fn test_cannot_add_different_units() {
// This would fail to compile:
// let _ = meters(1.0) + seconds(2.0);
// Error: expected `Quantity<Meters>`, found `Quantity<Seconds>`
assert!(true); // Compile-time safety — the test is that it compiles
}
#[test]
fn test_zero_sized() {
// PhantomData<U> is zero-sized — Quantity is just an f64
assert_eq!(
std::mem::size_of::<Quantity<Meters>>(),
std::mem::size_of::<f64>()
);
}
#[test]
fn test_copy_semantics() {
let d = meters(42.0);
let d2 = d; // Copy, not move
assert!((d.value() - d2.value()).abs() < f64::EPSILON);
}
}Key Differences
PhantomData**: Rust requires explicit PhantomData<U> to avoid "type parameter U is never used" errors. OCaml's phantom parameters are allowed without any placeholder — the type checker tracks the phantom directly.struct Meters; is a zero-sized type (ZST) — no memory at runtime. OCaml's phantom parameter is completely absent at runtime.PhantomData<U> makes Quantity<U> covariant in U (by default). Use PhantomData<fn(U)> for contravariance or PhantomData<*mut U> for invariance. OCaml's variance is inferred.uom crate**: Rust's uom crate (units of measure) uses phantom types extensively to implement dimension-safe arithmetic. This example is the conceptual foundation.PhantomData<T> has zero size — it is erased by the compiler, leaving no runtime overhead.Connection<Closed>, Connection<Open>, Connection<Authenticated>. Methods transition between states: connect(c: Connection<Closed>) -> Connection<Open>. Invalid transitions don't compile.type 'a t = { ... } where 'a is never used in the fields. The type parameter is phantom. OCaml's module system can hide the phantom parameter behind a signature.std::marker::PhantomData<T>:** Required in Rust to tell the type system that T is logically "used" even though no field actually contains T. Without it, the compiler complains about unused type parameters.OCaml Approach
OCaml's phantom types use a type parameter that is never instantiated: type 'a quantity = Quantity of float. type meters = Meters and type seconds = Seconds. let meters x : meters quantity = Quantity x and let seconds x : seconds quantity = Quantity x. At the call site: add (meters 5.0) (seconds 3.0) fails because the type checker sees meters quantity vs seconds quantity as different types.
Full Source
#![allow(clippy::all)]
/// # Phantom Types — Type-Safe Units
///
/// Phantom type parameters exist only at the type level — they carry no runtime data
/// but prevent mixing incompatible values (e.g., meters + seconds) at compile time.
use std::marker::PhantomData;
use std::ops::Add;
/// Unit marker types — zero-sized, exist only for the type system.
#[derive(Clone, Copy)]
pub struct Meters;
#[derive(Clone, Copy)]
pub struct Seconds;
/// A quantity tagged with a phantom unit type.
/// `PhantomData<U>` tells the compiler we "use" U without storing it.
#[derive(Debug, Clone, Copy)]
pub struct Quantity<U> {
value: f64,
_unit: PhantomData<U>,
}
impl<U> Quantity<U> {
pub fn new(value: f64) -> Self {
Quantity {
value,
_unit: PhantomData,
}
}
pub fn value(&self) -> f64 {
self.value
}
/// Scale by a dimensionless factor — preserves the unit type.
pub fn scale(&self, factor: f64) -> Self {
Quantity::new(self.value * factor)
}
}
/// Addition is only defined for quantities of the SAME unit.
/// Trying to add Quantity<Meters> + Quantity<Seconds> is a compile error!
impl<U> Add for Quantity<U> {
type Output = Self;
fn add(self, rhs: Self) -> Self {
Quantity::new(self.value + rhs.value)
}
}
/// Convenience constructors
pub fn meters(v: f64) -> Quantity<Meters> {
Quantity::new(v)
}
pub fn seconds(v: f64) -> Quantity<Seconds> {
Quantity::new(v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_same_units() {
let d1 = meters(100.0);
let d2 = meters(50.0);
let total = d1 + d2;
assert!((total.value() - 150.0).abs() < f64::EPSILON);
}
#[test]
fn test_scale() {
let t = seconds(3.0);
let doubled = t.scale(2.0);
assert!((doubled.value() - 6.0).abs() < f64::EPSILON);
}
#[test]
fn test_cannot_add_different_units() {
// This would fail to compile:
// let _ = meters(1.0) + seconds(2.0);
// Error: expected `Quantity<Meters>`, found `Quantity<Seconds>`
assert!(true); // Compile-time safety — the test is that it compiles
}
#[test]
fn test_zero_sized() {
// PhantomData<U> is zero-sized — Quantity is just an f64
assert_eq!(
std::mem::size_of::<Quantity<Meters>>(),
std::mem::size_of::<f64>()
);
}
#[test]
fn test_copy_semantics() {
let d = meters(42.0);
let d2 = d; // Copy, not move
assert!((d.value() - d2.value()).abs() < f64::EPSILON);
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_same_units() {
let d1 = meters(100.0);
let d2 = meters(50.0);
let total = d1 + d2;
assert!((total.value() - 150.0).abs() < f64::EPSILON);
}
#[test]
fn test_scale() {
let t = seconds(3.0);
let doubled = t.scale(2.0);
assert!((doubled.value() - 6.0).abs() < f64::EPSILON);
}
#[test]
fn test_cannot_add_different_units() {
// This would fail to compile:
// let _ = meters(1.0) + seconds(2.0);
// Error: expected `Quantity<Meters>`, found `Quantity<Seconds>`
assert!(true); // Compile-time safety — the test is that it compiles
}
#[test]
fn test_zero_sized() {
// PhantomData<U> is zero-sized — Quantity is just an f64
assert_eq!(
std::mem::size_of::<Quantity<Meters>>(),
std::mem::size_of::<f64>()
);
}
#[test]
fn test_copy_semantics() {
let d = meters(42.0);
let d2 = d; // Copy, not move
assert!((d.value() - d2.value()).abs() < f64::EPSILON);
}
}
Deep Comparison
Phantom Types — OCaml vs Rust Comparison
Core Insight
Phantom types let you encode invariants in the type system with zero runtime cost. Both OCaml and Rust support them, but Rust requires explicit PhantomData<T> marker while OCaml allows unused type parameters directly. The result is the same: the compiler prevents you from adding meters to seconds.
OCaml Approach
Declares abstract types (type meters, type seconds) with no constructors — they exist purely at the type level. The 'a quantity type carries the phantom parameter in its type signature. OCaml allows unused type parameters without complaint, making the pattern lightweight.
Rust Approach
Uses zero-sized marker structs (struct Meters;) and PhantomData<U> in the quantity struct. PhantomData is a zero-sized type that tells the compiler "I logically use U" without actually storing data. Implementing Add trait only for same-unit quantities enforces safety through the trait system.
Comparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Memory | Same as float | Same as f64 (PhantomData is ZST) |
| Null safety | Not applicable | Not applicable |
| Errors | Type error at compile time | Type error at compile time |
| Iteration | N/A | N/A |
| Marker | Abstract type meters | struct Meters; + PhantomData |
Things Rust Learners Should Notice
PhantomData<T>** is zero-sized — it compiles away completely, Quantity is just an f64struct Meters; (unit struct) carries no data, exists only for typesAdd** — implementing Add for Quantity<U> ensures same-unit additionCopy + Clone** can be derived since all fields are Copy (including PhantomData)meters(1.0) + seconds(2.0) literally cannot compileFurther Reading
Exercises
Speed<Meters, Seconds> phantom type and implement division: Quantity<Meters> / Quantity<Seconds> -> Quantity<Speed>. Use a type alias type MetersPerSecond = Speed<Meters, Seconds>.Connection<Disconnected> and Connection<Connected>. Only Connection<Connected> can have a send() method. This is the typestate pattern.Id<User>, Id<Post>, Id<Comment> as phantom-typed u64 wrappers. Demonstrate that passing a UserId where a PostId is expected fails at compile time.Lock<Locked> and Lock<Unlocked> with fn unlock(lock: Lock<Locked>, key: &str) -> Result<Lock<Unlocked>, &'static str> and fn use_lock(lock: &Lock<Unlocked>) -> &str. The type system prevents using a locked lock.Meters(f64) and Feet(f64). Add to_feet(m: Meters) -> Feet conversion and ensure Meters + Feet doesn't compile.