๐Ÿฆ€ Functional Rust

741: Parse-Don't-Validate: Rich Types Over Runtime Checks

Difficulty: 4 Level: Expert Parse once at the boundary into a type that can only hold valid values โ€” all code that receives the type knows it's valid without checking.

The Problem This Solves

Here's a common pattern: a function receives a `String` representing an email address, calls `is_valid_email(&email)` at the start to check it, and proceeds. That check must be repeated in every function that handles email strings โ€” or it gets forgotten. If any call site skips the check, bugs slip through silently. The "parse, don't validate" principle (coined by Alexis King) says: validate once at the input boundary, and return a type that proves validity. An `Email` newtype can only be constructed by `Email::parse()`, which validates the format. Any function that receives an `Email` knows it's valid โ€” the type is the proof. No defensive checks needed at every call site. This is the Rust translation of making impossible states unrepresentable. `String` can hold `"not-an-email"`. `Email` cannot. The type system does the documentation and enforcement simultaneously.

The Intuition

In Python, you might create a `@dataclass` with `__post_init__` validation, or use `pydantic`. In TypeScript, you'd use a branded type: `type Email = string & { _brand: 'Email' }`. In Rust, the newtype pattern with a private field is the idiomatic approach. The key is private fields + a single parse constructor. The field being private means `Email("bad@")` doesn't compile from outside the module โ€” callers must go through `Email::parse()`. The parse function validates and returns `Result<Email, ParseError>`, forcing callers to handle the error at the boundary. Once you have an `Email`, you never check validity again. Functions that need an email take `Email`, not `String`. The type carries its own proof of validity through the entire program.

How It Works in Rust

// Private field โ€” cannot construct Email directly from outside this module
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Email(String);  // private field

impl Email {
 /// The only way to create an Email โ€” validates at the boundary
 pub fn parse(s: &str) -> Result<Self, ParseError> {
     let at = s.find('@').ok_or_else(|| ParseError::InvalidEmail(s.to_owned()))?;
     let (local, domain) = s.split_at(at);
     let domain = &domain[1..]; // skip '@'
     if local.is_empty() || !domain.contains('.') || domain.starts_with('.') {
         return Err(ParseError::InvalidEmail(s.to_owned()));
     }
     Ok(Email(s.to_ascii_lowercase()))
 }

 pub fn as_str(&self) -> &str { &self.0 }
 pub fn domain(&self) -> &str { self.0.split('@').nth(1).unwrap() }
}

// Before parse-don't-validate (the bad way):
fn send_email_bad(address: &str) {
 if !is_valid_email(address) { panic!("invalid email"); }  // forgotten at half the call sites
 // ...
}

// After parse-don't-validate (the good way):
fn send_email(to: &Email, subject: &str) {  // can only receive a valid Email
 // No check needed โ€” the type is the proof
 println!("Sending to {}", to.as_str());
}

// Parse at the boundary โ€” once
let email = Email::parse("Alice@Example.COM")
 .expect("invalid email address");
// email is now Email("alice@example.com") โ€” lowercase-normalized, validated
send_email(&email, "Hello!");

// Bounded integer โ€” can only hold values in [1, 100]
pub struct Percentage(u8);

impl Percentage {
 pub fn parse(n: i64) -> Result<Self, ParseError> {
     if n < 1 || n > 100 {
         return Err(ParseError::OutOfRange { value: n, lo: 1, hi: 100 });
     }
     Ok(Percentage(n as u8))
 }
 pub fn value(&self) -> u8 { self.0 }
}

// Function that requires a valid percentage โ€” no range check needed
fn set_volume(level: Percentage) {
 // level.value() is guaranteed [1, 100] โ€” we know without checking
}
Key points:

What This Unlocks

Key Differences

ConceptOCamlRust
Opaque typeAbstract type in `.mli` signatureStruct with private field
Smart constructorFunction returning `result` type`fn parse(s: &str) -> Result<Self, ParseError>`
Private fieldModule-level privacy via `.mli``pub struct Email(String)` โ€” field private by default
Canonical formConstructor applies normalizationSame โ€” `to_ascii_lowercase()` in `parse()`
Hash/equalityStructural equality by defaultDerived `Hash + Eq`
Bounded integerVariant type with constraintNewtype struct with range check in `parse()`
/// 741: Parse-Don't-Validate
/// Types that can ONLY be created via parsing. Once created, always valid.

// โ”€โ”€ Error types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

#[derive(Debug, PartialEq)]
pub enum ParseError {
    EmptyString,
    InvalidEmail(String),
    OutOfRange { value: i64, lo: i64, hi: i64 },
    InvalidChar(char),
}

impl std::fmt::Display for ParseError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ParseError::EmptyString => write!(f, "string is empty"),
            ParseError::InvalidEmail(s) => write!(f, "'{}' is not a valid email", s),
            ParseError::OutOfRange { value, lo, hi } =>
                write!(f, "{} not in range [{}, {}]", value, lo, hi),
            ParseError::InvalidChar(c) => write!(f, "invalid character '{}'", c),
        }
    }
}

// โ”€โ”€ NonEmptyString โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// A string guaranteed to be non-empty. Private field prevents direct construction.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct NonEmptyString(String);

impl NonEmptyString {
    pub fn parse(s: &str) -> Result<Self, ParseError> {
        if s.is_empty() {
            return Err(ParseError::EmptyString);
        }
        Ok(NonEmptyString(s.to_owned()))
    }

    pub fn as_str(&self) -> &str { &self.0 }
    pub fn len(&self) -> usize { self.0.len() }
}

impl std::fmt::Display for NonEmptyString {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

// โ”€โ”€ Email โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// A validated email address.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Email(String);

impl Email {
    pub fn parse(s: &str) -> Result<Self, ParseError> {
        let at = s.find('@').ok_or_else(|| ParseError::InvalidEmail(s.to_owned()))?;
        let (local, domain) = s.split_at(at);
        let domain = &domain[1..]; // skip '@'
        if local.is_empty() || !domain.contains('.') || domain.starts_with('.') {
            return Err(ParseError::InvalidEmail(s.to_owned()));
        }
        Ok(Email(s.to_ascii_lowercase()))
    }

    pub fn as_str(&self) -> &str { &self.0 }
    pub fn local_part(&self) -> &str { self.0.split('@').next().unwrap() }
    pub fn domain(&self) -> &str { self.0.split('@').nth(1).unwrap() }
}

impl std::fmt::Display for Email {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

// โ”€โ”€ BoundedInt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// An integer constrained to [LO, HI].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct BoundedInt<const LO: i64, const HI: i64>(i64);

impl<const LO: i64, const HI: i64> BoundedInt<LO, HI> {
    pub fn parse(n: i64) -> Result<Self, ParseError> {
        if n < LO || n > HI {
            return Err(ParseError::OutOfRange { value: n, lo: LO, hi: HI });
        }
        Ok(BoundedInt(n))
    }

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

// โ”€โ”€ Functions that REQUIRE parsed types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/// This function only accepts valid emails โ€” no runtime checks needed inside.
fn send_welcome(email: &Email) -> String {
    format!("Welcome email sent to {}", email)
}

/// Only accepts non-empty usernames โ€” no `if name.is_empty()` guards needed.
fn create_account(username: &NonEmptyString, email: &Email) -> String {
    format!("Account '{}' created with email {}", username, email)
}

fn main() {
    // Valid email
    let email = Email::parse("User@Example.COM").unwrap();
    println!("Email: {} (domain: {})", email, email.domain());
    println!("{}", send_welcome(&email));

    // Invalid email
    match Email::parse("notanemail") {
        Ok(_)  => println!("unexpected success"),
        Err(e) => println!("Email error: {}", e),
    }

    // NonEmptyString
    let name = NonEmptyString::parse("Alice").unwrap();
    println!("{}", create_account(&name, &email));

    match NonEmptyString::parse("") {
        Ok(_)  => println!("unexpected"),
        Err(e) => println!("NonEmpty error: {}", e),
    }

    // BoundedInt
    type Percentage = BoundedInt<0, 100>;
    let pct = Percentage::parse(75).unwrap();
    println!("Progress: {}%", pct.value());

    match Percentage::parse(101) {
        Ok(_)  => println!("unexpected"),
        Err(e) => println!("Bounded error: {}", e),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_email_parses() {
        let e = Email::parse("user@example.com").unwrap();
        assert_eq!(e.domain(), "example.com");
        assert_eq!(e.local_part(), "user");
    }

    #[test]
    fn email_normalized_to_lowercase() {
        let e = Email::parse("USER@EXAMPLE.COM").unwrap();
        assert_eq!(e.as_str(), "user@example.com");
    }

    #[test]
    fn invalid_emails_rejected() {
        assert!(Email::parse("").is_err());
        assert!(Email::parse("noatsign").is_err());
        assert!(Email::parse("@nodomain").is_err());
        assert!(Email::parse("user@nodot").is_err());
    }

    #[test]
    fn non_empty_string_valid() {
        let s = NonEmptyString::parse("hello").unwrap();
        assert_eq!(s.len(), 5);
    }

    #[test]
    fn non_empty_string_rejects_empty() {
        assert_eq!(NonEmptyString::parse(""), Err(ParseError::EmptyString));
    }

    #[test]
    fn bounded_int_valid() {
        type Score = BoundedInt<0, 10>;
        assert_eq!(Score::parse(5).unwrap().value(), 5);
        assert_eq!(Score::parse(0).unwrap().value(), 0);
        assert_eq!(Score::parse(10).unwrap().value(), 10);
    }

    #[test]
    fn bounded_int_out_of_range() {
        type Score = BoundedInt<0, 10>;
        assert!(Score::parse(-1).is_err());
        assert!(Score::parse(11).is_err());
    }
}
(* 741: Parse Don't Validate โ€” OCaml with module privacy *)

(* Email: can only be constructed via Email.parse *)
module Email : sig
  type t
  val parse  : string -> (t, string) result
  val to_string : t -> string
end = struct
  type t = string  (* private representation *)

  let parse s =
    (* Simple validation: must contain @ and a dot after @ *)
    match String.split_on_char '@' s with
    | [local; domain] when String.length local > 0
                       && String.contains domain '.' ->
        Ok s
    | _ -> Error (Printf.sprintf "'%s' is not a valid email" s)

  let to_string t = t
end

(* NonEmptyString: guaranteed non-empty *)
module NonEmpty : sig
  type t
  val parse     : string -> (t, string) result
  val to_string : t -> string
  val length    : t -> int
end = struct
  type t = string
  let parse s =
    if String.length s = 0 then Error "string is empty"
    else Ok s
  let to_string t = t
  let length t = String.length t
end

(* BoundedInt: integer in range [lo, hi] *)
module BoundedInt : sig
  type t
  val make      : lo:int -> hi:int -> int -> (t, string) result
  val value     : t -> int
end = struct
  type t = int
  let make ~lo ~hi n =
    if n >= lo && n <= hi then Ok n
    else Error (Printf.sprintf "%d not in [%d, %d]" n lo hi)
  let value t = t
end

let () =
  (* Email *)
  (match Email.parse "user@example.com" with
  | Ok e -> Printf.printf "Valid email: %s\n" (Email.to_string e)
  | Error e -> Printf.printf "Error: %s\n" e);
  (match Email.parse "notanemail" with
  | Ok _ -> ()
  | Error e -> Printf.printf "Error: %s\n" e);

  (* NonEmpty *)
  (match NonEmpty.parse "" with
  | Ok _ -> ()
  | Error e -> Printf.printf "Error: %s\n" e);
  (match NonEmpty.parse "hello" with
  | Ok s -> Printf.printf "NonEmpty: %s (len=%d)\n" (NonEmpty.to_string s) (NonEmpty.length s)
  | Error _ -> ());

  (* BoundedInt *)
  (match BoundedInt.make ~lo:1 ~hi:100 42 with
  | Ok n -> Printf.printf "Bounded: %d\n" (BoundedInt.value n)
  | Error e -> Printf.printf "Error: %s\n" e);
  (match BoundedInt.make ~lo:1 ~hi:100 999 with
  | Ok _ -> ()
  | Error e -> Printf.printf "Error: %s\n" e)