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.
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);
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);
From and IntoMake 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);
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());
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
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.
Use newtypes when:
f64 parameters could be confused (same type, different units)Skip newtypes when:
#[must_use] PatternCombine 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
Several crates provide unit systems for Rust:
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.
| Pattern | Catches | Cost |
|---|---|---|
Raw f64 | Nothing | None |
| Named parameters | Docs only | None |
| Newtypes | Wrong unit at compile time | Boilerplate |
From/Into | Incorrect conversions | Conversion implementations |
| Arithmetic traits | Meaningless operations | Trait 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.