081 — Newtype Pattern
Tutorial
The Problem
Use single-field tuple structs (struct Meters(f64)) to give distinct types to values that share the same underlying representation. Prevent units-of-measure confusion (Meters vs Seconds), enforce distinct ID types (UserId vs OrderId), and add type-safe conversions between Celsius and Fahrenheit — all with zero runtime overhead.
🎯 Learning Outcomes
From<Celsius> for Fahrenheit for ergonomic .into() conversionstype meters = Meters of float)Code Example
//! 081: Newtype Pattern
//!
//! The newtype pattern wraps a primitive (or any existing type) in a
//! single-field tuple struct so the compiler treats semantically distinct
//! values as distinct types. This is zero-cost at runtime — the wrapper
//! compiles away — but catches whole classes of bugs at compile time
//! (mixing meters with seconds, user IDs with order IDs, etc.).
// ---------------------------------------------------------------------------
// Approach 1: Simple newtypes — unit-carrying numeric types
// ---------------------------------------------------------------------------
/// A distance expressed in meters.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Meters(pub f64);
/// A duration expressed in seconds.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Seconds(pub f64);
/// A speed expressed in meters per second.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MetersPerSecond(pub f64);
/// Computes speed as `distance / time`.
///
/// Returns `None` when `time` is zero (division by zero is undefined), and
/// `Some(MetersPerSecond(...))` otherwise. The return type guarantees that
/// the result can never be silently mixed up with a raw `f64` at the call
/// site.
pub fn speed(distance: Meters, time: Seconds) -> Option<MetersPerSecond> {
if time.0 == 0.0 {
None
} else {
Some(MetersPerSecond(distance.0 / time.0))
}
}
// ---------------------------------------------------------------------------
// Approach 2: Distinct ID types — prevent mixing identifiers
// ---------------------------------------------------------------------------
/// Opaque identifier for a user.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(pub u64);
/// Opaque identifier for an order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OrderId(pub u64);
/// Formats a `UserId` as a human-readable lookup string.
///
/// The compiler rejects `find_user(OrderId(1))` — the two newtypes are
/// structurally identical but nominally distinct.
pub fn find_user(id: UserId) -> String {
format!("User #{}", id.0)
}
/// Formats an `OrderId` as a human-readable lookup string.
pub fn find_order(id: OrderId) -> String {
format!("Order #{}", id.0)
}
// ---------------------------------------------------------------------------
// Approach 3: Newtypes with conversions between related units
// ---------------------------------------------------------------------------
/// A temperature in degrees Celsius.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Celsius(pub f64);
/// A temperature in degrees Fahrenheit.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Fahrenheit(pub f64);
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self {
Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
}
}
impl From<Fahrenheit> for Celsius {
fn from(f: Fahrenheit) -> Self {
Celsius((f.0 - 32.0) * 5.0 / 9.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
#[test]
fn speed_divides_distance_by_time() {
let s = speed(Meters(100.0), Seconds(10.0)).expect("non-zero time");
assert!((s.0 - 10.0).abs() < EPS);
}
#[test]
fn speed_returns_none_on_zero_time() {
assert!(speed(Meters(100.0), Seconds(0.0)).is_none());
}
#[test]
fn speed_handles_fractional_values() {
let s = speed(Meters(5.0), Seconds(2.0)).unwrap();
assert!((s.0 - 2.5).abs() < EPS);
}
#[test]
fn find_user_formats_id() {
assert_eq!(find_user(UserId(42)), "User #42");
}
#[test]
fn find_order_formats_id() {
assert_eq!(find_order(OrderId(7)), "Order #7");
}
#[test]
fn distinct_id_types_have_equal_values_but_different_types() {
// Same underlying u64, but these are different types — the compiler
// would reject `UserId(1) == OrderId(1)`. We only compare within a
// type here.
assert_eq!(UserId(1), UserId(1));
assert_ne!(UserId(1), UserId(2));
assert_eq!(OrderId(1), OrderId(1));
}
#[test]
fn celsius_to_fahrenheit_boiling_point() {
let f: Fahrenheit = Celsius(100.0).into();
assert!((f.0 - 212.0).abs() < EPS);
}
#[test]
fn fahrenheit_to_celsius_freezing_point() {
let c: Celsius = Fahrenheit(32.0).into();
assert!(c.0.abs() < EPS);
}
#[test]
fn temperature_round_trip() {
let original = Celsius(25.0);
let f: Fahrenheit = original.into();
let back: Celsius = f.into();
assert!((back.0 - original.0).abs() < EPS);
}
#[test]
fn absolute_zero_conversion() {
let f: Fahrenheit = Celsius(-40.0).into();
assert!((f.0 - (-40.0)).abs() < EPS);
}
}Key Differences
| Aspect | Rust | OCaml |
|---|---|---|
| Syntax | struct Meters(f64) | type meters = Meters of float |
| Runtime cost | Zero (transparent layout) | Possible boxing for float in records |
| Unwrapping | .0 field access | Pattern match (Meters m) |
| Conversion | impl From<A> for B | Named function to_celsius |
| Ergonomics | .into() / From::from | Explicit call |
| Compile-time safety | Full (different types) | Full (different constructors) |
Both languages provide the same semantic guarantee: you cannot pass a Meters value where a Seconds is expected. Rust adds the From/Into trait infrastructure for ergonomic conversions; OCaml relies on explicit function naming.
OCaml Approach
OCaml achieves the same safety with single-constructor variants: type meters = Meters of float. Pattern matching in function arguments (let speed (Meters d) (Seconds t)) destructures automatically. OCaml variants are slightly heavier than Rust newtypes in that they may not be unboxed in all contexts, but the safety guarantee is equivalent. The to_fahrenheit and to_celsius functions are explicit conversions — OCaml has no From/Into trait system, so conversions require naming.
Full Source
//! 081: Newtype Pattern
//!
//! The newtype pattern wraps a primitive (or any existing type) in a
//! single-field tuple struct so the compiler treats semantically distinct
//! values as distinct types. This is zero-cost at runtime — the wrapper
//! compiles away — but catches whole classes of bugs at compile time
//! (mixing meters with seconds, user IDs with order IDs, etc.).
// ---------------------------------------------------------------------------
// Approach 1: Simple newtypes — unit-carrying numeric types
// ---------------------------------------------------------------------------
/// A distance expressed in meters.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Meters(pub f64);
/// A duration expressed in seconds.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Seconds(pub f64);
/// A speed expressed in meters per second.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MetersPerSecond(pub f64);
/// Computes speed as `distance / time`.
///
/// Returns `None` when `time` is zero (division by zero is undefined), and
/// `Some(MetersPerSecond(...))` otherwise. The return type guarantees that
/// the result can never be silently mixed up with a raw `f64` at the call
/// site.
pub fn speed(distance: Meters, time: Seconds) -> Option<MetersPerSecond> {
if time.0 == 0.0 {
None
} else {
Some(MetersPerSecond(distance.0 / time.0))
}
}
// ---------------------------------------------------------------------------
// Approach 2: Distinct ID types — prevent mixing identifiers
// ---------------------------------------------------------------------------
/// Opaque identifier for a user.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(pub u64);
/// Opaque identifier for an order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct OrderId(pub u64);
/// Formats a `UserId` as a human-readable lookup string.
///
/// The compiler rejects `find_user(OrderId(1))` — the two newtypes are
/// structurally identical but nominally distinct.
pub fn find_user(id: UserId) -> String {
format!("User #{}", id.0)
}
/// Formats an `OrderId` as a human-readable lookup string.
pub fn find_order(id: OrderId) -> String {
format!("Order #{}", id.0)
}
// ---------------------------------------------------------------------------
// Approach 3: Newtypes with conversions between related units
// ---------------------------------------------------------------------------
/// A temperature in degrees Celsius.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Celsius(pub f64);
/// A temperature in degrees Fahrenheit.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Fahrenheit(pub f64);
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self {
Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
}
}
impl From<Fahrenheit> for Celsius {
fn from(f: Fahrenheit) -> Self {
Celsius((f.0 - 32.0) * 5.0 / 9.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
#[test]
fn speed_divides_distance_by_time() {
let s = speed(Meters(100.0), Seconds(10.0)).expect("non-zero time");
assert!((s.0 - 10.0).abs() < EPS);
}
#[test]
fn speed_returns_none_on_zero_time() {
assert!(speed(Meters(100.0), Seconds(0.0)).is_none());
}
#[test]
fn speed_handles_fractional_values() {
let s = speed(Meters(5.0), Seconds(2.0)).unwrap();
assert!((s.0 - 2.5).abs() < EPS);
}
#[test]
fn find_user_formats_id() {
assert_eq!(find_user(UserId(42)), "User #42");
}
#[test]
fn find_order_formats_id() {
assert_eq!(find_order(OrderId(7)), "Order #7");
}
#[test]
fn distinct_id_types_have_equal_values_but_different_types() {
// Same underlying u64, but these are different types — the compiler
// would reject `UserId(1) == OrderId(1)`. We only compare within a
// type here.
assert_eq!(UserId(1), UserId(1));
assert_ne!(UserId(1), UserId(2));
assert_eq!(OrderId(1), OrderId(1));
}
#[test]
fn celsius_to_fahrenheit_boiling_point() {
let f: Fahrenheit = Celsius(100.0).into();
assert!((f.0 - 212.0).abs() < EPS);
}
#[test]
fn fahrenheit_to_celsius_freezing_point() {
let c: Celsius = Fahrenheit(32.0).into();
assert!(c.0.abs() < EPS);
}
#[test]
fn temperature_round_trip() {
let original = Celsius(25.0);
let f: Fahrenheit = original.into();
let back: Celsius = f.into();
assert!((back.0 - original.0).abs() < EPS);
}
#[test]
fn absolute_zero_conversion() {
let f: Fahrenheit = Celsius(-40.0).into();
assert!((f.0 - (-40.0)).abs() < EPS);
}
}#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
#[test]
fn speed_divides_distance_by_time() {
let s = speed(Meters(100.0), Seconds(10.0)).expect("non-zero time");
assert!((s.0 - 10.0).abs() < EPS);
}
#[test]
fn speed_returns_none_on_zero_time() {
assert!(speed(Meters(100.0), Seconds(0.0)).is_none());
}
#[test]
fn speed_handles_fractional_values() {
let s = speed(Meters(5.0), Seconds(2.0)).unwrap();
assert!((s.0 - 2.5).abs() < EPS);
}
#[test]
fn find_user_formats_id() {
assert_eq!(find_user(UserId(42)), "User #42");
}
#[test]
fn find_order_formats_id() {
assert_eq!(find_order(OrderId(7)), "Order #7");
}
#[test]
fn distinct_id_types_have_equal_values_but_different_types() {
// Same underlying u64, but these are different types — the compiler
// would reject `UserId(1) == OrderId(1)`. We only compare within a
// type here.
assert_eq!(UserId(1), UserId(1));
assert_ne!(UserId(1), UserId(2));
assert_eq!(OrderId(1), OrderId(1));
}
#[test]
fn celsius_to_fahrenheit_boiling_point() {
let f: Fahrenheit = Celsius(100.0).into();
assert!((f.0 - 212.0).abs() < EPS);
}
#[test]
fn fahrenheit_to_celsius_freezing_point() {
let c: Celsius = Fahrenheit(32.0).into();
assert!(c.0.abs() < EPS);
}
#[test]
fn temperature_round_trip() {
let original = Celsius(25.0);
let f: Fahrenheit = original.into();
let back: Celsius = f.into();
assert!((back.0 - original.0).abs() < EPS);
}
#[test]
fn absolute_zero_conversion() {
let f: Fahrenheit = Celsius(-40.0).into();
assert!((f.0 - (-40.0)).abs() < EPS);
}
}
Deep Comparison
Core Insight
A newtype wraps a primitive to create a distinct type. Meters(5.0) and Feet(5.0) are different types — the compiler prevents accidental mixing. Zero runtime overhead in both languages.
OCaml Approach
type meters = Meters of float (single-variant)Rust Approach
struct Meters(f64)Comparison Table
| Feature | OCaml | Rust |
|---|---|---|
| Syntax | type t = T of inner | struct T(inner); |
| Access | Pattern match | .0 field access |
| Overhead | Zero | Zero |
| Trait impl | N/A | Can impl traits |
Exercises
Kilometers(f64) newtype and implement From<Kilometers> for Meters and vice versa.NonEmptyString(String) newtype with a constructor fn new(s: String) -> Option<NonEmptyString> that returns None for empty strings.std::ops::Add<Meters> for Meters so that two distances can be summed while still being Meters.std::fmt::Display for Celsius to print values as "100°C" and Fahrenheit as "212°F".validated_email newtype and a smart constructor val make : string -> validated_email option. Compare the pattern with Rust's equivalent approach.