ExamplesBy LevelBy TopicLearning Paths
072 Intermediate

072 — Error Accumulation (Validation Type)

Functional Programming

Tutorial Video

Text description (accessibility)

This video demonstrates the "072 — Error Accumulation (Validation Type)" functional Rust example. Difficulty level: Intermediate. Key concepts covered: Functional Programming. When validating user input, configuration files, or CSV rows, you typically want to report ALL errors at once — not just the first. Key difference from OCaml: 1. **Not a `Result`**: `Validation` and `Result` have different semantics despite similar structure. `Result::and_then` is sequential (second step sees first result). `Validation::apply` is parallel (both sides run independently).

Tutorial

The Problem

When validating user input, configuration files, or CSV rows, you typically want to report ALL errors at once — not just the first. A form with five bad fields should show five error messages, not just the first one and stop. This is where Result's short-circuit behavior becomes a liability: and_then stops at the first Err, losing all subsequent errors.

This example implements a custom Validation<T, E> type that accumulates errors rather than short-circuiting. The distinction is fundamental in category theory: Result is a monad (sequential, errors short-circuit), Validation is an applicative functor (parallel, errors accumulate). You cannot derive Validation from Result without sacrificing the independent-error property.

The Validation pattern originates in Haskell's Data.Validation (also Validation in the validation crate), Scala's cats.data.Validated, and F#'s Result<_,_list>. It is essential whenever errors are independent and all should be reported: web form validation, API request validation, CSV import verification, and configuration parsing are the canonical use cases.

🎯 Learning Outcomes

  • • Define a Validation<T, E> enum as a first-class type (not using Result)
  • • Implement map (functor) for transforming the success value
  • • Implement apply (applicative) for combining two Validation values, merging error lists
  • • Understand why Validation is not a monad (second step cannot depend on first result)
  • • Connect to the applicative functor laws
  • Code Example

    #![allow(clippy::all)]
    // Error accumulation: collect ALL errors, not just the first.
    // Unlike Result which short-circuits, Validation gathers a list of errors.
    // Models the Applicative Validation pattern from functional programming.
    
    /// Solution 1: Idiomatic Rust — enum that mirrors the OCaml `validation` type.
    #[derive(Debug, PartialEq)]
    pub enum Validation<T, E> {
        Ok(T),
        Errors(Vec<E>),
    }
    
    impl<T, E> Validation<T, E> {
        pub fn ok(value: T) -> Self {
            Validation::Ok(value)
        }
    
        /// Functor: transform the success value.
        pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Validation<U, E> {
            match self {
                Validation::Ok(x) => Validation::Ok(f(x)),
                Validation::Errors(es) => Validation::Errors(es),
            }
        }
    
        /// Applicative apply: combine errors from both sides.
        pub fn apply<U, F>(self, arg: Validation<T, E>) -> Validation<U, E>
        where
            Self: Into<Validation<F, E>>,
            F: FnOnce(T) -> U,
        {
            match (self.into(), arg) {
                (Validation::Ok(f), Validation::Ok(x)) => Validation::Ok(f(x)),
                (Validation::Ok(_), Validation::Errors(es)) => Validation::Errors(es),
                (Validation::Errors(es), Validation::Ok(_)) => Validation::Errors(es),
                (Validation::Errors(mut e1), Validation::Errors(e2)) => {
                    e1.extend(e2);
                    Validation::Errors(e1)
                }
            }
        }
    }
    
    // ---------------------------------------------------------------------------
    // Validators (mirroring the OCaml example)
    // ---------------------------------------------------------------------------
    
    pub fn validate_name(s: &str) -> Validation<&str, String> {
        if !s.is_empty() {
            Validation::Ok(s)
        } else {
            Validation::Errors(vec!["name cannot be empty".to_string()])
        }
    }
    
    pub fn validate_age(n: i32) -> Validation<i32, String> {
        if (18..=120).contains(&n) {
            Validation::Ok(n)
        } else {
            Validation::Errors(vec![format!("age {n} out of range (18-120)")])
        }
    }
    
    pub fn validate_email(s: &str) -> Validation<&str, String> {
        if s.contains('@') {
            Validation::Ok(s)
        } else {
            Validation::Errors(vec!["email must contain @".to_string()])
        }
    }
    
    /// Solution 2: Functional style — accumulate using a fold over validators.
    pub fn accumulate<T: Clone, E: Clone>(
        validators: &[fn(T) -> Validation<T, E>],
        input: T,
    ) -> Validation<T, E> {
        let mut errors: Vec<E> = vec![];
        let mut last_ok: Option<T> = None;
    
        for validator in validators {
            match validator(input.clone()) {
                Validation::Ok(v) => last_ok = Some(v),
                Validation::Errors(es) => errors.extend(es),
            }
        }
    
        if errors.is_empty() {
            Validation::Ok(last_ok.unwrap())
        } else {
            Validation::Errors(errors)
        }
    }
    
    // ---------------------------------------------------------------------------
    // Tests
    // ---------------------------------------------------------------------------
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_validate_name_valid() {
            assert_eq!(validate_name("Alice"), Validation::Ok("Alice"));
        }
    
        #[test]
        fn test_validate_name_empty() {
            assert_eq!(
                validate_name(""),
                Validation::Errors(vec!["name cannot be empty".to_string()])
            );
        }
    
        #[test]
        fn test_validate_age_valid() {
            assert_eq!(validate_age(30), Validation::Ok(30));
        }
    
        #[test]
        fn test_validate_age_too_young() {
            assert_eq!(
                validate_age(15),
                Validation::Errors(vec!["age 15 out of range (18-120)".to_string()])
            );
        }
    
        #[test]
        fn test_validate_email_valid() {
            assert_eq!(
                validate_email("alice@example.com"),
                Validation::Ok("alice@example.com")
            );
        }
    
        #[test]
        fn test_validate_email_invalid() {
            assert_eq!(
                validate_email("bad-email"),
                Validation::Errors(vec!["email must contain @".to_string()])
            );
        }
    
        #[test]
        fn test_errors_accumulate() {
            // All three validators fail — all three errors must be collected.
            let name_err = validate_name("");
            let age_err = validate_age(15);
            let email_err = validate_email("bad-email");
    
            let mut all_errors: Vec<String> = vec![];
            if let Validation::Errors(es) = name_err {
                all_errors.extend(es);
            }
            if let Validation::Errors(es) = age_err {
                all_errors.extend(es);
            }
            if let Validation::Errors(es) = email_err {
                all_errors.extend(es);
            }
    
            assert_eq!(all_errors.len(), 3);
            assert!(all_errors[0].contains("name"));
            assert!(all_errors[1].contains("age"));
            assert!(all_errors[2].contains("email"));
        }
    
        #[test]
        fn test_map_ok() {
            let v: Validation<i32, String> = Validation::Ok(3);
            assert_eq!(v.map(|x| x * 2), Validation::Ok(6));
        }
    }

    Key Differences

  • **Not a Result**: Validation and Result have different semantics despite similar structure. Result::and_then is sequential (second step sees first result). Validation::apply is parallel (both sides run independently).
  • **Vec<E> vs E**: Validation collects errors in Vec<E>. Result carries a single E. The vector accumulation is what enables reporting multiple errors.
  • **No and_then**: Validation intentionally does not have and_then (monadic bind) — implementing it would require making the second step depend on the first, losing the ability to accumulate errors from the second step when the first fails.
  • **apply complexity**: The apply method requires Self: Into<Validation<F, E>> — a somewhat awkward bound in Rust due to the function being inside a Validation. Practice using it with concrete types first.
  • OCaml Approach

    OCaml defines the Validation type as a polymorphic variant:

    type ('a, 'e) validation = Ok of 'a | Errors of 'e list
    
    let combine v1 v2 = match v1, v2 with
      | Ok a, Ok b     -> Ok (a, b)
      | Ok _, Errors e -> Errors e
      | Errors e, Ok _ -> Errors e
      | Errors e1, Errors e2 -> Errors (e1 @ e2)
    

    The key property: Errors(e1) combine Errors(e2) = Errors(e1 @ e2) — both error lists are concatenated, not just one kept. This is what distinguishes Validation from Result at the semantic level.

    Full Source

    #![allow(clippy::all)]
    // Error accumulation: collect ALL errors, not just the first.
    // Unlike Result which short-circuits, Validation gathers a list of errors.
    // Models the Applicative Validation pattern from functional programming.
    
    /// Solution 1: Idiomatic Rust — enum that mirrors the OCaml `validation` type.
    #[derive(Debug, PartialEq)]
    pub enum Validation<T, E> {
        Ok(T),
        Errors(Vec<E>),
    }
    
    impl<T, E> Validation<T, E> {
        pub fn ok(value: T) -> Self {
            Validation::Ok(value)
        }
    
        /// Functor: transform the success value.
        pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Validation<U, E> {
            match self {
                Validation::Ok(x) => Validation::Ok(f(x)),
                Validation::Errors(es) => Validation::Errors(es),
            }
        }
    
        /// Applicative apply: combine errors from both sides.
        pub fn apply<U, F>(self, arg: Validation<T, E>) -> Validation<U, E>
        where
            Self: Into<Validation<F, E>>,
            F: FnOnce(T) -> U,
        {
            match (self.into(), arg) {
                (Validation::Ok(f), Validation::Ok(x)) => Validation::Ok(f(x)),
                (Validation::Ok(_), Validation::Errors(es)) => Validation::Errors(es),
                (Validation::Errors(es), Validation::Ok(_)) => Validation::Errors(es),
                (Validation::Errors(mut e1), Validation::Errors(e2)) => {
                    e1.extend(e2);
                    Validation::Errors(e1)
                }
            }
        }
    }
    
    // ---------------------------------------------------------------------------
    // Validators (mirroring the OCaml example)
    // ---------------------------------------------------------------------------
    
    pub fn validate_name(s: &str) -> Validation<&str, String> {
        if !s.is_empty() {
            Validation::Ok(s)
        } else {
            Validation::Errors(vec!["name cannot be empty".to_string()])
        }
    }
    
    pub fn validate_age(n: i32) -> Validation<i32, String> {
        if (18..=120).contains(&n) {
            Validation::Ok(n)
        } else {
            Validation::Errors(vec![format!("age {n} out of range (18-120)")])
        }
    }
    
    pub fn validate_email(s: &str) -> Validation<&str, String> {
        if s.contains('@') {
            Validation::Ok(s)
        } else {
            Validation::Errors(vec!["email must contain @".to_string()])
        }
    }
    
    /// Solution 2: Functional style — accumulate using a fold over validators.
    pub fn accumulate<T: Clone, E: Clone>(
        validators: &[fn(T) -> Validation<T, E>],
        input: T,
    ) -> Validation<T, E> {
        let mut errors: Vec<E> = vec![];
        let mut last_ok: Option<T> = None;
    
        for validator in validators {
            match validator(input.clone()) {
                Validation::Ok(v) => last_ok = Some(v),
                Validation::Errors(es) => errors.extend(es),
            }
        }
    
        if errors.is_empty() {
            Validation::Ok(last_ok.unwrap())
        } else {
            Validation::Errors(errors)
        }
    }
    
    // ---------------------------------------------------------------------------
    // Tests
    // ---------------------------------------------------------------------------
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_validate_name_valid() {
            assert_eq!(validate_name("Alice"), Validation::Ok("Alice"));
        }
    
        #[test]
        fn test_validate_name_empty() {
            assert_eq!(
                validate_name(""),
                Validation::Errors(vec!["name cannot be empty".to_string()])
            );
        }
    
        #[test]
        fn test_validate_age_valid() {
            assert_eq!(validate_age(30), Validation::Ok(30));
        }
    
        #[test]
        fn test_validate_age_too_young() {
            assert_eq!(
                validate_age(15),
                Validation::Errors(vec!["age 15 out of range (18-120)".to_string()])
            );
        }
    
        #[test]
        fn test_validate_email_valid() {
            assert_eq!(
                validate_email("alice@example.com"),
                Validation::Ok("alice@example.com")
            );
        }
    
        #[test]
        fn test_validate_email_invalid() {
            assert_eq!(
                validate_email("bad-email"),
                Validation::Errors(vec!["email must contain @".to_string()])
            );
        }
    
        #[test]
        fn test_errors_accumulate() {
            // All three validators fail — all three errors must be collected.
            let name_err = validate_name("");
            let age_err = validate_age(15);
            let email_err = validate_email("bad-email");
    
            let mut all_errors: Vec<String> = vec![];
            if let Validation::Errors(es) = name_err {
                all_errors.extend(es);
            }
            if let Validation::Errors(es) = age_err {
                all_errors.extend(es);
            }
            if let Validation::Errors(es) = email_err {
                all_errors.extend(es);
            }
    
            assert_eq!(all_errors.len(), 3);
            assert!(all_errors[0].contains("name"));
            assert!(all_errors[1].contains("age"));
            assert!(all_errors[2].contains("email"));
        }
    
        #[test]
        fn test_map_ok() {
            let v: Validation<i32, String> = Validation::Ok(3);
            assert_eq!(v.map(|x| x * 2), Validation::Ok(6));
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_validate_name_valid() {
            assert_eq!(validate_name("Alice"), Validation::Ok("Alice"));
        }
    
        #[test]
        fn test_validate_name_empty() {
            assert_eq!(
                validate_name(""),
                Validation::Errors(vec!["name cannot be empty".to_string()])
            );
        }
    
        #[test]
        fn test_validate_age_valid() {
            assert_eq!(validate_age(30), Validation::Ok(30));
        }
    
        #[test]
        fn test_validate_age_too_young() {
            assert_eq!(
                validate_age(15),
                Validation::Errors(vec!["age 15 out of range (18-120)".to_string()])
            );
        }
    
        #[test]
        fn test_validate_email_valid() {
            assert_eq!(
                validate_email("alice@example.com"),
                Validation::Ok("alice@example.com")
            );
        }
    
        #[test]
        fn test_validate_email_invalid() {
            assert_eq!(
                validate_email("bad-email"),
                Validation::Errors(vec!["email must contain @".to_string()])
            );
        }
    
        #[test]
        fn test_errors_accumulate() {
            // All three validators fail — all three errors must be collected.
            let name_err = validate_name("");
            let age_err = validate_age(15);
            let email_err = validate_email("bad-email");
    
            let mut all_errors: Vec<String> = vec![];
            if let Validation::Errors(es) = name_err {
                all_errors.extend(es);
            }
            if let Validation::Errors(es) = age_err {
                all_errors.extend(es);
            }
            if let Validation::Errors(es) = email_err {
                all_errors.extend(es);
            }
    
            assert_eq!(all_errors.len(), 3);
            assert!(all_errors[0].contains("name"));
            assert!(all_errors[1].contains("age"));
            assert!(all_errors[2].contains("email"));
        }
    
        #[test]
        fn test_map_ok() {
            let v: Validation<i32, String> = Validation::Ok(3);
            assert_eq!(v.map(|x| x * 2), Validation::Ok(6));
        }
    }

    Deep Comparison

    OCaml vs Rust: Error Accumulation

    Overview

    See the example.rs and example.ml files for detailed implementations.

    Key Differences

    AspectOCamlRust
    Type systemHindley-MilnerOwnership + traits
    MemoryGCZero-cost abstractions
    MutabilityExplicit refmut keyword
    Error handlingOption/ResultResult<T, E>

    See README.md for detailed comparison.

    Exercises

  • Validated constructor: Write validated_person(name: &str, age: i32, email: &str) -> Validation<Person, ValidationError> that validates all three fields simultaneously and accumulates errors.
  • Map2: Write map2<A, B, C, E>(va: Validation<A, E>, vb: Validation<B, E>, f: impl FnOnce(A, B) -> C) -> Validation<C, E> as a higher-level combinator over apply.
  • List validation: Write validate_all<T, E, F>(items: &[T], validate: F) -> Validation<Vec<T>, E> where validate: Fn(&T) -> Validation<T, E>. Return all validation errors across all items.
  • Open Source Repos