054 — Applicative Validation
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
Result<T, Vec<E>>f <*> a where both f and a may have errorsand_then) because the second step does not depend on the firstValidation<T, E> type with combine that merges Vec<E> error listsCode 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
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.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.garde and validator crates implement applicative validation for Rust structs via derive macros. Understanding the manual implementation explains what these macros generate.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>.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);
}
}#[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
validated type: Valid of 'a | Invalid of 'e listRust Approach
Vec<String> or custom error enumvalidator exist, but std approach works fineComparison Table
| Feature | OCaml | Rust |
|---|---|---|
| Fail-fast | Result + bind | Result + ? |
| Collect all | Custom validated type | Vec<Error> accumulation |
| Combine | Manual applicative | .iter().filter_map() |
Exercises
validate_registration(form: &RegistrationForm) -> Result<User, Vec<String>> that validates username length, password strength, and email format simultaneously.and_then (stops at first error) and parallel Validation (collects all errors). Demonstrate with an input that has 3 errors.Vec<ValidationError>, use HashMap<String, Vec<String>> as the error type, where keys are field names. This gives per-field error messages.UserInput { name: String, age: String, email: String } struct, returning all validation errors at once using the accumulative Validation type.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.