Rust Newtype Pattern: Catch Unit Bugs at Compile Time
Prevent degree/radian mix-ups in Rust with the newtype pattern. Real astronomy case study, From/Into conversions, operator overloading, and compile-fail tests.
Stop mixing degrees and radians. Let
rustcdo it for you.
TL;DR: Newtypes prevent mixing units by making types incompatible at compile time. Use From/Into for explicit conversions. Don't implement Deref; it defeats the purpose. Reach for uom when you need full dimensional analysis.
Your telescope tracking software just slewed 57 degrees when it should have moved 1 radian. The window's gone. Somewhere in a few thousand lines of f64 math, a .to_radians() never happened.
When everything is an f64, Rust can't help you. But once you give angles and distances real types, it can stop the bug before you ship it.
Why unit bugs happen in Rust codebases
On September 23, 1999, NASA lost the Mars Climate Orbiter, a $327 million mission, after a unit mismatch. Lockheed Martin's ground software produced thruster impulse in pound-force-seconds (lbf·s), while the navigation system expected newton-seconds (N·s), off by a factor of 4.45 (1 lbf = 4.45 N). The Mishap Investigation Board reported that a periapsis of 226 km was planned, but after-the-fact estimates put it at around 57 km, "too low for spacecraft survival."
That's an extreme case, but the underlying mistake is ordinary. It happens whenever two values share the same primitive type but mean different things:
fn point_telescope(angle: f64, distance: f64) {
// angle in... degrees? radians? arcseconds?
// distance in... meters? AU? parsecs?
let x = distance * angle.cos(); // f64::cos() takes radians, not degrees
// ...
}Every f64 looks the same to the compiler. So you pass degrees where radians were expected, and the code compiles without complaint. The tests might even pass, until your telescope tracks empty sky because (45.0_f64).cos() is 0.53 (treating 45 as radians) while (0.785_f64).cos() is 0.71 (the actual cosine of 45 degrees).
This same class of bug shows up everywhere. In 1983, Air Canada Flight 143 ran out of fuel mid-flight after both the maintenance crew and the flight crew used a pounds-per-liter conversion factor on an aircraft calibrated in kilograms. Game engines mix pixel coordinates with world coordinates. Finance code confuses basis points with percentages (a 50 basis point rate hike is 0.5%, not 50%). Embedded systems treat raw ADC register values as calibrated voltages.
Rust can prevent a lot of these at compile time, but only if you stop hiding meaning inside raw primitives. That's what the newtype pattern is for.
What is the Rust newtype pattern?
A newtype (sometimes called a wrapper type or the newtype idiom) is a single-field tuple struct that wraps a primitive:
struct Degrees(f64);
struct Radians(f64);Two types, each holding an f64, but the compiler treats them as entirely separate. You can't pass a Degrees where a Radians is expected.
The Rust Book uses this exact example (Millimeters vs Meters) as the motivating use case. Rust Design Patterns calls it out as a first-class idiom.
Newtype vs type alias
People often reach for type first. A type alias creates a synonym, not a new type:
type Degrees = f64;
type Radians = f64;
fn compute_position(angle: Radians) -> f64 {
angle.cos()
}
let heading: Degrees = 45.0;
compute_position(heading); // Compiles! Degrees and Radians are both just f64The compiler treats Degrees and Radians as identical to f64. You get readability but zero safety.
A newtype struct creates an actual distinct type:
struct Degrees(f64);
struct Radians(f64);
fn compute_position(angle: Radians) -> f64 {
angle.0.cos()
}
let heading = Degrees(45.0);
compute_position(heading); // Compile ERRORThe compiler rejects this with:
error[E0308]: mismatched types
--> src/main.rs:10:19
|
10 | compute_position(heading);
| ---------------- ^^^^^^^ expected `Radians`, found `Degrees`
| |
| arguments to this function are incorrectThat's the whole point: the compiler refuses to guess.
Why newtypes beat type aliases
- Wrong-unit bugs become compile errors. You cannot pass degrees where radians are expected without an explicit conversion.
- Self-documenting signatures.
fn compute_position(angle: Radians)tells you exactly what unit is expected. No doc comments needed. - No runtime cost. The wrapper is a distinct type at compile time, but the compiler typically optimizes it to the same representation as the inner value. If you need a hard layout guarantee (for FFI, for example), add
#[repr(transparent)]; otherwise the optimization is reliable in practice but not contractually guaranteed.
Prevent degrees vs radians bugs in Rust
Here's a practical implementation. One important detail upfront: Rust's trig methods (f64::sin(), f64::cos(), etc.) take radians, not degrees. This is where the bug usually sneaks in.
From/Into conversions
Start with the types and conversions. Note the private inner field: this lets you add invariants or normalization later without breaking callers:
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Degrees(f64);
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Radians(f64);
impl Degrees {
pub fn new(value: f64) -> Self {
Degrees(value)
}
pub fn get(self) -> f64 {
self.0
}
/// Normalize to [0, 360)
pub fn normalized(self) -> Self {
Degrees(self.0.rem_euclid(360.0))
}
/// Convenience: cosine, converting to radians internally
pub fn cos(self) -> f64 {
self.0.to_radians().cos()
}
/// Convenience: sine, converting to radians internally
pub fn sin(self) -> f64 {
self.0.to_radians().sin()
}
}
impl Radians {
pub fn new(value: f64) -> Self {
Radians(value)
}
pub fn get(self) -> f64 {
self.0
}
}
impl From<Degrees> for Radians {
fn from(deg: Degrees) -> Self {
Radians(deg.0.to_radians()) // uses f64::to_radians()
}
}
impl From<Radians> for Degrees {
fn from(rad: Radians) -> Self {
Degrees(rad.0.to_degrees()) // uses f64::to_degrees()
}
}Using f64::to_radians() and f64::to_degrees() in the conversions avoids hand-rolling the * PI / 180.0 math (one fewer place to introduce a typo).
Display vs Debug
Debug (already derived) prints Degrees(45.0), useful in test failures. Add Display for user-facing output:
impl fmt::Display for Degrees {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:.2}°", self.0) // "45.00°"
}
}
impl fmt::Display for Radians {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:.4} rad", self.0) // "0.7854 rad"
}
}How to implement ergonomic newtypes (Add/Sub, AddAssign, Neg)
Operator overloading lets you do arithmetic without unwrapping constantly:
use std::ops::{Add, Sub, Neg, AddAssign, SubAssign, Mul};
impl Add for Degrees {
type Output = Self;
fn add(self, rhs: Self) -> Self { Degrees(self.0 + rhs.0) }
}
impl Sub for Degrees {
type Output = Self;
fn sub(self, rhs: Self) -> Self { Degrees(self.0 - rhs.0) }
}
impl Neg for Degrees {
type Output = Self;
fn neg(self) -> Self { Degrees(-self.0) }
}
impl AddAssign for Degrees {
fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; }
}
impl SubAssign for Degrees {
fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; }
}
impl Mul<f64> for Degrees {
type Output = Self;
fn mul(self, rhs: f64) -> Self { Degrees(self.0 * rhs) }
}You'd implement the same traits for Radians. One gotcha: Degrees::new(2.0) * 3.0 works, but 3.0 * Degrees::new(2.0) doesn't without an extra impl. You can add impl Mul<Degrees> for f64 (your local type Degrees satisfies the orphan rules), but it's more boilerplate for marginal convenience. Most codebases just put the newtype on the left side.
This is where the boilerplate complaint comes from, and it's legitimate.
Now arithmetic works naturally:
let mut ra = Degrees::new(186.65); // Right Ascension of Vega
let offset = Degrees::new(15.04); // Earth's rotation per sidereal hour
ra += offset; // AddAssign just works
println!("New RA: {ra}"); // "New RA: 201.69°"A production-ready template
If you want a baseline you can paste and tweak, here's one:
use std::fmt;
use std::ops::{Add, Sub, Neg, AddAssign, SubAssign, Mul};
/// Angle measured in degrees.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Degrees(f64); // private field: callers use new()
impl Degrees {
pub fn new(value: f64) -> Self { Degrees(value) }
pub fn get(self) -> f64 { self.0 }
pub fn normalized(self) -> Self { Degrees(self.0.rem_euclid(360.0)) }
pub fn to_radians(self) -> Radians { Radians(self.0.to_radians()) }
pub fn cos(self) -> f64 { self.0.to_radians().cos() }
pub fn sin(self) -> f64 { self.0.to_radians().sin() }
}
impl From<Radians> for Degrees {
fn from(r: Radians) -> Self { Degrees(r.0.to_degrees()) }
}
impl fmt::Display for Degrees {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:.2}°", self.0)
}
}
impl Add for Degrees {
type Output = Self;
fn add(self, rhs: Self) -> Self { Degrees(self.0 + rhs.0) }
}
impl Sub for Degrees {
type Output = Self;
fn sub(self, rhs: Self) -> Self { Degrees(self.0 - rhs.0) }
}
impl Neg for Degrees {
type Output = Self;
fn neg(self) -> Self { Degrees(-self.0) }
}
impl AddAssign for Degrees {
fn add_assign(&mut self, rhs: Self) { self.0 += rhs.0; }
}
impl SubAssign for Degrees {
fn sub_assign(&mut self, rhs: Self) { self.0 -= rhs.0; }
}
impl Mul<f64> for Degrees {
type Output = Self;
fn mul(self, rhs: f64) -> Self { Degrees(self.0 * rhs) }
}
// Optional: #[derive(serde::Serialize, serde::Deserialize)]
// with #[serde(transparent)] to serialize as a plain f64Repeat the pattern for Radians, Hours, JulianDay, or whatever your domain needs.
Compile-fail tests: trybuild and rustdoc
You can assert that invalid code fails to compile using trybuild, a test harness built for exactly this purpose.
Create a test file that should not compile:
// tests/compile_fail/degrees_radians_mismatch.rs
use your_crate::{Degrees, Radians};
fn takes_radians(_: Radians) {}
fn main() {
let d = Degrees::new(45.0);
takes_radians(d); // Should fail: expected Radians, found Degrees
}Then in your test suite:
#[test]
fn newtype_safety() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/compile_fail/degrees_radians_mismatch.rs");
}trybuild compiles the file, expects it to fail, and checks the error message against a saved .stderr snapshot. If someone accidentally makes Degrees convertible to Radians via Deref or a blanket Into, this test catches it.
For a lighter-weight option, Rust's built-in doctests support compile_fail without any external crate:
/// Degrees and Radians are not interchangeable:
///
/// ```compile_fail
/// # use your_crate::{Degrees, Radians};
/// fn takes_radians(_: Radians) {}
/// let d = Degrees::new(45.0);
/// takes_radians(d); // won't compile
/// ```trybuild gives you .stderr snapshot matching for exact error messages. Rustdoc compile_fail is simpler: it just asserts the code doesn't compile, without checking the specific error. Use whichever fits your needs.
Case study: preventing a coordinate mix-up
I work on an astronomy API that computes Greenwich Mean Sidereal Time (GMST), the angle between the vernal equinox and the Greenwich meridian. GMST is used to convert between celestial and terrestrial coordinate systems.
Sidereal time is commonly expressed in hours (0-24), but calculations often need degrees (0-360) or radians. The standard formula from Meeus' Astronomical Algorithms produces GMST in degrees. Trig functions need radians. Hour angle display needs hours. With raw f64s, it's easy to mix units and not notice:
// Before newtypes: every f64 is a landmine
fn hour_angle(gmst: f64, longitude: f64, ra: f64) -> f64 {
// gmst in degrees... right? Or hours? Or radians?
// longitude in degrees... east-positive or west-positive?
// ra in degrees... or hours (the traditional unit)?
let lst = gmst + longitude;
let ha = lst - ra;
ha.rem_euclid(360.0) // assumes everything is degrees
}Three f64 parameters, three chances to pass the wrong unit. I've spent an evening debugging a pointing error that turned out to be a GMST value in radians (about 4.9) being treated as degrees: an offset of 4.9° instead of the intended 281°.
GMST/LST in Rust: hours vs degrees
With newtypes, the types carry the unit information:
/// Sidereal time in hours (0–24 range)
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Hours(f64);
impl Hours {
pub fn new(value: f64) -> Self { Hours(value) }
pub fn get(self) -> f64 { self.0 }
}
impl From<Degrees> for Hours {
fn from(d: Degrees) -> Self { Hours(d.get() / 15.0) } // 360° = 24h
}
impl From<Hours> for Degrees {
fn from(h: Hours) -> Self { Degrees::new(h.0 * 15.0) }
}
/// Julian Day in UTC timescale
#[derive(Debug, Clone, Copy)]
pub struct JulianDay(f64);
impl JulianDay {
pub fn new(value: f64) -> Self { JulianDay(value) }
pub fn get(self) -> f64 { self.0 }
}
/// GMST from Julian Day — returns Degrees per Meeus' formula
pub fn gmst(jd: JulianDay) -> Degrees {
let t = (jd.get() - 2451545.0) / 36525.0;
let gmst = 280.46061837
+ 360.98564736629 * (jd.get() - 2451545.0)
+ 0.000387933 * t * t
- (t * t * t) / 38710000.0;
Degrees::new(gmst.rem_euclid(360.0))
}
/// Local Sidereal Time
pub fn lst(gmst: Degrees, longitude: Degrees) -> Degrees {
(gmst + longitude).normalized()
}
/// Hour angle — all inputs must be Degrees, convert at the boundary
pub fn hour_angle(lst: Degrees, ra: Degrees) -> Degrees {
(lst - ra).normalized()
}Now the compiler enforces the contract. If someone computes GMST in radians (from a different library) and tries to pass it to hour_angle, the code won't compile.
Two gotchas this example glosses over: longitude sign convention varies between sources (east-positive vs west-positive). The Degrees::new(-73.9) above uses east-positive, but a different library might hand you west-positive. If this matters in your domain, consider separate EastLongitude / WestLongitude types, or at minimum document the convention on the struct. Time scale also matters: JulianDay here assumes UTC, but real GMST calculations use UT1. This example is about type safety, not astrometric precision. If you need the latter, use a proper astrometry library.
The JulianDay and Hours newtypes help too. Julian Day numbers look like ordinary floats (2460350.5), and you don't want to accidentally pass one into a function expecting degrees. Hours and degrees have different ranges (0-24 vs 0-360). Making them the same type makes mix-ups easy.
Conversions happen at boundaries, explicitly:
let jd = JulianDay::new(2460350.5);
let sidereal = gmst(jd); // Degrees
let local = lst(sidereal, Degrees::new(-73.9)); // NYC longitude
let ha = hour_angle(local, Degrees::new(186.6));
// Display in hours for astronomers:
let ha_hours: Hours = ha.into();
println!("Hour angle: {:.2}h", ha_hours.get());Should you implement Deref for a newtype? (Usually no)
For unit newtypes (angles, IDs, distances), don't implement Deref<Target = f64>. It reintroduces implicit mixing: if Degrees auto-derefs to f64, you can silently pass it anywhere an f64 is accepted, and your type safety evaporates. The Rust Design Patterns book lists this as a named anti-pattern: "Deref is designed for the implementation of custom pointer types... not conversion between arbitrary types."
The exception: wrappers intentionally acting like the inner type (smart pointers, transparent reference wrappers, or thin wrappers where implicit access is the design goal) are fine. The anti-pattern is using Deref as a conversion mechanism between semantically different types. Effective Rust draws the same line.
For unit newtypes, use a .get() method instead. It's explicit, greppable, and doesn't undermine the type system.
Newtype boilerplate and how to reduce it
The boilerplate is the main downside. Every newtype needs From conversions, operator implementations, and trait derives. For two types it's manageable. For ten types across an astronomy library (right ascension, declination, hour angle, Julian day, epoch, magnitude), the repetition gets old.
derive_more auto-generates the common trait implementations:
use derive_more::{Add, Sub, Mul, Display, From, Into};
#[derive(Debug, Clone, Copy, PartialEq, Add, Sub, Display, From, Into)]
#[display("{_0:.2}°")]
pub struct Degrees(f64);nutype goes further by adding validation constraints:
use nutype::nutype;
#[nutype(validate(finite, greater_or_equal = -90.0, less_or_equal = 90.0))]
pub struct Latitude(f64);
// Latitude::try_new(91.0) returns Err -- value out of range
// Latitude::try_new(45.0) returns Ok(Latitude)This is useful when your newtype needs to enforce a range, not just a unit. It also handles serde deserialization: invalid values are rejected before they reach your code.
Ergonomic friction. Conversions are explicit. That's the point, but it can feel verbose. Adding convenience methods like Degrees::cos() (shown above) smooths over the most common cases.
Serde support. Serde already treats tuple newtypes like their inner value: struct Degrees(f64) serializes as 45.0, not {"Degrees": 45.0}, without any extra attributes. Add #[serde(transparent)] explicitly if you switch to a named-field struct or want to be defensive about future changes:
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(transparent)]
pub struct Degrees(f64);Other common newtype use cases
The pattern applies anywhere you have "same type, different meaning":
Latitude(f64)/Longitude(f64)-- prevents swapping lat/lon argumentsMeters(f64)/AstronomicalUnits(f64)-- distance scalesArcseconds(f64)-- angular resolutionUserId(i64)/OrgId(i64)-- database IDs that shouldn't be interchangeable
Why PartialOrd but not Ord? You'll notice the examples derive PartialOrd but not Ord. That's because f64 can be NaN, and NaN has no ordering. If you try to put Degrees in a BTreeMap or call .sort() on a Vec<Degrees>, it won't compile. For sorting, use f64::total_cmp() (stable since Rust 1.62): .sort_by(|a, b| a.get().total_cmp(&b.get())). It defines a total ordering where NaN sorts to the end. You can also implement Ord on your newtype using total_cmp to avoid the external ordered-float dependency.
uom vs newtypes: when you need dimensional analysis
For most codebases, hand-rolled newtypes for a few domain types is the right call. But if you need dimensional analysis (multiplying velocity by time to get distance, or checking that force equals mass times acceleration), newtypes alone won't scale. You'd need to define the output type of every cross-unit operation.
The uom (Units of Measurement) crate handles this. Its promise: type-safe zero-cost dimensional analysis.
use uom::si::f64::*;
use uom::si::length::meter;
use uom::si::time::second;
let distance = Length::new::<meter>(100.0);
let time = Time::new::<second>(9.58);
let speed = distance / time; // Velocity, type-checked
// This won't compile:
// let nonsense = distance + time; // can't add meters to secondsuom covers SI units extensively. The tradeoff: compile errors are harder to read, type signatures get long, and the learning curve is steeper than plain newtypes.
Here's how I'd choose:
| You need | Use |
|---|---|
| 2-5 semantic types (angles, IDs, timestamps) | Hand-rolled newtypes |
| Same but with heavy boilerplate | derive_more |
| Newtypes with range validation | nutype |
| Cross-unit arithmetic (m/s, N·m, kg·m/s²) | uom |
If your units don't multiply or divide into each other, newtypes are simpler. If they do, uom saves you from defining impl Div<Time> for Length { type Output = Velocity; } for every combination.
Rules of thumb
- Use a newtype when the same primitive carries different meaning: IDs, units, validated domains.
- Implement
From/Intoonly for lossless, obvious conversions. Make lossy conversions explicit methods. - Prefer named methods (
to_radians(),to_hours()) at API boundaries for readability. - Normalize only if it's a true domain invariant (angles wrapping at 360° are; arbitrary floats aren't).
- Don't implement
Derefon unit newtypes. Use.get(). - Start with 1-2 newtypes where bugs have actually bitten you. Don't boil the ocean.
If you're building a web API around these types, newtypes compose well with Axum extractors. Deserialize JSON directly into your newtype and reject bad input before it reaches your handler. I'll cover that pattern in a separate post.
I keep my Rust fundamentals sharp with spaced repetition. SyntaxCache has exercises covering ownership, traits, pattern matching, and more, built for developers who want to practice Rust syntax without forgetting what they learned last month.
FAQ
Does a Rust newtype have runtime overhead?
No. The compiler optimizes the wrapper away in release builds. Add #[repr(transparent)] if you need an ABI guarantee for FFI, but for pure Rust code, don't worry about it.
Newtype vs type alias -- what's the difference?
A type alias (type Degrees = f64) gives you a new name but zero safety; the compiler still treats it as f64. A newtype (struct Degrees(f64)) is a genuinely distinct type that the compiler refuses to mix with anything else.
Should I implement Deref for my newtype?
Not for unit newtypes (angles, IDs, distances); it lets the inner type leak out implicitly, defeating the safety guarantee. Use .get() instead. The exception is wrappers where transparent access is the point (smart pointers, thin reference wrappers).
When should I use uom instead of hand-rolled newtypes?
When your units multiply and divide into each other. If velocity * time should give you distance, uom encodes that in the type system. If you just need "degrees and radians shouldn't be interchangeable," plain newtypes are simpler.
Can newtypes help with Rust's orphan rule?
Yes. If you can't implement a foreign trait on a foreign type (e.g., impl Display for Vec<T>), wrapping it in a newtype (struct MyVec(Vec<T>)) gives you a local type to attach the impl to. That's the other classic newtype use case. Different motivation than unit safety, but the same pattern.
References
- The Rust Programming Language: Advanced Types -- official newtype coverage
- Effective Rust, Item 6: Embrace the newtype pattern
- Rust Design Patterns: Newtype
- Rust Design Patterns: Deref anti-pattern
- The Rust Reference:
repr(transparent) - Rust
f64docs:cos()takes radians - Mars Climate Orbiter Mishap Investigation Board Phase I Report (PDF)
- USNO: Computing Sidereal Time
- uom (Units of Measurement) crate
- nutype crate -- newtypes with validation
- trybuild -- compile-fail test harness