ExamplesBy LevelBy TopicLearning Paths
853 Intermediate

Applicative Validation

Functional Programming

Tutorial

The Problem

Result short-circuits on the first error — useful for computations where later steps depend on earlier ones, but poor for user input validation where you want to report all errors at once. If a signup form has invalid email AND weak password, showing only the first error forces users to resubmit multiple times. The Validated type accumulates all errors instead of short-circuiting. This is the applicative approach: both validations run independently and their errors are combined. In contrast to monadic and_then which chains dependent operations, applicative validation runs all checks in parallel and collects all failures. This pattern is used in form validation libraries, configuration parsers, and data pipeline error reporting.

🎯 Learning Outcomes

  • • Implement Validated<T, E> with Valid(T) and Invalid(Vec<E>) variants
  • • Implement apply that combines two Validated values: both must be Valid to succeed; errors accumulate
  • • Contrast with Result::and_then which short-circuits at the first Err
  • • Apply to form validation: validate name, email, and age independently, report all errors
  • • Recognize the semigroup constraint: errors must be combinable (Vec<E> is a natural semigroup)
  • Code Example

    enum Validated<T, E> {
        Valid(T),
        Invalid(Vec<E>),
    }

    Key Differences

    AspectRustOCaml
    Error accumulationVec::extendList.(@) append
    Both invalidextend merges vectors@ concatenates lists
    Apply signature.apply(vf: Validated<F,E>)let apply vf vx
    Form validationvalidate().apply(validate()).apply(...)va \|> apply vb \|> apply vc
    Error typeVec<E>'e list
    vs. ResultShort-circuitsThis accumulates

    OCaml Approach

    OCaml defines type ('a, 'e) validated = Valid of 'a | Invalid of 'e list. The apply function: let apply vf vx = match vf, vx with Valid f, Valid x -> Valid (f x) | Invalid e1, Invalid e2 -> Invalid (e1 @ e2) | Invalid e, _ | _, Invalid e -> Invalid e. The @ operator appends lists. OCaml's List.concat merges multiple error lists. Form validation: validate_name name |> apply (validate_email email) |> apply (validate_age age) runs all validations and combines errors. The Alcotest library uses similar validation for test result accumulation.

    Full Source

    #![allow(clippy::all)]
    // Example 054: Applicative Validation
    // Accumulate ALL errors instead of short-circuiting on first
    
    #[derive(Debug, PartialEq, Clone)]
    enum Validated<T, E> {
        Valid(T),
        Invalid(Vec<E>),
    }
    
    impl<T, E> Validated<T, E> {
        fn pure(x: T) -> Self {
            Validated::Valid(x)
        }
    
        fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Validated<U, E> {
            match self {
                Validated::Valid(x) => Validated::Valid(f(x)),
                Validated::Invalid(es) => Validated::Invalid(es),
            }
        }
    }
    
    // Approach 1: Apply that accumulates errors
    fn apply<A, B, E, F: FnOnce(A) -> B>(vf: Validated<F, E>, va: Validated<A, E>) -> Validated<B, E> {
        match (vf, va) {
            (Validated::Valid(f), Validated::Valid(a)) => Validated::Valid(f(a)),
            (Validated::Invalid(mut e1), Validated::Invalid(e2)) => {
                e1.extend(e2);
                Validated::Invalid(e1)
            }
            (Validated::Invalid(e), _) | (_, Validated::Invalid(e)) => Validated::Invalid(e),
        }
    }
    
    // Approach 2: lift2/lift3 for validation
    fn lift2<A, B, C, E, F: FnOnce(A, B) -> C>(
        f: F,
        a: Validated<A, E>,
        b: Validated<B, E>,
    ) -> Validated<C, E> {
        match (a, b) {
            (Validated::Valid(a), Validated::Valid(b)) => Validated::Valid(f(a, b)),
            (Validated::Invalid(mut e1), Validated::Invalid(e2)) => {
                e1.extend(e2);
                Validated::Invalid(e1)
            }
            (Validated::Invalid(e), _) | (_, Validated::Invalid(e)) => Validated::Invalid(e),
        }
    }
    
    fn lift3<A, B, C, D, E, F: FnOnce(A, B, C) -> D>(
        f: F,
        a: Validated<A, E>,
        b: Validated<B, E>,
        c: Validated<C, E>,
    ) -> Validated<D, E> {
        let mut errors = Vec::new();
        let a = match a {
            Validated::Valid(v) => Some(v),
            Validated::Invalid(e) => {
                errors.extend(e);
                None
            }
        };
        let b = match b {
            Validated::Valid(v) => Some(v),
            Validated::Invalid(e) => {
                errors.extend(e);
                None
            }
        };
        let c = match c {
            Validated::Valid(v) => Some(v),
            Validated::Invalid(e) => {
                errors.extend(e);
                None
            }
        };
        if errors.is_empty() {
            Validated::Valid(f(a.unwrap(), b.unwrap(), c.unwrap()))
        } else {
            Validated::Invalid(errors)
        }
    }
    
    // Approach 3: Validate a user record
    #[derive(Debug, PartialEq)]
    struct User {
        name: String,
        age: i32,
        email: String,
    }
    
    fn validate_name(s: &str) -> Validated<String, String> {
        if !s.is_empty() {
            Validated::Valid(s.to_string())
        } else {
            Validated::Invalid(vec!["Name cannot be empty".to_string()])
        }
    }
    
    fn validate_age(n: i32) -> Validated<i32, String> {
        if (0..=150).contains(&n) {
            Validated::Valid(n)
        } else {
            Validated::Invalid(vec!["Age must be between 0 and 150".to_string()])
        }
    }
    
    fn validate_email(s: &str) -> Validated<String, String> {
        if s.contains('@') {
            Validated::Valid(s.to_string())
        } else {
            Validated::Invalid(vec!["Email must contain @".to_string()])
        }
    }
    
    fn validate_user(name: &str, age: i32, email: &str) -> Validated<User, String> {
        lift3(
            |name, age, email| User { name, age, email },
            validate_name(name),
            validate_age(age),
            validate_email(email),
        )
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_user() {
            let u = validate_user("Alice", 30, "alice@example.com");
            assert_eq!(
                u,
                Validated::Valid(User {
                    name: "Alice".into(),
                    age: 30,
                    email: "alice@example.com".into(),
                })
            );
        }
    
        #[test]
        fn test_single_error() {
            let u = validate_user("", 30, "alice@example.com");
            assert_eq!(u, Validated::Invalid(vec!["Name cannot be empty".into()]));
        }
    
        #[test]
        fn test_all_errors_accumulated() {
            let u = validate_user("", -5, "bad");
            match u {
                Validated::Invalid(errors) => {
                    assert_eq!(errors.len(), 3);
                    assert!(errors[0].contains("Name"));
                    assert!(errors[1].contains("Age"));
                    assert!(errors[2].contains("Email"));
                }
                _ => panic!("Expected Invalid"),
            }
        }
    
        #[test]
        fn test_lift2_both_valid() {
            let r = lift2(
                |a, b| a + b,
                Validated::<i32, &str>::Valid(1),
                Validated::Valid(2),
            );
            assert_eq!(r, Validated::Valid(3));
        }
    
        #[test]
        fn test_lift2_errors_accumulated() {
            let r = lift2(
                |a: i32, b: i32| a + b,
                Validated::Invalid(vec!["e1"]),
                Validated::Invalid(vec!["e2"]),
            );
            assert_eq!(r, Validated::Invalid(vec!["e1", "e2"]));
        }
    
        #[test]
        fn test_apply_accumulates() {
            let vf: Validated<fn(i32) -> i32, &str> = Validated::Invalid(vec!["e1"]);
            let va: Validated<i32, &str> = Validated::Invalid(vec!["e2"]);
            let r = apply(vf, va);
            assert_eq!(r, Validated::Invalid(vec!["e1", "e2"]));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_valid_user() {
            let u = validate_user("Alice", 30, "alice@example.com");
            assert_eq!(
                u,
                Validated::Valid(User {
                    name: "Alice".into(),
                    age: 30,
                    email: "alice@example.com".into(),
                })
            );
        }
    
        #[test]
        fn test_single_error() {
            let u = validate_user("", 30, "alice@example.com");
            assert_eq!(u, Validated::Invalid(vec!["Name cannot be empty".into()]));
        }
    
        #[test]
        fn test_all_errors_accumulated() {
            let u = validate_user("", -5, "bad");
            match u {
                Validated::Invalid(errors) => {
                    assert_eq!(errors.len(), 3);
                    assert!(errors[0].contains("Name"));
                    assert!(errors[1].contains("Age"));
                    assert!(errors[2].contains("Email"));
                }
                _ => panic!("Expected Invalid"),
            }
        }
    
        #[test]
        fn test_lift2_both_valid() {
            let r = lift2(
                |a, b| a + b,
                Validated::<i32, &str>::Valid(1),
                Validated::Valid(2),
            );
            assert_eq!(r, Validated::Valid(3));
        }
    
        #[test]
        fn test_lift2_errors_accumulated() {
            let r = lift2(
                |a: i32, b: i32| a + b,
                Validated::Invalid(vec!["e1"]),
                Validated::Invalid(vec!["e2"]),
            );
            assert_eq!(r, Validated::Invalid(vec!["e1", "e2"]));
        }
    
        #[test]
        fn test_apply_accumulates() {
            let vf: Validated<fn(i32) -> i32, &str> = Validated::Invalid(vec!["e1"]);
            let va: Validated<i32, &str> = Validated::Invalid(vec!["e2"]);
            let r = apply(vf, va);
            assert_eq!(r, Validated::Invalid(vec!["e1", "e2"]));
        }
    }

    Deep Comparison

    Comparison: Applicative Validation

    Validated Type

    OCaml:

    type ('a, 'e) validated =
      | Valid of 'a
      | Invalid of 'e list
    

    Rust:

    enum Validated<T, E> {
        Valid(T),
        Invalid(Vec<E>),
    }
    

    Error-Accumulating Apply

    OCaml:

    let apply vf vx = match vf, vx with
      | Valid f, Valid x -> Valid (f x)
      | Invalid e1, Invalid e2 -> Invalid (e1 @ e2)  (* accumulate! *)
      | Invalid e, _ | _, Invalid e -> Invalid e
    

    Rust:

    fn apply<A, B, E, F: FnOnce(A) -> B>(vf: Validated<F, E>, va: Validated<A, E>) -> Validated<B, E> {
        match (vf, va) {
            (Validated::Valid(f), Validated::Valid(a)) => Validated::Valid(f(a)),
            (Validated::Invalid(mut e1), Validated::Invalid(e2)) => {
                e1.extend(e2);  // accumulate!
                Validated::Invalid(e1)
            }
            (Validated::Invalid(e), _) | (_, Validated::Invalid(e)) => Validated::Invalid(e),
        }
    }
    

    Validating a Record

    OCaml:

    let validate_user name age email =
      pure make_user <*> validate_name name <*> validate_age age <*> validate_email email
    (* All three validations run independently *)
    

    Rust:

    fn validate_user(name: &str, age: i32, email: &str) -> Validated<User, String> {
        lift3(
            |name, age, email| User { name, age, email },
            validate_name(name),
            validate_age(age),
            validate_email(email),
        )
    }
    

    Exercises

  • Implement form validation for a user signup struct with name, email, password, and age fields — report all errors.
  • Implement validate_all(validations: Vec<Validated<T, E>>) -> Validated<Vec<T>, E> using apply.
  • Show that Validated::apply satisfies the applicative identity law: Valid(|x| x).apply(vx) == vx.
  • Implement a parser combinator using Validated that runs multiple field parsers and collects all parse errors.
  • Compare the user experience: validate a form with Result (first-error-only) and Validated (all-errors) and show the difference in error output.
  • Open Source Repos