Applicative Validation
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
Validated<T, E> with Valid(T) and Invalid(Vec<E>) variantsapply that combines two Validated values: both must be Valid to succeed; errors accumulateResult::and_then which short-circuits at the first ErrVec<E> is a natural semigroup)Code Example
enum Validated<T, E> {
Valid(T),
Invalid(Vec<E>),
}Key Differences
| Aspect | Rust | OCaml |
|---|---|---|
| Error accumulation | Vec::extend | List.(@) append |
| Both invalid | extend merges vectors | @ concatenates lists |
| Apply signature | .apply(vf: Validated<F,E>) | let apply vf vx |
| Form validation | validate().apply(validate()).apply(...) | va \|> apply vb \|> apply vc |
| Error type | Vec<E> | 'e list |
| vs. Result | Short-circuits | This 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"]));
}
}#[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
validate_all(validations: Vec<Validated<T, E>>) -> Validated<Vec<T>, E> using apply.Validated::apply satisfies the applicative identity law: Valid(|x| x).apply(vx) == vx.Validated that runs multiple field parsers and collects all parse errors.Result (first-error-only) and Validated (all-errors) and show the difference in error output.