🦀 Functional Rust

073: Validated Type — Smart Constructors

Difficulty: 3 Level: Intermediate Parse at the boundary, validate once, never check again — opaque newtypes that enforce invariants at construction time.

The Problem This Solves

Functions often receive strings or numbers that must meet certain criteria: an email must contain `@`, a port must be between 1–65535, a price must be positive. The naive approach validates at every use site — scattered `if` checks that are easy to forget and inconsistent to audit. Smart constructors flip this: validation happens once, at the moment you create the value. If construction succeeds, you hold a type that by construction satisfies the invariant — forever, everywhere it travels in your code. No function that receives `Email` needs to re-check it's valid. The compiler enforces the guarantee. This is the "parse, don't validate" principle: transform unstructured input into a typed proof of validity.

The Intuition

In Python or JavaScript, you'd pass a raw string around and check `is_valid_email(s)` at each use site. Bugs emerge when someone skips the check. Rust's private fields make the newtype pattern enforceable: if the inner field is private, the only way to construct an `Email` is through `Email::parse()`. The type system becomes your validator — once the value exists, it's always valid. Compare to OCaml's `type email = private string` — Rust achieves the same with a tuple struct and `pub`/private field control.

How It Works in Rust

// The inner field is private — only constructible via ::create()
#[derive(Debug, Clone, PartialEq)]
pub struct NonEmptyString(String); // private field!

impl NonEmptyString {
 // The one true entrance: validates, then wraps
 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())
     }
 }

 // Read-only access — no way to get a mutable reference to the inner String
 pub fn value(&self) -> &str { &self.0 }

 // Operations on the validated type stay valid — concat of two non-empty strings is non-empty
 pub fn concat(&self, other: &NonEmptyString) -> NonEmptyString {
     NonEmptyString(format!("{}{}", self.0, other.0))
 }
}

// Positive integer: validated at construction, always > 0 after that
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct PositiveInt(u64);

impl PositiveInt {
 pub fn new(n: u64) -> Result<Self, String> {
     if n > 0 { Ok(PositiveInt(n)) }
     else { Err("must be > 0".to_string()) }
 }

 pub fn value(self) -> u64 { self.0 }

 // PositiveInt + PositiveInt is always positive — no re-validation needed
 pub fn add(self, other: PositiveInt) -> PositiveInt {
     PositiveInt(self.0 + other.0) // safe: sum of positives is positive
 }
}
The key: make the inner field private, expose only a `parse`/`create`/`new` method returning `Result`. Downstream code never deals with invalid states.

What This Unlocks

Key Differences

ConceptOCamlRust
Opaque type`type t = private string`Tuple struct with private field
Constructor`let create s = if ... then Ok (wrap s) else Error ...``pub fn create(s: &str) -> Result<Self, E>`
Field accessModule-controlled`pub fn value(&self) -> &T`
Derive traits`[@@deriving eq, show]``#[derive(Debug, Clone, PartialEq)]`
Invalid statesPrevented by private typePrevented by private field + Result constructor
// 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,
        }
    }
}

fn main() {
    // NonEmptyString
    match NonEmptyString::create("hello") {
        Ok(s) => println!("NonEmpty: {} (len {})", s.value(), s.len()),
        Err(e) => println!("Error: {}", e),
    }

    match NonEmptyString::create("") {
        Ok(_) => panic!("should have been rejected"),
        Err(e) => println!("Rejected: {}", e),
    }

    // PositiveInt
    match (PositiveInt::create(42), PositiveInt::create(-1)) {
        (Ok(p), Err(e)) => println!("PositiveInt: {}; rejected: {}", p.value(), e),
        _ => panic!("unexpected"),
    }

    // Validated accumulates errors
    let v1: Validated<i32> = Validated::err("name is empty");
    let v2: Validated<i32> = Validated::err("age must be positive");
    let combined = v1.and(v2);
    println!(
        "Accumulated errors: {:?}",
        combined.errors().unwrap()
    );

    // All OK
    let a: Validated<NonEmptyString> = NonEmptyString::create("Alice")
        .map(|s| Validated::Ok(s))
        .unwrap_or_else(|e| Validated::err(e));
    let b: Validated<PositiveInt> = PositiveInt::create(30)
        .map(|n| Validated::Ok(n))
        .unwrap_or_else(|e| Validated::err(e));
    match a.and(b) {
        Validated::Ok((name, age)) => println!("Valid user: {} age {}", name, age),
        Validated::Err(es) => println!("Errors: {:?}", es),
    }
}

#[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)));
    }
}
(* Smart constructors: enforce invariants at the type level.
   The type is opaque — you can only create values through validated constructors. *)

(* Non-empty string *)
module NonEmptyString : sig
  type t
  val create : string -> (t, string) result
  val value  : t -> string
  val length : t -> int
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 length s = String.length s
end

(* Positive integer *)
module PositiveInt : sig
  type t
  val create : int -> (t, string) result
  val value  : t -> int
  val add    : t -> t -> t
end = struct
  type t = int
  let create n =
    if n > 0 then Ok n
    else Error (Printf.sprintf "%d is not positive" n)
  let value n = n
  let add a b = a + b
end

let () =
  (match NonEmptyString.create "hello" with
   | Ok s  -> Printf.printf "NonEmpty: %s (len %d)\n" (NonEmptyString.value s) (NonEmptyString.length s)
   | Error e -> Printf.printf "Error: %s\n" e);

  (match NonEmptyString.create "" with
   | Ok _  -> assert false
   | Error e -> Printf.printf "Rejected: %s\n" e);

  (match PositiveInt.create 42, PositiveInt.create (-1) with
   | Ok p, Error e ->
     Printf.printf "PositiveInt: %d; rejected: %s\n" (PositiveInt.value p) e
   | _ -> assert false)