ExamplesBy LevelBy TopicLearning Paths
066 Advanced

066 — Phantom Types (Type-Safe Units)

Functional Programming

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

  • • Use PhantomData<U> to attach a type parameter that carries no runtime data
  • • Create unit marker types as zero-sized structs: struct Meters; struct Seconds;
  • • Implement Add for Quantity<U> to allow adding same-unit quantities
  • • Understand that PhantomData<U> informs the compiler about type variance
  • • Recognize phantom types as zero-cost abstraction: no runtime overhead
  • • Use PhantomData<T> with zero runtime overhead to add a phantom type parameter to a struct
  • • Implement typestate patterns where calling a method on the wrong state is a compile-time error
  • Code 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.
  • Zero-sized types: Rust's struct Meters; is a zero-sized type (ZST) — no memory at runtime. OCaml's phantom parameter is completely absent at runtime.
  • Variance: 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.
  • Zero runtime cost: Phantom types exist only at compile time. PhantomData<T> has zero size — it is erased by the compiler, leaving no runtime overhead.
  • Type-level state machines: The classic use case: Connection<Closed>, Connection<Open>, Connection<Authenticated>. Methods transition between states: connect(c: Connection<Closed>) -> Connection<Open>. Invalid transitions don't compile.
  • OCaml phantom types: OCaml uses the same technique: 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);
        }
    }
    ✓ Tests Rust test suite
    #[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

    AspectOCamlRust
    MemorySame as floatSame as f64 (PhantomData is ZST)
    Null safetyNot applicableNot applicable
    ErrorsType error at compile timeType error at compile time
    IterationN/AN/A
    MarkerAbstract type metersstruct Meters; + PhantomData

    Things Rust Learners Should Notice

  • **PhantomData<T>** is zero-sized — it compiles away completely, Quantity is just an f64
  • Marker structsstruct Meters; (unit struct) carries no data, exists only for types
  • **Trait bounds on Add** — implementing Add for Quantity<U> ensures same-unit addition
  • **Copy + Clone** can be derived since all fields are Copy (including PhantomData)
  • Compile-time guaranteemeters(1.0) + seconds(2.0) literally cannot compile
  • Further Reading

  • • [PhantomData](https://doc.rust-lang.org/std/marker/struct.PhantomData.html)
  • • [Rust by Example: Phantom types](https://doc.rust-lang.org/rust-by-example/generics/phantom.html)
  • • [Zero-Sized Types](https://doc.rust-lang.org/nomicon/exotic-sizes.html#zero-sized-types-zsts)
  • Exercises

  • Velocity: Define a Speed<Meters, Seconds> phantom type and implement division: Quantity<Meters> / Quantity<Seconds> -> Quantity<Speed>. Use a type alias type MetersPerSecond = Speed<Meters, Seconds>.
  • State machine: Use phantom types to model a connection state: Connection<Disconnected> and Connection<Connected>. Only Connection<Connected> can have a send() method. This is the typestate pattern.
  • Typed IDs: Define 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.
  • Typestate lock: Implement 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.
  • Unit conversion: Use phantom types to prevent mixing units — Meters(f64) and Feet(f64). Add to_feet(m: Meters) -> Feet conversion and ensure Meters + Feet doesn't compile.
  • Open Source Repos