ExamplesBy LevelBy TopicLearning Paths
073 Intermediate

073 — Validated Types (Parse, Don't Validate)

Functional Programming

Tutorial

The Problem

"Parse, don't validate" (Lexi Lambda, 2019) is a foundational design principle: instead of checking a precondition and continuing with the raw value, parse the input into a type that structurally PROVES the precondition is satisfied. NonEmptyString cannot be empty by construction — its type is the proof. PositiveInt cannot be negative — the type system, not runtime checks, enforces this.

This pattern eliminates entire categories of defensive programming. If a function takes NonEmptyString, callers cannot accidentally pass an empty string — the compiler prevents it. Downstream code needs no re-validation. Applied in Rust, types like Email, PositiveInt, and BoundedString<1, 50> make invalid states literally unrepresentable in the program's type structure.

Used in nutype (a Rust derive macro for validated newtypes), validator, and custom domain types throughout production Rust codebases, this pattern is especially valuable in domain-driven design where the business rules should be embedded in types, not scattered as defensive checks throughout the logic.

🎯 Learning Outcomes

  • • Define newtypes with private fields so construction is controlled
  • • Implement fallible constructors returning Option<T> or Result<T, E>
  • • Understand that the type guarantees the invariant everywhere — no re-checking needed
  • • Apply the "parse, don't validate" principle: validate once at the boundary, then trust
  • • Use these validated types in function signatures to express preconditions
  • Code Example

    #![allow(clippy::all)]
    // 073: Parse Don't Validate — Validated Types
    
    // Approach 1: NonEmptyString
    #[derive(Debug, Clone, PartialEq)]
    struct NonEmptyString(String); // private field!
    
    impl NonEmptyString {
        fn new(s: &str) -> Option<Self> {
            if s.is_empty() {
                None
            } else {
                Some(NonEmptyString(s.to_string()))
            }
        }
    
        fn as_str(&self) -> &str {
            &self.0
        }
    
        fn len(&self) -> usize {
            self.0.len() // always >= 1
        }
    }
    
    // Approach 2: PositiveInt
    #[derive(Debug, Clone, Copy, PartialEq)]
    struct PositiveInt(u32); // private field, always > 0
    
    impl PositiveInt {
        fn new(n: i32) -> Option<Self> {
            if n <= 0 {
                None
            } else {
                Some(PositiveInt(n as u32))
            }
        }
    
        fn value(&self) -> u32 {
            self.0
        }
    
        fn add(self, other: Self) -> Self {
            PositiveInt(self.0 + other.0) // sum of positives is positive
        }
    }
    
    // Approach 3: Email
    #[derive(Debug, Clone, PartialEq)]
    struct Email(String);
    
    impl Email {
        fn new(s: &str) -> Result<Self, String> {
            if !s.contains('@') {
                Err("Missing @".into())
            } else if s.len() < 3 {
                Err("Too short".into())
            } else {
                Ok(Email(s.to_string()))
            }
        }
    
        fn as_str(&self) -> &str {
            &self.0
        }
    }
    
    // Using validated types — no further validation needed
    fn greet(name: &NonEmptyString) -> String {
        format!("Hello, {}!", name.as_str())
    }
    
    fn double_positive(n: PositiveInt) -> u32 {
        n.value() * 2
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_non_empty_string() {
            assert!(NonEmptyString::new("").is_none());
            assert!(NonEmptyString::new("hello").is_some());
            let s = NonEmptyString::new("Alice").unwrap();
            assert_eq!(s.as_str(), "Alice");
            assert_eq!(s.len(), 5);
        }
    
        #[test]
        fn test_positive_int() {
            assert!(PositiveInt::new(0).is_none());
            assert!(PositiveInt::new(-5).is_none());
            let n = PositiveInt::new(42).unwrap();
            assert_eq!(n.value(), 42);
            assert_eq!(double_positive(n), 84);
        }
    
        #[test]
        fn test_positive_add() {
            let a = PositiveInt::new(3).unwrap();
            let b = PositiveInt::new(4).unwrap();
            assert_eq!(a.add(b).value(), 7);
        }
    
        #[test]
        fn test_email() {
            assert!(Email::new("bad").is_err());
            assert!(Email::new("a@b.com").is_ok());
            assert_eq!(Email::new("a@b.com").unwrap().as_str(), "a@b.com");
        }
    
        #[test]
        fn test_greet() {
            let name = NonEmptyString::new("Alice").unwrap();
            assert_eq!(greet(&name), "Hello, Alice!");
        }
    }

    Key Differences

  • Private fields: Rust uses a tuple struct with no pub on the field: struct NonEmptyString(String). OCaml uses abstract types in a module signature to hide the representation.
  • Newtype vs abstract type: Rust's newtype pattern is the typical approach. OCaml's module-based abstract types provide the same guarantee but through the module system.
  • **nutype crate**: Rust's nutype crate generates validated newtypes via derive macros. OCaml has no equivalent — modules are written manually.
  • Zero-cost: Both approaches are zero-cost — no runtime overhead for the wrapper. Rust's tuple struct has the same layout as the inner type.
  • OCaml Approach

    OCaml uses abstract types in modules to hide the internal representation:

    module NonEmptyString : sig
      type t
      val of_string : string -> t option
      val to_string : t -> string
    end = struct
      type t = string
      let of_string s = if s = "" then None else Some s
      let to_string s = s
    end
    

    The sig restricts the visible interface: the t type is abstract outside the module, so callers cannot construct a NonEmptyString.t directly — only through of_string. This is OCaml's equivalent of Rust's private fields.</p>

    Full Source

    #![allow(clippy::all)]
    // 073: Parse Don't Validate — Validated Types
    
    // Approach 1: NonEmptyString
    #[derive(Debug, Clone, PartialEq)]
    struct NonEmptyString(String); // private field!
    
    impl NonEmptyString {
        fn new(s: &str) -> Option<Self> {
            if s.is_empty() {
                None
            } else {
                Some(NonEmptyString(s.to_string()))
            }
        }
    
        fn as_str(&self) -> &str {
            &self.0
        }
    
        fn len(&self) -> usize {
            self.0.len() // always >= 1
        }
    }
    
    // Approach 2: PositiveInt
    #[derive(Debug, Clone, Copy, PartialEq)]
    struct PositiveInt(u32); // private field, always > 0
    
    impl PositiveInt {
        fn new(n: i32) -> Option<Self> {
            if n <= 0 {
                None
            } else {
                Some(PositiveInt(n as u32))
            }
        }
    
        fn value(&self) -> u32 {
            self.0
        }
    
        fn add(self, other: Self) -> Self {
            PositiveInt(self.0 + other.0) // sum of positives is positive
        }
    }
    
    // Approach 3: Email
    #[derive(Debug, Clone, PartialEq)]
    struct Email(String);
    
    impl Email {
        fn new(s: &str) -> Result<Self, String> {
            if !s.contains('@') {
                Err("Missing @".into())
            } else if s.len() < 3 {
                Err("Too short".into())
            } else {
                Ok(Email(s.to_string()))
            }
        }
    
        fn as_str(&self) -> &str {
            &self.0
        }
    }
    
    // Using validated types — no further validation needed
    fn greet(name: &NonEmptyString) -> String {
        format!("Hello, {}!", name.as_str())
    }
    
    fn double_positive(n: PositiveInt) -> u32 {
        n.value() * 2
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_non_empty_string() {
            assert!(NonEmptyString::new("").is_none());
            assert!(NonEmptyString::new("hello").is_some());
            let s = NonEmptyString::new("Alice").unwrap();
            assert_eq!(s.as_str(), "Alice");
            assert_eq!(s.len(), 5);
        }
    
        #[test]
        fn test_positive_int() {
            assert!(PositiveInt::new(0).is_none());
            assert!(PositiveInt::new(-5).is_none());
            let n = PositiveInt::new(42).unwrap();
            assert_eq!(n.value(), 42);
            assert_eq!(double_positive(n), 84);
        }
    
        #[test]
        fn test_positive_add() {
            let a = PositiveInt::new(3).unwrap();
            let b = PositiveInt::new(4).unwrap();
            assert_eq!(a.add(b).value(), 7);
        }
    
        #[test]
        fn test_email() {
            assert!(Email::new("bad").is_err());
            assert!(Email::new("a@b.com").is_ok());
            assert_eq!(Email::new("a@b.com").unwrap().as_str(), "a@b.com");
        }
    
        #[test]
        fn test_greet() {
            let name = NonEmptyString::new("Alice").unwrap();
            assert_eq!(greet(&name), "Hello, Alice!");
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_non_empty_string() {
            assert!(NonEmptyString::new("").is_none());
            assert!(NonEmptyString::new("hello").is_some());
            let s = NonEmptyString::new("Alice").unwrap();
            assert_eq!(s.as_str(), "Alice");
            assert_eq!(s.len(), 5);
        }
    
        #[test]
        fn test_positive_int() {
            assert!(PositiveInt::new(0).is_none());
            assert!(PositiveInt::new(-5).is_none());
            let n = PositiveInt::new(42).unwrap();
            assert_eq!(n.value(), 42);
            assert_eq!(double_positive(n), 84);
        }
    
        #[test]
        fn test_positive_add() {
            let a = PositiveInt::new(3).unwrap();
            let b = PositiveInt::new(4).unwrap();
            assert_eq!(a.add(b).value(), 7);
        }
    
        #[test]
        fn test_email() {
            assert!(Email::new("bad").is_err());
            assert!(Email::new("a@b.com").is_ok());
            assert_eq!(Email::new("a@b.com").unwrap().as_str(), "a@b.com");
        }
    
        #[test]
        fn test_greet() {
            let name = NonEmptyString::new("Alice").unwrap();
            assert_eq!(greet(&name), "Hello, Alice!");
        }
    }

    Deep Comparison

    Core Insight

    Instead of validating at every use site, validate once at construction and encode the invariant in the type system. A NonEmptyString can never be empty — no runtime checks needed downstream.

    OCaml Approach

  • • Private/abstract types in module signatures
  • • Constructor function returns option or result
  • • Module signature hides the raw constructor
  • Rust Approach

  • • Newtype pattern: struct NonEmptyString(String) with private field
  • • Constructor returns Result
  • • No public access to inner value without going through API
  • Comparison Table

    FeatureOCamlRust
    Hide constructorModule signaturePrivate field
    Validatecreate : string -> t optionfn new(s) -> Result<Self>
    AccessGetter function.as_str() / .value()
    GuaranteeType-levelType-level

    Exercises

  • Email type: Define Email(String) with Email::new(s: &str) -> Option<Email> that validates the string contains exactly one @ and a non-empty domain. Use it in a send_email(to: Email, subject: NonEmptyString, body: &str) function.
  • Bounded string: Write BoundedString<const MIN: usize, const MAX: usize>(String) using const generics. new validates MIN <= s.len() <= MAX.
  • Range value: Write RangeValue<const MIN: i32, const MAX: i32>(i32) with const generics. Implement Add<RangeValue<MIN, MAX>> for RangeValue<MIN, MAX> that saturates at the bounds.
  • Open Source Repos