875-newtype-pattern — Newtype Pattern
Tutorial
The Problem
Type aliases create convenient shorthand but provide no safety guarantees: type UserId = u64 means UserId and u64 are interchangeable. The newtype pattern wraps a type in a single-field struct, creating a distinct type with identical runtime representation. Haskell's newtype keyword formalizes this with zero-cost guarantee. In Rust, tuple structs like struct UserId(u64) achieve the same: you cannot accidentally pass a UserId where an OrderId is expected, even though both wrap u64. This pattern is standard in domain-driven design for preventing primitive obsession and encoding domain invariants.
🎯 Learning Outcomes
ResultDisplay and Debug implementations to give newtypes meaningful outputCode Example
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);
impl UserId {
fn new(id: u64) -> Option<Self> {
if id > 0 { Some(UserId(id)) } else { None }
}
fn value(self) -> u64 { self.0 }
}Key Differences
struct Email(String) is three words; OCaml needs a full module definition.#[derive(Debug, Clone, Copy, PartialEq)]; OCaml requires explicit functor application or manual implementation..0 field accessor breaks abstraction (unless private); OCaml abstract types are more strictly opaque without the module's value function.OCaml Approach
OCaml achieves the same effect through abstract module types. module UserId: sig type t; val create: int -> t; val value: t -> int end = struct type t = int; ... end makes UserId.t opaque outside the module. The inner int is inaccessible directly, and UserId.t and OrderId.t are incompatible types. OCaml's with type constraint can selectively expose the type alias when needed. The pattern is more verbose than Rust's tuple struct but achieves the same compile-time safety.
Full Source
#![allow(clippy::all)]
// Example 081: Newtype Pattern
// Rust tuple structs for type safety
use std::fmt;
// === Approach 1: Simple newtype wrappers ===
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct OrderId(u64);
impl UserId {
fn new(id: u64) -> Option<Self> {
if id > 0 {
Some(UserId(id))
} else {
None
}
}
fn value(self) -> u64 {
self.0
}
}
impl OrderId {
fn new(id: u64) -> Option<Self> {
if id > 0 {
Some(OrderId(id))
} else {
None
}
}
fn value(self) -> u64 {
self.0
}
}
impl fmt::Display for UserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "User#{}", self.0)
}
}
impl fmt::Display for OrderId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Order#{}", self.0)
}
}
// Can't accidentally pass UserId where OrderId expected!
fn process_order(order: OrderId, user: UserId) -> String {
format!("{} placed {}", user, order)
}
// === Approach 2: Newtype with validation ===
#[derive(Debug, Clone, PartialEq)]
struct Email(String);
impl Email {
fn new(s: &str) -> Option<Self> {
if s.contains('@') {
Some(Email(s.to_string()))
} else {
None
}
}
fn as_str(&self) -> &str {
&self.0
}
}
// === Approach 3: Newtype for unit safety ===
#[derive(Debug, Clone, Copy, PartialEq)]
struct Celsius(f64);
#[derive(Debug, Clone, Copy, PartialEq)]
struct Fahrenheit(f64);
impl Celsius {
fn new(v: f64) -> Self {
Celsius(v)
}
fn to_fahrenheit(self) -> Fahrenheit {
Fahrenheit(self.0 * 9.0 / 5.0 + 32.0)
}
fn value(self) -> f64 {
self.0
}
}
impl Fahrenheit {
fn new(v: f64) -> Self {
Fahrenheit(v)
}
fn to_celsius(self) -> Celsius {
Celsius((self.0 - 32.0) * 5.0 / 9.0)
}
fn value(self) -> f64 {
self.0
}
}
// Implement Deref for transparent access when appropriate
use std::ops::Deref;
#[derive(Debug, Clone, PartialEq)]
struct NonEmptyString(String);
impl NonEmptyString {
fn new(s: &str) -> Option<Self> {
if s.is_empty() {
None
} else {
Some(NonEmptyString(s.to_string()))
}
}
}
impl Deref for NonEmptyString {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_order_type_safety() {
let u = UserId::new(1).unwrap();
let o = OrderId::new(2).unwrap();
assert_eq!(u.value(), 1);
assert_eq!(o.value(), 2);
// UserId and OrderId are different types
}
#[test]
fn test_invalid_ids() {
assert!(UserId::new(0).is_none());
assert!(OrderId::new(0).is_none());
}
#[test]
fn test_email_validation() {
assert!(Email::new("a@b.com").is_some());
assert!(Email::new("invalid").is_none());
}
#[test]
fn test_temperature_conversion() {
let c = Celsius::new(0.0);
assert!((c.to_fahrenheit().value() - 32.0).abs() < 1e-10);
let f = Fahrenheit::new(212.0);
assert!((f.to_celsius().value() - 100.0).abs() < 1e-10);
}
#[test]
fn test_roundtrip() {
let c = Celsius::new(37.0);
let back = c.to_fahrenheit().to_celsius();
assert!((back.value() - 37.0).abs() < 1e-10);
}
#[test]
fn test_non_empty_string() {
assert!(NonEmptyString::new("hello").is_some());
assert!(NonEmptyString::new("").is_none());
let s = NonEmptyString::new("test").unwrap();
assert_eq!(s.len(), 4); // Deref to &str
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_order_type_safety() {
let u = UserId::new(1).unwrap();
let o = OrderId::new(2).unwrap();
assert_eq!(u.value(), 1);
assert_eq!(o.value(), 2);
// UserId and OrderId are different types
}
#[test]
fn test_invalid_ids() {
assert!(UserId::new(0).is_none());
assert!(OrderId::new(0).is_none());
}
#[test]
fn test_email_validation() {
assert!(Email::new("a@b.com").is_some());
assert!(Email::new("invalid").is_none());
}
#[test]
fn test_temperature_conversion() {
let c = Celsius::new(0.0);
assert!((c.to_fahrenheit().value() - 32.0).abs() < 1e-10);
let f = Fahrenheit::new(212.0);
assert!((f.to_celsius().value() - 100.0).abs() < 1e-10);
}
#[test]
fn test_roundtrip() {
let c = Celsius::new(37.0);
let back = c.to_fahrenheit().to_celsius();
assert!((back.value() - 37.0).abs() < 1e-10);
}
#[test]
fn test_non_empty_string() {
assert!(NonEmptyString::new("hello").is_some());
assert!(NonEmptyString::new("").is_none());
let s = NonEmptyString::new("test").unwrap();
assert_eq!(s.len(), 4); // Deref to &str
}
}
Deep Comparison
Comparison: Newtype Pattern
Abstract Type vs Tuple Struct
OCaml:
module UserId : sig
type t
val create : int -> t
val value : t -> int
end = struct
type t = int
let create x = if x > 0 then x else failwith "invalid"
let value x = x
end
Rust:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);
impl UserId {
fn new(id: u64) -> Option<Self> {
if id > 0 { Some(UserId(id)) } else { None }
}
fn value(self) -> u64 { self.0 }
}
Temperature Units
OCaml:
type celsius = { celsius_value : float }
type fahrenheit = { fahrenheit_value : float }
let to_fahrenheit c =
{ fahrenheit_value = c.celsius_value *. 9.0 /. 5.0 +. 32.0 }
Rust:
struct Celsius(f64);
struct Fahrenheit(f64);
impl Celsius {
fn to_fahrenheit(self) -> Fahrenheit {
Fahrenheit(self.0 * 9.0 / 5.0 + 32.0)
}
}
Validated Data
OCaml:
module Email : sig
type t = private string
val create : string -> t option
end = struct
type t = string
let create s = if String.contains s '@' then Some s else None
end
Rust:
struct Email(String);
impl Email {
fn new(s: &str) -> Option<Self> {
if s.contains('@') { Some(Email(s.to_string())) } else { None }
}
}
Exercises
struct Percentage(f64) with a smart constructor that validates the value is between 0.0 and 100.0.Quantity<Unit> (a newtype-over-f64) that only allows same-unit addition but returns a plain f64 for division.struct NonNegative(i64) and derive Add, Sub (returning Result on underflow), and Mul for it.