ExamplesBy LevelBy TopicLearning Paths
1015 Intermediate

1015-validation-error — Validation Errors

Functional Programming

Tutorial

The Problem

Form validation, data ingestion, and configuration parsing all share a common need: report every error at once rather than stopping at the first one. A user who submits a form with three invalid fields should not have to submit-fix-submit-fix three times. This "accumulate all errors" pattern requires a different data structure than Result<T, E>, which can carry only one error at a time.

The standard approach is to validate each field independently, collect errors into a Vec<FieldError>, and return them together. Libraries like Haskell's Validation type, OCaml's Base.Or_error, and Rust crates like validator formalize this pattern.

🎯 Learning Outcomes

  • • Design a FieldError type that carries both the field name and a human-readable message
  • • Write independent per-field validators that return Vec<FieldError>
  • • Accumulate errors from multiple validators into a single report
  • • Distinguish fail-fast (Result) from accumulate-all (Vec<FieldError>) error handling
  • • Know when each strategy is appropriate in production systems
  • Code Example

    #![allow(clippy::all)]
    // 1015: Validation Errors — Accumulating All Errors
    // Not short-circuiting: collect ALL validation failures
    
    #[derive(Debug, Clone, PartialEq)]
    struct FieldError {
        field: String,
        message: String,
    }
    
    impl std::fmt::Display for FieldError {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "{}: {}", self.field, self.message)
        }
    }
    
    // Approach 1: Collect errors from each validator
    fn validate_name(name: &str) -> Vec<FieldError> {
        let mut errors = Vec::new();
        if name.is_empty() {
            errors.push(FieldError {
                field: "name".into(),
                message: "required".into(),
            });
        }
        if name.len() > 50 {
            errors.push(FieldError {
                field: "name".into(),
                message: "too long".into(),
            });
        }
        errors
    }
    
    fn validate_age(age: i32) -> Vec<FieldError> {
        let mut errors = Vec::new();
        if age < 0 {
            errors.push(FieldError {
                field: "age".into(),
                message: "negative".into(),
            });
        }
        if age > 150 {
            errors.push(FieldError {
                field: "age".into(),
                message: "unreasonable".into(),
            });
        }
        errors
    }
    
    fn validate_email(email: &str) -> Vec<FieldError> {
        let mut errors = Vec::new();
        if email.is_empty() {
            errors.push(FieldError {
                field: "email".into(),
                message: "required".into(),
            });
        }
        if !email.contains('@') {
            errors.push(FieldError {
                field: "email".into(),
                message: "missing @".into(),
            });
        }
        errors
    }
    
    #[derive(Debug, PartialEq)]
    struct ValidForm {
        name: String,
        age: i32,
        email: String,
    }
    
    fn validate_form(name: &str, age: i32, email: &str) -> Result<ValidForm, Vec<FieldError>> {
        let mut errors = Vec::new();
        errors.extend(validate_name(name));
        errors.extend(validate_age(age));
        errors.extend(validate_email(email));
    
        if errors.is_empty() {
            Ok(ValidForm {
                name: name.to_string(),
                age,
                email: email.to_string(),
            })
        } else {
            Err(errors)
        }
    }
    
    // Approach 2: Functional with iterators
    fn validate_field<T>(field: &str, value: &T, checks: &[(fn(&T) -> bool, &str)]) -> Vec<FieldError> {
        checks
            .iter()
            .filter(|(pred, _)| !pred(value))
            .map(|(_, msg)| FieldError {
                field: field.to_string(),
                message: msg.to_string(),
            })
            .collect()
    }
    
    fn validate_form_functional(
        name: &str,
        age: i32,
        email: &str,
    ) -> Result<ValidForm, Vec<FieldError>> {
        let name_checks: Vec<(fn(&&str) -> bool, &str)> = vec![
            (|s: &&str| !s.is_empty(), "required"),
            (|s: &&str| s.len() <= 50, "too long"),
        ];
        let age_checks: Vec<(fn(&i32) -> bool, &str)> = vec![
            (|n: &i32| *n >= 0, "negative"),
            (|n: &i32| *n <= 150, "unreasonable"),
        ];
    
        let errors: Vec<FieldError> = [
            validate_field("name", &name, &name_checks),
            validate_field("age", &age, &age_checks),
            validate_email(email),
        ]
        .concat();
    
        if errors.is_empty() {
            Ok(ValidForm {
                name: name.into(),
                age,
                email: email.into(),
            })
        } else {
            Err(errors)
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_all_errors_collected() {
            let result = validate_form("", -5, "bademail");
            let errors = result.unwrap_err();
            assert!(errors.len() >= 3);
            assert!(errors.iter().any(|e| e.field == "name"));
            assert!(errors.iter().any(|e| e.field == "age"));
            assert!(errors.iter().any(|e| e.field == "email"));
        }
    
        #[test]
        fn test_valid_form() {
            let result = validate_form("Alice", 30, "a@b.com");
            assert!(result.is_ok());
            let form = result.unwrap();
            assert_eq!(form.name, "Alice");
            assert_eq!(form.age, 30);
        }
    
        #[test]
        fn test_single_error() {
            let result = validate_form("Alice", 30, "no-at");
            let errors = result.unwrap_err();
            assert_eq!(errors.len(), 1);
            assert_eq!(errors[0].field, "email");
        }
    
        #[test]
        fn test_functional_approach() {
            let result = validate_form_functional("", -1, "bad");
            assert!(result.is_err());
            assert!(result.unwrap_err().len() >= 3);
    
            let result = validate_form_functional("Bob", 25, "b@c.com");
            assert!(result.is_ok());
        }
    
        #[test]
        fn test_no_short_circuit() {
            // Key: unlike ?, ALL fields are checked even after first error
            let errors = validate_form("", -5, "").unwrap_err();
            // name error + age error + email errors — all present
            assert!(errors.len() >= 4); // empty name + negative age + empty email + missing @
        }
    }

    Key Differences

  • Fail-fast vs accumulate: Result short-circuits at first error; Vec<FieldError> accumulates all. The choice affects API design fundamentally.
  • Composition: Rust validators return Vec and are composed by extending vectors; OCaml functional validators can be composed with applicative combinators.
  • Library support: The Rust ecosystem has validator, garde, and nutype for declarative validation; OCaml has Base.Validate and ppx_jane derivations.
  • Type-level guarantee: Rust's type system can encode "validated" vs "unvalidated" data using the newtype pattern; OCaml uses the same technique with opaque types.
  • OCaml Approach

    OCaml's Base library provides Validate.t for this pattern. Without a library, the same logic uses List.concat_map:

    type field_error = { field: string; message: string }
    
    let validate_all validators input =
      List.concat_map (fun v -> v input) validators
    
    let is_valid errors = errors = []
    

    Functional libraries often use an Applicative functor over a Validation type to compose validators without short-circuiting, analogous to Result's Applicative instance in Haskell.

    Full Source

    #![allow(clippy::all)]
    // 1015: Validation Errors — Accumulating All Errors
    // Not short-circuiting: collect ALL validation failures
    
    #[derive(Debug, Clone, PartialEq)]
    struct FieldError {
        field: String,
        message: String,
    }
    
    impl std::fmt::Display for FieldError {
        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
            write!(f, "{}: {}", self.field, self.message)
        }
    }
    
    // Approach 1: Collect errors from each validator
    fn validate_name(name: &str) -> Vec<FieldError> {
        let mut errors = Vec::new();
        if name.is_empty() {
            errors.push(FieldError {
                field: "name".into(),
                message: "required".into(),
            });
        }
        if name.len() > 50 {
            errors.push(FieldError {
                field: "name".into(),
                message: "too long".into(),
            });
        }
        errors
    }
    
    fn validate_age(age: i32) -> Vec<FieldError> {
        let mut errors = Vec::new();
        if age < 0 {
            errors.push(FieldError {
                field: "age".into(),
                message: "negative".into(),
            });
        }
        if age > 150 {
            errors.push(FieldError {
                field: "age".into(),
                message: "unreasonable".into(),
            });
        }
        errors
    }
    
    fn validate_email(email: &str) -> Vec<FieldError> {
        let mut errors = Vec::new();
        if email.is_empty() {
            errors.push(FieldError {
                field: "email".into(),
                message: "required".into(),
            });
        }
        if !email.contains('@') {
            errors.push(FieldError {
                field: "email".into(),
                message: "missing @".into(),
            });
        }
        errors
    }
    
    #[derive(Debug, PartialEq)]
    struct ValidForm {
        name: String,
        age: i32,
        email: String,
    }
    
    fn validate_form(name: &str, age: i32, email: &str) -> Result<ValidForm, Vec<FieldError>> {
        let mut errors = Vec::new();
        errors.extend(validate_name(name));
        errors.extend(validate_age(age));
        errors.extend(validate_email(email));
    
        if errors.is_empty() {
            Ok(ValidForm {
                name: name.to_string(),
                age,
                email: email.to_string(),
            })
        } else {
            Err(errors)
        }
    }
    
    // Approach 2: Functional with iterators
    fn validate_field<T>(field: &str, value: &T, checks: &[(fn(&T) -> bool, &str)]) -> Vec<FieldError> {
        checks
            .iter()
            .filter(|(pred, _)| !pred(value))
            .map(|(_, msg)| FieldError {
                field: field.to_string(),
                message: msg.to_string(),
            })
            .collect()
    }
    
    fn validate_form_functional(
        name: &str,
        age: i32,
        email: &str,
    ) -> Result<ValidForm, Vec<FieldError>> {
        let name_checks: Vec<(fn(&&str) -> bool, &str)> = vec![
            (|s: &&str| !s.is_empty(), "required"),
            (|s: &&str| s.len() <= 50, "too long"),
        ];
        let age_checks: Vec<(fn(&i32) -> bool, &str)> = vec![
            (|n: &i32| *n >= 0, "negative"),
            (|n: &i32| *n <= 150, "unreasonable"),
        ];
    
        let errors: Vec<FieldError> = [
            validate_field("name", &name, &name_checks),
            validate_field("age", &age, &age_checks),
            validate_email(email),
        ]
        .concat();
    
        if errors.is_empty() {
            Ok(ValidForm {
                name: name.into(),
                age,
                email: email.into(),
            })
        } else {
            Err(errors)
        }
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_all_errors_collected() {
            let result = validate_form("", -5, "bademail");
            let errors = result.unwrap_err();
            assert!(errors.len() >= 3);
            assert!(errors.iter().any(|e| e.field == "name"));
            assert!(errors.iter().any(|e| e.field == "age"));
            assert!(errors.iter().any(|e| e.field == "email"));
        }
    
        #[test]
        fn test_valid_form() {
            let result = validate_form("Alice", 30, "a@b.com");
            assert!(result.is_ok());
            let form = result.unwrap();
            assert_eq!(form.name, "Alice");
            assert_eq!(form.age, 30);
        }
    
        #[test]
        fn test_single_error() {
            let result = validate_form("Alice", 30, "no-at");
            let errors = result.unwrap_err();
            assert_eq!(errors.len(), 1);
            assert_eq!(errors[0].field, "email");
        }
    
        #[test]
        fn test_functional_approach() {
            let result = validate_form_functional("", -1, "bad");
            assert!(result.is_err());
            assert!(result.unwrap_err().len() >= 3);
    
            let result = validate_form_functional("Bob", 25, "b@c.com");
            assert!(result.is_ok());
        }
    
        #[test]
        fn test_no_short_circuit() {
            // Key: unlike ?, ALL fields are checked even after first error
            let errors = validate_form("", -5, "").unwrap_err();
            // name error + age error + email errors — all present
            assert!(errors.len() >= 4); // empty name + negative age + empty email + missing @
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_all_errors_collected() {
            let result = validate_form("", -5, "bademail");
            let errors = result.unwrap_err();
            assert!(errors.len() >= 3);
            assert!(errors.iter().any(|e| e.field == "name"));
            assert!(errors.iter().any(|e| e.field == "age"));
            assert!(errors.iter().any(|e| e.field == "email"));
        }
    
        #[test]
        fn test_valid_form() {
            let result = validate_form("Alice", 30, "a@b.com");
            assert!(result.is_ok());
            let form = result.unwrap();
            assert_eq!(form.name, "Alice");
            assert_eq!(form.age, 30);
        }
    
        #[test]
        fn test_single_error() {
            let result = validate_form("Alice", 30, "no-at");
            let errors = result.unwrap_err();
            assert_eq!(errors.len(), 1);
            assert_eq!(errors[0].field, "email");
        }
    
        #[test]
        fn test_functional_approach() {
            let result = validate_form_functional("", -1, "bad");
            assert!(result.is_err());
            assert!(result.unwrap_err().len() >= 3);
    
            let result = validate_form_functional("Bob", 25, "b@c.com");
            assert!(result.is_ok());
        }
    
        #[test]
        fn test_no_short_circuit() {
            // Key: unlike ?, ALL fields are checked even after first error
            let errors = validate_form("", -5, "").unwrap_err();
            // name error + age error + email errors — all present
            assert!(errors.len() >= 4); // empty name + negative age + empty email + missing @
        }
    }

    Deep Comparison

    Validation Errors — Comparison

    Core Insight

    Standard Result + ? short-circuits at the first error. Validation needs to report ALL errors. Both languages solve this by collecting errors into a list.

    OCaml Approach

  • • Each validator returns field_error list (empty = valid)
  • • Concatenate with @ operator
  • • Applicative-style: validate independently, merge error lists
  • Rust Approach

  • • Each validator returns Vec<FieldError> (empty = valid)
  • extend() to accumulate across validators
  • • Functional: filter + map + collect for rule-based checks
  • Comparison Table

    AspectOCamlRust
    Error accumulatorerror listVec<Error>
    Concatenation@ (list append)extend() / concat()
    Per-field validatorsReturn [] \| [err]Return Vec::new() \| vec![err]
    Short-circuit optionResult.bind / let*? operator
    Non-short-circuitCollect listsCollect Vecs
    LibrariesCustomvalidator crate

    Exercises

  • Add an email field to the validated struct with a validator that checks for the presence of @ and a non-empty domain.
  • Write a validate_all function that takes a list of validators and returns Ok(()) if all pass, or Err(Vec<FieldError>) if any fail.
  • Implement a Validated<T> newtype wrapper that can only be constructed by a successful validation, preventing accidental use of unvalidated data.
  • Open Source Repos