rust language logo

Beyond Unit Errors: Using Rust's PhantomData for Type-Safe Physical Quantities

The Danger of Primitive Obsession

In many languages, we represent physical measurements like distance, weight, or temperature using raw floating-point numbers. While simple, this leads to 'primitive obsession,' where a function expecting meters accidentally receives feet. Rust offers a powerful way to eliminate these bugs entirely at compile time using PhantomData.

PhantomData allows us to 'tag' a struct with a type that isn't actually stored in memory. This creates a zero-cost abstraction: your code becomes impossible to misuse, but the compiled binary performs exactly like it's using raw numbers.

Defining Our Unit Markers

First, we define empty markers for the units we want to support. These don't need to hold data; they exist only to satisfy the type system.

use std::marker::PhantomData;

#[derive(Debug, Clone, Copy)]
struct Meters;

#[derive(Debug, Clone, Copy)]
struct Kilometers;

#[derive(Debug, Clone, Copy)]
struct Quantity<T, U> {
    value: T,
    _unit: PhantomData<U>,
}

Implementing Type-Safe Arithmetic

Now, we can implement logic that ensures we only add meters to meters. By restricting the generic parameter U, we prevent the compiler from allowing mixed-unit arithmetic.

impl<T, U> Quantity<T, U> {
    fn new(value: T) -> Self {
        Self {
            value,
            _unit: PhantomData,
        }
    }
}

// Only allow addition if units match exactly
impl<T, U> std::ops::Add for Quantity<T, U>
where
    T: std::ops::Add<Output = T>,
{
    type Output = Self;

    fn add(self, other: Self) -> Self {
        Self::new(self.value + other.value)
    }
}

Explicit Conversions Only

The beauty of this pattern is that you must be explicit about conversions. You can't accidentally pass a Quantity<f64, Kilometers> into a function expecting Quantity<f64, Meters>. You must provide a dedicated conversion method.

impl Quantity<f64, Kilometers> {
    fn to_meters(self) -> Quantity<f64, Meters> {
        Quantity::new(self.value * 1000.0)
    }
}

fn main() {
    let dist_a = Quantity::<f64, Meters>::new(500.0);
    let dist_b = Quantity::<f64, Kilometers>::new(1.2);

    // This line would fail to compile:
    // let total = dist_a + dist_b;

    // This works and is safe:
    let total = dist_a + dist_b.to_meters();
    println!("Total distance: {:?} meters", total.value);
}

Why This Wins

When you use this pattern, the PhantomData field is a ZST (Zero Sized Type). It takes up no space in the resulting machine code. Your CPU sees f64 additions, but your developer experience feels like a high-level, specialized domain language. This effectively moves your business logic validation from runtime unit tests to the compiler itself.