ExamplesBy LevelBy TopicLearning Paths
944 Intermediate

944 Validated Type

Functional Programming

Tutorial

The Problem

Use Rust's type system to enforce domain invariants at construction time via smart constructors. Implement NonEmptyString and PositiveInt as opaque newtypes whose inner fields are private. Consumers can only obtain values of these types by calling a validated constructor that returns Result, making invalid states unrepresentable at compile time.

🎯 Learning Outcomes

  • • Implement opaque newtypes with private fields to prevent direct construction of invalid values
  • • Write smart constructors that return Result<T, String> and enforce invariants at the boundary
  • • Expose safe derived operations that preserve invariants without re-checking (e.g., NonEmptyString::concat is always non-empty)
  • • Implement Display for newtype wrappers
  • • Understand the OCaml module system's role in encapsulation vs Rust's module visibility (pub vs no modifier)
  • Code Example

    #![allow(clippy::all)]
    // Smart constructors: enforce invariants at the type level.
    // The type is opaque — you can only create values through validated constructors.
    
    // ── NonEmptyString ──────────────────────────────────────────────────────────
    
    /// A string guaranteed to be non-empty.
    /// The inner field is private; construction goes through `create`.
    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
    pub struct NonEmptyString(String);
    
    impl NonEmptyString {
        pub fn create(s: &str) -> Result<Self, String> {
            if !s.is_empty() {
                Ok(NonEmptyString(s.to_string()))
            } else {
                Err("string must be non-empty".to_string())
            }
        }
    
        pub fn value(&self) -> &str {
            &self.0
        }
    
        pub fn len(&self) -> usize {
            self.0.len()
        }
    
        /// Concatenate two NonEmptyStrings — result is always non-empty.
        pub fn concat(&self, other: &NonEmptyString) -> NonEmptyString {
            NonEmptyString(format!("{}{}", self.0, other.0))
        }
    }
    
    impl std::fmt::Display for NonEmptyString {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "{}", self.0)
        }
    }
    
    // ── PositiveInt ─────────────────────────────────────────────────────────────
    
    /// An integer guaranteed to be strictly positive (> 0).
    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
    pub struct PositiveInt(i64);
    
    impl PositiveInt {
        pub fn create(n: i64) -> Result<Self, String> {
            if n > 0 {
                Ok(PositiveInt(n))
            } else {
                Err(format!("{} is not positive", n))
            }
        }
    
        pub fn value(self) -> i64 {
            self.0
        }
    
        /// Addition of two PositiveInts — result is always positive.
        pub fn add(self, other: Self) -> Self {
            PositiveInt(self.0 + other.0)
        }
    
        /// Multiplication of two PositiveInts — result is always positive.
        pub fn mul(self, other: Self) -> Self {
            PositiveInt(self.0 * other.0)
        }
    }
    
    impl std::fmt::Display for PositiveInt {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "{}", self.0)
        }
    }
    
    // ── Validated accumulating error type ───────────────────────────────────────
    // Goes beyond the OCaml example: a Validated<T> that collects ALL errors.
    
    #[derive(Debug, PartialEq)]
    pub enum Validated<T> {
        Ok(T),
        Err(Vec<String>),
    }
    
    impl<T> Validated<T> {
        pub fn ok(v: T) -> Self {
            Validated::Ok(v)
        }
    
        pub fn err(e: impl Into<String>) -> Self {
            Validated::Err(vec![e.into()])
        }
    
        pub fn is_ok(&self) -> bool {
            matches!(self, Validated::Ok(_))
        }
    
        /// Map over a successful value.
        pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Validated<U> {
            match self {
                Validated::Ok(v) => Validated::Ok(f(v)),
                Validated::Err(es) => Validated::Err(es),
            }
        }
    
        /// Combine two Validated values, collecting errors from both.
        pub fn and<U>(self, other: Validated<U>) -> Validated<(T, U)> {
            match (self, other) {
                (Validated::Ok(a), Validated::Ok(b)) => Validated::Ok((a, b)),
                (Validated::Err(mut e1), Validated::Err(e2)) => {
                    e1.extend(e2);
                    Validated::Err(e1)
                }
                (Validated::Err(e), _) | (_, Validated::Err(e)) => Validated::Err(e),
            }
        }
    
        pub fn errors(&self) -> Option<&[String]> {
            match self {
                Validated::Err(es) => Some(es),
                _ => None,
            }
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_non_empty_string_ok() {
            let s = NonEmptyString::create("hello").unwrap();
            assert_eq!(s.value(), "hello");
            assert_eq!(s.len(), 5);
        }
    
        #[test]
        fn test_non_empty_string_err() {
            assert!(NonEmptyString::create("").is_err());
        }
    
        #[test]
        fn test_positive_int_ok() {
            let n = PositiveInt::create(42).unwrap();
            assert_eq!(n.value(), 42);
        }
    
        #[test]
        fn test_positive_int_err() {
            assert!(PositiveInt::create(0).is_err());
            assert!(PositiveInt::create(-5).is_err());
        }
    
        #[test]
        fn test_positive_int_add() {
            let a = PositiveInt::create(3).unwrap();
            let b = PositiveInt::create(4).unwrap();
            assert_eq!(a.add(b).value(), 7);
        }
    
        #[test]
        fn test_validated_accumulates_errors() {
            let v1: Validated<i32> = Validated::err("error 1");
            let v2: Validated<i32> = Validated::err("error 2");
            let combined = v1.and(v2);
            assert_eq!(combined.errors().unwrap().len(), 2);
        }
    
        #[test]
        fn test_validated_ok() {
            let v1 = Validated::ok(1_i32);
            let v2 = Validated::ok(2_i32);
            assert_eq!(v1.and(v2), Validated::Ok((1, 2)));
        }
    }

    Key Differences

    AspectRustOCaml
    EncapsulationPrivate struct field (not pub)Abstract type in module signature
    Constructor gatingSmart constructor returning ResultSame pattern
    Safe derived opsMethods that skip re-validationSame; compiler cannot verify but convention holds
    Copy semantics#[derive(Copy)] for cheap typesValue semantics throughout (no Copy concept)
    Ordering#[derive(PartialOrd, Ord)] — lexicographic by defaultcompare function or module Ord

    This pattern — make invalid states unrepresentable — is one of the most effective uses of static type systems in both languages. The cost is a single validation at the boundary; the benefit is zero-cost safety everywhere the type is used.

    OCaml Approach

    (* Opaque type via module signature *)
    module NonEmptyString : sig
      type t
      val create : string -> (t, string) result
      val value : t -> string
      val concat : t -> t -> t
    end = struct
      type t = string
      let create s =
        if String.length s > 0 then Ok s
        else Error "string must be non-empty"
      let value s = s
      let concat a b = a ^ b
    end
    
    module PositiveInt : sig
      type t
      val create : int -> (t, string) result
      val value : t -> int
      val add : t -> t -> t
      val mul : t -> t -> t
    end = struct
      type t = int
      let create n =
        if n > 0 then Ok n
        else Error (string_of_int n ^ " is not positive")
      let value n = n
      let add a b = a + b
      let mul a b = a * b
    end
    

    OCaml uses the module system's signature/implementation split to create opaque types. The signature exposes type t without revealing that t = string, so external code cannot construct or pattern-match on values of t directly.

    Full Source

    #![allow(clippy::all)]
    // Smart constructors: enforce invariants at the type level.
    // The type is opaque — you can only create values through validated constructors.
    
    // ── NonEmptyString ──────────────────────────────────────────────────────────
    
    /// A string guaranteed to be non-empty.
    /// The inner field is private; construction goes through `create`.
    #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
    pub struct NonEmptyString(String);
    
    impl NonEmptyString {
        pub fn create(s: &str) -> Result<Self, String> {
            if !s.is_empty() {
                Ok(NonEmptyString(s.to_string()))
            } else {
                Err("string must be non-empty".to_string())
            }
        }
    
        pub fn value(&self) -> &str {
            &self.0
        }
    
        pub fn len(&self) -> usize {
            self.0.len()
        }
    
        /// Concatenate two NonEmptyStrings — result is always non-empty.
        pub fn concat(&self, other: &NonEmptyString) -> NonEmptyString {
            NonEmptyString(format!("{}{}", self.0, other.0))
        }
    }
    
    impl std::fmt::Display for NonEmptyString {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "{}", self.0)
        }
    }
    
    // ── PositiveInt ─────────────────────────────────────────────────────────────
    
    /// An integer guaranteed to be strictly positive (> 0).
    #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
    pub struct PositiveInt(i64);
    
    impl PositiveInt {
        pub fn create(n: i64) -> Result<Self, String> {
            if n > 0 {
                Ok(PositiveInt(n))
            } else {
                Err(format!("{} is not positive", n))
            }
        }
    
        pub fn value(self) -> i64 {
            self.0
        }
    
        /// Addition of two PositiveInts — result is always positive.
        pub fn add(self, other: Self) -> Self {
            PositiveInt(self.0 + other.0)
        }
    
        /// Multiplication of two PositiveInts — result is always positive.
        pub fn mul(self, other: Self) -> Self {
            PositiveInt(self.0 * other.0)
        }
    }
    
    impl std::fmt::Display for PositiveInt {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "{}", self.0)
        }
    }
    
    // ── Validated accumulating error type ───────────────────────────────────────
    // Goes beyond the OCaml example: a Validated<T> that collects ALL errors.
    
    #[derive(Debug, PartialEq)]
    pub enum Validated<T> {
        Ok(T),
        Err(Vec<String>),
    }
    
    impl<T> Validated<T> {
        pub fn ok(v: T) -> Self {
            Validated::Ok(v)
        }
    
        pub fn err(e: impl Into<String>) -> Self {
            Validated::Err(vec![e.into()])
        }
    
        pub fn is_ok(&self) -> bool {
            matches!(self, Validated::Ok(_))
        }
    
        /// Map over a successful value.
        pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Validated<U> {
            match self {
                Validated::Ok(v) => Validated::Ok(f(v)),
                Validated::Err(es) => Validated::Err(es),
            }
        }
    
        /// Combine two Validated values, collecting errors from both.
        pub fn and<U>(self, other: Validated<U>) -> Validated<(T, U)> {
            match (self, other) {
                (Validated::Ok(a), Validated::Ok(b)) => Validated::Ok((a, b)),
                (Validated::Err(mut e1), Validated::Err(e2)) => {
                    e1.extend(e2);
                    Validated::Err(e1)
                }
                (Validated::Err(e), _) | (_, Validated::Err(e)) => Validated::Err(e),
            }
        }
    
        pub fn errors(&self) -> Option<&[String]> {
            match self {
                Validated::Err(es) => Some(es),
                _ => None,
            }
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_non_empty_string_ok() {
            let s = NonEmptyString::create("hello").unwrap();
            assert_eq!(s.value(), "hello");
            assert_eq!(s.len(), 5);
        }
    
        #[test]
        fn test_non_empty_string_err() {
            assert!(NonEmptyString::create("").is_err());
        }
    
        #[test]
        fn test_positive_int_ok() {
            let n = PositiveInt::create(42).unwrap();
            assert_eq!(n.value(), 42);
        }
    
        #[test]
        fn test_positive_int_err() {
            assert!(PositiveInt::create(0).is_err());
            assert!(PositiveInt::create(-5).is_err());
        }
    
        #[test]
        fn test_positive_int_add() {
            let a = PositiveInt::create(3).unwrap();
            let b = PositiveInt::create(4).unwrap();
            assert_eq!(a.add(b).value(), 7);
        }
    
        #[test]
        fn test_validated_accumulates_errors() {
            let v1: Validated<i32> = Validated::err("error 1");
            let v2: Validated<i32> = Validated::err("error 2");
            let combined = v1.and(v2);
            assert_eq!(combined.errors().unwrap().len(), 2);
        }
    
        #[test]
        fn test_validated_ok() {
            let v1 = Validated::ok(1_i32);
            let v2 = Validated::ok(2_i32);
            assert_eq!(v1.and(v2), Validated::Ok((1, 2)));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_non_empty_string_ok() {
            let s = NonEmptyString::create("hello").unwrap();
            assert_eq!(s.value(), "hello");
            assert_eq!(s.len(), 5);
        }
    
        #[test]
        fn test_non_empty_string_err() {
            assert!(NonEmptyString::create("").is_err());
        }
    
        #[test]
        fn test_positive_int_ok() {
            let n = PositiveInt::create(42).unwrap();
            assert_eq!(n.value(), 42);
        }
    
        #[test]
        fn test_positive_int_err() {
            assert!(PositiveInt::create(0).is_err());
            assert!(PositiveInt::create(-5).is_err());
        }
    
        #[test]
        fn test_positive_int_add() {
            let a = PositiveInt::create(3).unwrap();
            let b = PositiveInt::create(4).unwrap();
            assert_eq!(a.add(b).value(), 7);
        }
    
        #[test]
        fn test_validated_accumulates_errors() {
            let v1: Validated<i32> = Validated::err("error 1");
            let v2: Validated<i32> = Validated::err("error 2");
            let combined = v1.and(v2);
            assert_eq!(combined.errors().unwrap().len(), 2);
        }
    
        #[test]
        fn test_validated_ok() {
            let v1 = Validated::ok(1_i32);
            let v2 = Validated::ok(2_i32);
            assert_eq!(v1.and(v2), Validated::Ok((1, 2)));
        }
    }

    Exercises

  • Implement EmailAddress as an opaque type whose create validates presence of @ and at least one . after it.
  • Add NonEmptyString::split_first() -> (char, &str) — guaranteed safe because the string is non-empty.
  • Implement BoundedInt { value: i64, min: i64, max: i64 } with a constructor that validates range.
  • Add serde serialization that validates on deserialization (implement Deserialize with invariant check).
  • Write a function that takes two PositiveInts and computes their ratio as f64 — always safe (no division by zero).
  • Open Source Repos