Rust

Type-Safe Units

Using Rust's type system to prevent unit confusion in engineering calculations

Type-Safe Units

Unit confusion kills spacecraft. NASA's Mars Climate Orbiter was lost because one module output thrust in pound-force·seconds while another expected newton·seconds. In RF engineering, the stakes are lower but the bugs are just as sneaky: mixing dB with linear, watts with dBm, or Hz with GHz produces results that look plausible but are silently wrong.

Rust's type system can catch these errors at compile time. This guide covers practical patterns for making unit mistakes impossible — from simple newtypes to more expressive approaches.

The Problem

Consider a function that computes received power:

fn received_power(tx_power: f64, path_loss: f64, antenna_gain: f64) -> f64 {
    tx_power + antenna_gain - path_loss
}

This works if everything is in dB. But what if someone passes linear power? Or path loss in natural units? The compiler can't help — it's all f64.

// Bug: mixing dB and linear. Compiles fine. Wrong answer.
let rx = received_power(10.0, 1e-12, 30.0);

Newtypes: The Simplest Fix

Wrap f64 in a struct. The compiler treats each wrapper as a distinct type:

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Decibels(pub f64);

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Linear(pub f64);

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DecibelMilliwatts(pub f64);

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Watts(pub f64);

Now the function signature documents and enforces its expectations:

fn received_power(
    tx_power: DecibelMilliwatts,
    path_loss: Decibels,
    antenna_gain: Decibels,
) -> DecibelMilliwatts {
    DecibelMilliwatts(tx_power.0 + antenna_gain.0 - path_loss.0)
}

Passing a Watts value where DecibelMilliwatts is expected is now a compile error:

let tx = Watts(10.0);
let loss = Decibels(150.0);
let gain = Decibels(30.0);

// ❌ Compile error: expected `DecibelMilliwatts`, found `Watts`
let rx = received_power(tx, loss, gain);

Conversions with From and Into

Make conversions explicit and correct by implementing the standard traits:

impl From<DecibelMilliwatts> for Watts {
    fn from(dbm: DecibelMilliwatts) -> Self {
        Watts(10f64.powf((dbm.0 - 30.0) / 10.0))
    }
}

impl From<Watts> for DecibelMilliwatts {
    fn from(w: Watts) -> Self {
        DecibelMilliwatts(10.0 * w.0.log10() + 30.0)
    }
}

impl From<Linear> for Decibels {
    fn from(lin: Linear) -> Self {
        Decibels(10.0 * lin.0.log10())
    }
}

impl From<Decibels> for Linear {
    fn from(db: Decibels) -> Self {
        Linear(10f64.powf(db.0 / 10.0))
    }
}

Conversions are now explicit at the call site:

let tx = Watts(10.0);
let tx_dbm: DecibelMilliwatts = tx.into(); // 40 dBm
let rx = received_power(tx_dbm, loss, gain);

Frequency Units

RF calculations span Hz to THz. A common bug is forgetting a 1e9 factor:

#[derive(Debug, Clone, Copy)]
pub struct Hertz(pub f64);

#[derive(Debug, Clone, Copy)]
pub struct MegaHertz(pub f64);

#[derive(Debug, Clone, Copy)]
pub struct GigaHertz(pub f64);

impl From<GigaHertz> for Hertz {
    fn from(ghz: GigaHertz) -> Self {
        Hertz(ghz.0 * 1e9)
    }
}

impl From<MegaHertz> for Hertz {
    fn from(mhz: MegaHertz) -> Self {
        Hertz(mhz.0 * 1e6)
    }
}

impl From<Hertz> for GigaHertz {
    fn from(hz: Hertz) -> Self {
        GigaHertz(hz.0 / 1e9)
    }
}

Now a free-space path loss function can require Hertz:

use std::f64::consts::PI;

const SPEED_OF_LIGHT: f64 = 299_792_458.0;

fn free_space_path_loss(distance_m: f64, freq: Hertz) -> Decibels {
    let wavelength = SPEED_OF_LIGHT / freq.0;
    Decibels(20.0 * (4.0 * PI * distance_m / wavelength).log10())
}

// Caller must convert explicitly
let loss = free_space_path_loss(36_000e3, GigaHertz(12.0).into());

Adding Arithmetic

Newtypes need operator overloads to be ergonomic. Only implement operations that are physically meaningful:

use std::ops::{Add, Sub, Neg};

impl Add for Decibels {
    type Output = Decibels;
    fn add(self, rhs: Decibels) -> Decibels {
        Decibels(self.0 + rhs.0)
    }
}

impl Sub for Decibels {
    type Output = Decibels;
    fn sub(self, rhs: Decibels) -> Decibels {
        Decibels(self.0 - rhs.0)
    }
}

impl Neg for Decibels {
    type Output = Decibels;
    fn neg(self) -> Decibels {
        Decibels(-self.0)
    }
}

But don't implement Add<Decibels> for DecibelMilliwatts returning DecibelMilliwatts — adding a gain (dB) to a power (dBm) gives a power, but adding two powers in dBm is meaningless in log domain. Encode this in the type system:

// ✅ dBm + dB = dBm (adding gain to power)
impl Add<Decibels> for DecibelMilliwatts {
    type Output = DecibelMilliwatts;
    fn add(self, rhs: Decibels) -> DecibelMilliwatts {
        DecibelMilliwatts(self.0 + rhs.0)
    }
}

// ✅ dBm - dB = dBm (subtracting loss from power)
impl Sub<Decibels> for DecibelMilliwatts {
    type Output = DecibelMilliwatts;
    fn sub(self, rhs: Decibels) -> DecibelMilliwatts {
        DecibelMilliwatts(self.0 - rhs.0)
    }
}

// ✅ dBm - dBm = dB (comparing two powers gives a ratio)
impl Sub for DecibelMilliwatts {
    type Output = Decibels;
    fn sub(self, rhs: DecibelMilliwatts) -> Decibels {
        Decibels(self.0 - rhs.0)
    }
}

// ❌ Don't implement: Add<DecibelMilliwatts> for DecibelMilliwatts
// Adding two powers in dBm requires converting to linear first

Display and Formatting

Make debug output readable:

impl std::fmt::Display for Decibels {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:.2} dB", self.0)
    }
}

impl std::fmt::Display for DecibelMilliwatts {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:.2} dBm", self.0)
    }
}

impl std::fmt::Display for GigaHertz {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:.3} GHz", self.0)
    }
}

Here's a simplified link budget using type-safe units:

struct LinkBudget {
    tx_power: DecibelMilliwatts,
    tx_antenna_gain: Decibels,
    path_loss: Decibels,
    rx_antenna_gain: Decibels,
    system_noise_temp: f64,      // Kelvin — another newtype candidate
    bandwidth: Hertz,
}

impl LinkBudget {
    fn eirp(&self) -> DecibelMilliwatts {
        self.tx_power + self.tx_antenna_gain
    }

    fn received_power(&self) -> DecibelMilliwatts {
        self.eirp() + self.rx_antenna_gain - self.path_loss
    }

    fn noise_power(&self) -> DecibelMilliwatts {
        // N = kTB
        let k_boltzmann = 1.380649e-23; // J/K
        let n_watts = k_boltzmann * self.system_noise_temp * self.bandwidth.0;
        Watts(n_watts).into()
    }

    fn snr(&self) -> Decibels {
        self.received_power() - self.noise_power()
    }
}

fn main() {
    let budget = LinkBudget {
        tx_power: DecibelMilliwatts(20.0),       // 100 mW
        tx_antenna_gain: Decibels(45.0),          // High-gain dish
        path_loss: Decibels(205.0),               // GEO Ka-band
        rx_antenna_gain: Decibels(40.0),          // Ground station
        system_noise_temp: 290.0,                 // K
        bandwidth: MegaHertz(36.0).into(),        // Transponder BW
    };

    println!("EIRP:           {}", budget.eirp());
    println!("Received Power: {}", budget.received_power());
    println!("Noise Power:    {}", budget.noise_power());
    println!("SNR:            {}", budget.snr());
}

The type system prevents mixing up gains and powers. If you accidentally swap path_loss and tx_power, the compiler catches it.

When to Use Newtypes

Use newtypes when:

  • Multiple f64 parameters could be confused (same type, different units)
  • Bugs from unit confusion would be hard to spot in test output
  • The API is used across module boundaries
  • You're building a library others will use

Skip newtypes when:

  • The function is small and local with obvious parameter names
  • Performance is critical and you can't afford the (tiny) abstraction cost
  • You're prototyping and will add types later

The #[must_use] Pattern

Combine newtypes with #[must_use] to catch discarded results:

#[derive(Debug, Clone, Copy)]
#[must_use = "power value was computed but never used"]
pub struct DecibelMilliwatts(pub f64);

This catches a subtle bug where a conversion is called but its result is thrown away:

let power = Watts(10.0);
let _: DecibelMilliwatts = power.into(); // OK — result used
DecibelMilliwatts::from(power);          // ⚠️ Warning: unused

Crate Ecosystem

Several crates provide unit systems for Rust:

  • uom — Full SI unit system with compile-time dimension checking. Comprehensive but heavy.
  • dimensioned — Compile-time dimensional analysis. Lighter than uom.
  • Custom newtypes — What this guide covers. Zero dependencies, full control, RF-specific semantics.

For RF work, custom newtypes often win because RF has domain-specific rules (dB arithmetic, noise figure conventions) that generic unit systems don't encode.

Summary

PatternCatchesCost
Raw f64NothingNone
Named parametersDocs onlyNone
NewtypesWrong unit at compile timeBoilerplate
From/IntoIncorrect conversionsConversion implementations
Arithmetic traitsMeaningless operationsTrait implementations

Start with newtypes for your most error-prone interfaces. Add conversions and arithmetic as needed. The compiler becomes your unit-checking co-pilot — bugs that would surface as mysterious 30 dB offsets in test data become red squiggles in your editor instead.