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.