ExamplesBy LevelBy TopicLearning Paths
081 Intermediate

081 — Newtype Pattern

Functional Programming

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

  • • Define newtypes as single-field tuple structs in Rust
  • • Understand that newtypes are zero-cost: no runtime overhead vs the wrapped type
  • • Prevent accidental mixing of same-representation values at compile time
  • • Implement From<Celsius> for Fahrenheit for ergonomic .into() conversions
  • • Map Rust newtypes to OCaml single-constructor variants (type meters = Meters of float)
  • • Recognise when newtypes add safety versus when they add friction
  • 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

    AspectRustOCaml
    Syntaxstruct Meters(f64)type meters = Meters of float
    Runtime costZero (transparent layout)Possible boxing for float in records
    Unwrapping.0 field accessPattern match (Meters m)
    Conversionimpl From<A> for BNamed function to_celsius
    Ergonomics.into() / From::fromExplicit call
    Compile-time safetyFull (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);
        }
    }
    ✓ Tests Rust test suite
    #[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

  • • Private types in module signatures
  • type meters = Meters of float (single-variant)
  • • Module abstraction hides constructor
  • Rust Approach

  • • Tuple struct: struct Meters(f64)
  • • Can implement traits on the newtype
  • • Deref for ergonomic access (use sparingly)
  • Comparison Table

    FeatureOCamlRust
    Syntaxtype t = T of innerstruct T(inner);
    AccessPattern match.0 field access
    OverheadZeroZero
    Trait implN/ACan impl traits

    Exercises

  • Add a Kilometers(f64) newtype and implement From<Kilometers> for Meters and vice versa.
  • Create a NonEmptyString(String) newtype with a constructor fn new(s: String) -> Option<NonEmptyString> that returns None for empty strings.
  • Add std::ops::Add<Meters> for Meters so that two distances can be summed while still being Meters.
  • Implement std::fmt::Display for Celsius to print values as "100°C" and Fahrenheit as "212°F".
  • In OCaml, define a validated_email newtype and a smart constructor val make : string -> validated_email option. Compare the pattern with Rust's equivalent approach.
  • Open Source Repos