1015-validation-error — Validation Errors
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
FieldError type that carries both the field name and a human-readable messageVec<FieldError>Result) from accumulate-all (Vec<FieldError>) error handlingCode 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
Result short-circuits at first error; Vec<FieldError> accumulates all. The choice affects API design fundamentally.Vec and are composed by extending vectors; OCaml functional validators can be composed with applicative combinators.validator, garde, and nutype for declarative validation; OCaml has Base.Validate and ppx_jane derivations.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 @
}
}#[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
field_error list (empty = valid)@ operatorRust Approach
Vec<FieldError> (empty = valid)extend() to accumulate across validatorsfilter + map + collect for rule-based checksComparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Error accumulator | error list | Vec<Error> |
| Concatenation | @ (list append) | extend() / concat() |
| Per-field validators | Return [] \| [err] | Return Vec::new() \| vec![err] |
| Short-circuit option | Result.bind / let* | ? operator |
| Non-short-circuit | Collect lists | Collect Vecs |
| Libraries | Custom | validator crate |
Exercises
email field to the validated struct with a validator that checks for the presence of @ and a non-empty domain.validate_all function that takes a list of validators and returns Ok(()) if all pass, or Err(Vec<FieldError>) if any fail.Validated<T> newtype wrapper that can only be constructed by a successful validation, preventing accidental use of unvalidated data.