ExamplesBy LevelBy TopicLearning Paths
054 Intermediate

054 — Applicative Validation

Functional Programming

Tutorial

The Problem

Standard Result short-circuits at the first error — if name validation fails, age and email are never checked. For user-facing forms, you want to collect ALL errors and report them together. Applicative validation (inspired by Haskell's Validation type) accumulates errors rather than short-circuiting.

This pattern is essential in form validation, API request validation, configuration file validation, and data import pipelines. Instead of "the form has an error" (Result), you get "the form has 3 errors: name too long, email invalid, age negative". Used in Haskell's validation crate, Scala's Validated, and Rust's garde/validator crates.

🎯 Learning Outcomes

  • • Understand the difference between fail-fast (Result) and accumulate-all (Validation)
  • • Implement individual validators returning Result<T, Vec<E>>
  • • Combine validators by merging error vectors from both sides
  • • Understand the applicative functor pattern: f <*> a where both f and a may have errors
  • • Recognize that validation is not a monad (no and_then) because the second step does not depend on the first
  • • Distinguish applicative validation (accumulate all errors) from monadic chaining (short-circuit on first error)
  • • Implement a Validation<T, E> type with combine that merges Vec<E> error lists
  • Code Example

    #![allow(clippy::all)]
    // 054: Applicative Validation
    // Collect all validation errors instead of stopping at the first
    
    #[derive(Debug, PartialEq)]
    struct Person {
        name: String,
        age: u32,
        email: String,
    }
    
    #[derive(Debug, PartialEq, Clone)]
    enum ValidationError {
        NameEmpty,
        NameTooLong,
        AgeNegative,
        AgeUnrealistic,
        EmailInvalid,
    }
    
    // Approach 1: Individual validators
    fn validate_name(name: &str) -> Result<String, Vec<ValidationError>> {
        if name.is_empty() {
            Err(vec![ValidationError::NameEmpty])
        } else if name.len() > 50 {
            Err(vec![ValidationError::NameTooLong])
        } else {
            Ok(name.to_string())
        }
    }
    
    fn validate_age(age: i32) -> Result<u32, Vec<ValidationError>> {
        if age < 0 {
            Err(vec![ValidationError::AgeNegative])
        } else if age > 150 {
            Err(vec![ValidationError::AgeUnrealistic])
        } else {
            Ok(age as u32)
        }
    }
    
    fn validate_email(email: &str) -> Result<String, Vec<ValidationError>> {
        if !email.contains('@') {
            Err(vec![ValidationError::EmailInvalid])
        } else {
            Ok(email.to_string())
        }
    }
    
    // Approach 2: Collect all errors
    fn validate_person(name: &str, age: i32, email: &str) -> Result<Person, Vec<ValidationError>> {
        let mut errors = Vec::new();
        let name_result = validate_name(name);
        let age_result = validate_age(age);
        let email_result = validate_email(email);
    
        if let Err(ref e) = name_result {
            errors.extend(e.iter().cloned());
        }
        if let Err(ref e) = age_result {
            errors.extend(e.iter().cloned());
        }
        if let Err(ref e) = email_result {
            errors.extend(e.iter().cloned());
        }
    
        if errors.is_empty() {
            Ok(Person {
                name: name_result.unwrap(),
                age: age_result.unwrap(),
                email: email_result.unwrap(),
            })
        } else {
            Err(errors)
        }
    }
    
    // Approach 3: Using a Validated type
    enum Validated<T> {
        Valid(T),
        Invalid(Vec<ValidationError>),
    }
    
    impl<T> Validated<T> {
        fn and_then<U>(self, f: impl FnOnce(T) -> Validated<U>) -> Validated<U> {
            match self {
                Validated::Valid(x) => f(x),
                Validated::Invalid(e) => Validated::Invalid(e),
            }
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_person() {
            let result = validate_person("Alice", 30, "alice@example.com");
            assert!(result.is_ok());
            let p = result.unwrap();
            assert_eq!(p.name, "Alice");
            assert_eq!(p.age, 30);
        }
    
        #[test]
        fn test_all_errors_collected() {
            let result = validate_person("", -5, "bad");
            assert!(result.is_err());
            assert_eq!(result.unwrap_err().len(), 3);
        }
    
        #[test]
        fn test_partial_errors() {
            let result = validate_person("Bob", 25, "bad");
            assert!(result.is_err());
            assert_eq!(result.unwrap_err().len(), 1);
        }
    }

    Key Differences

  • Not a monad: Validation is an applicative functor but not a monad. The second computation does not depend on the first's result — all run independently. Result (a monad) can model dependency. This is the fundamental difference.
  • Error merging: Validation merges error lists on both failure cases. Result::and_then only runs the second step if the first succeeds — it cannot accumulate errors.
  • **Vec<E> errors**: Both implementations use Vec<ValidationError> for the error accumulator. The individual validators return single errors wrapped in vec![...] for uniformity.
  • Crates: The garde and validator crates implement applicative validation for Rust structs via derive macros. Understanding the manual implementation explains what these macros generate.
  • Accumulating vs short-circuiting: and_then (>>=) short-circuits on the first error. Applicative validation (using <*> or map2) accumulates all errors. The choice depends on whether you want "first error" or "all errors" reporting.
  • **Result vs custom Validation:** Rust's standard Result::and_then short-circuits. For accumulation, define a Validation<T, E> type that collects errors in a Vec<E>.
  • Real-world use: Form validation always uses accumulative errors — you want to show ALL invalid fields, not just the first. API request validation is similar.
  • **OCaml Validated:** OCaml's ppx_validate and libraries like Validate provide applicative-style validation. The type is type ('a, 'e) validated = Valid of 'a | Invalid of 'e list.
  • OCaml Approach

    OCaml defines a validation type: type ('a, 'e) validation = Ok of 'a | Errors of 'e list. The applicative combine: let combine v1 v2 f = match v1, v2 with | Ok a, Ok b -> Ok (f a b) | Ok _, Errors e | Errors e, Ok _ -> Errors e | Errors e1, Errors e2 -> Errors (e1 @ e2). The key: combine merges error lists from both sides, even when both fail.

    Full Source

    #![allow(clippy::all)]
    // 054: Applicative Validation
    // Collect all validation errors instead of stopping at the first
    
    #[derive(Debug, PartialEq)]
    struct Person {
        name: String,
        age: u32,
        email: String,
    }
    
    #[derive(Debug, PartialEq, Clone)]
    enum ValidationError {
        NameEmpty,
        NameTooLong,
        AgeNegative,
        AgeUnrealistic,
        EmailInvalid,
    }
    
    // Approach 1: Individual validators
    fn validate_name(name: &str) -> Result<String, Vec<ValidationError>> {
        if name.is_empty() {
            Err(vec![ValidationError::NameEmpty])
        } else if name.len() > 50 {
            Err(vec![ValidationError::NameTooLong])
        } else {
            Ok(name.to_string())
        }
    }
    
    fn validate_age(age: i32) -> Result<u32, Vec<ValidationError>> {
        if age < 0 {
            Err(vec![ValidationError::AgeNegative])
        } else if age > 150 {
            Err(vec![ValidationError::AgeUnrealistic])
        } else {
            Ok(age as u32)
        }
    }
    
    fn validate_email(email: &str) -> Result<String, Vec<ValidationError>> {
        if !email.contains('@') {
            Err(vec![ValidationError::EmailInvalid])
        } else {
            Ok(email.to_string())
        }
    }
    
    // Approach 2: Collect all errors
    fn validate_person(name: &str, age: i32, email: &str) -> Result<Person, Vec<ValidationError>> {
        let mut errors = Vec::new();
        let name_result = validate_name(name);
        let age_result = validate_age(age);
        let email_result = validate_email(email);
    
        if let Err(ref e) = name_result {
            errors.extend(e.iter().cloned());
        }
        if let Err(ref e) = age_result {
            errors.extend(e.iter().cloned());
        }
        if let Err(ref e) = email_result {
            errors.extend(e.iter().cloned());
        }
    
        if errors.is_empty() {
            Ok(Person {
                name: name_result.unwrap(),
                age: age_result.unwrap(),
                email: email_result.unwrap(),
            })
        } else {
            Err(errors)
        }
    }
    
    // Approach 3: Using a Validated type
    enum Validated<T> {
        Valid(T),
        Invalid(Vec<ValidationError>),
    }
    
    impl<T> Validated<T> {
        fn and_then<U>(self, f: impl FnOnce(T) -> Validated<U>) -> Validated<U> {
            match self {
                Validated::Valid(x) => f(x),
                Validated::Invalid(e) => Validated::Invalid(e),
            }
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_person() {
            let result = validate_person("Alice", 30, "alice@example.com");
            assert!(result.is_ok());
            let p = result.unwrap();
            assert_eq!(p.name, "Alice");
            assert_eq!(p.age, 30);
        }
    
        #[test]
        fn test_all_errors_collected() {
            let result = validate_person("", -5, "bad");
            assert!(result.is_err());
            assert_eq!(result.unwrap_err().len(), 3);
        }
    
        #[test]
        fn test_partial_errors() {
            let result = validate_person("Bob", 25, "bad");
            assert!(result.is_err());
            assert_eq!(result.unwrap_err().len(), 1);
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_person() {
            let result = validate_person("Alice", 30, "alice@example.com");
            assert!(result.is_ok());
            let p = result.unwrap();
            assert_eq!(p.name, "Alice");
            assert_eq!(p.age, 30);
        }
    
        #[test]
        fn test_all_errors_collected() {
            let result = validate_person("", -5, "bad");
            assert!(result.is_err());
            assert_eq!(result.unwrap_err().len(), 3);
        }
    
        #[test]
        fn test_partial_errors() {
            let result = validate_person("Bob", 25, "bad");
            assert!(result.is_err());
            assert_eq!(result.unwrap_err().len(), 1);
        }
    }

    Deep Comparison

    Core Insight

    Monadic (bind/and_then) error handling stops at the first error. Applicative validation runs all validations and collects every error. This is critical for form validation UX.

    OCaml Approach

  • • Define a custom validated type: Valid of 'a | Invalid of 'e list
  • • Combine validators by accumulating error lists
  • • No built-in applicative — manual implementation
  • Rust Approach

  • • Collect errors into Vec<String> or custom error enum
  • • Use iterator + partition or fold to gather all results
  • • Libraries like validator exist, but std approach works fine
  • Comparison Table

    FeatureOCamlRust
    Fail-fastResult + bindResult + ?
    Collect allCustom validated typeVec<Error> accumulation
    CombineManual applicative.iter().filter_map()

    Exercises

  • Form validation: Write a validate_registration(form: &RegistrationForm) -> Result<User, Vec<String>> that validates username length, password strength, and email format simultaneously.
  • Parallel vs sequential: Write the same validation using sequential and_then (stops at first error) and parallel Validation (collects all errors). Demonstrate with an input that has 3 errors.
  • Custom accumulator: Instead of Vec<ValidationError>, use HashMap<String, Vec<String>> as the error type, where keys are field names. This gives per-field error messages.
  • Validate struct: Implement validation for a UserInput { name: String, age: String, email: String } struct, returning all validation errors at once using the accumulative Validation type.
  • Sequence validations: Implement validate_all<T, E: Clone>(validations: Vec<impl Fn() -> Result<T, E>>) -> Result<Vec<T>, Vec<E>> that runs all validations and collects either all successes or all failures.
  • Open Source Repos