Validation with Error Accumulation
The Problem
Standard Result types short-circuit on the first error:
#![allow(unused)]
fn main() {
fn validate_form(data: FormData) -> Result<ValidForm, Error> {
let email = validate_email(data.email)?; // ❌ Stops here if invalid
let password = validate_password(data.pwd)?; // Never reached
let age = validate_age(data.age)?; // Never reached
Ok(ValidForm { email, password, age })
}
}
If the email is invalid, the user doesn’t learn about password or age errors. They have to submit the form multiple times, fixing one error at a time. Frustrating!
The Solution: Validation
Stillwater’s Validation type accumulates ALL errors:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
fn validate_form(data: FormData) -> Validation<ValidForm, Vec<Error>> {
Validation::all((
validate_email(data.email),
validate_password(data.pwd),
validate_age(data.age),
))
.map(|(email, password, age)| ValidForm { email, password, age })
}
}
Now all three validations run, and the user sees all errors at once!
Core API
Creating Validations
#![allow(unused)]
fn main() {
use stillwater::Validation;
// Success
let v = Validation::success(42);
// Failure
let v = Validation::failure(vec!["error"]);
// From Result
let v = Validation::from_result(Ok(42));
let v = Validation::from_result(Err("error"));
}
Pattern Matching
#![allow(unused)]
fn main() {
use stillwater::Validation;
match validation {
Validation::Success(value) => println!("Valid: {}", value),
Validation::Failure(errors) => println!("Errors: {:?}", errors),
}
}
Checking Status
#![allow(unused)]
fn main() {
use stillwater::Validation;
let v = Validation::success(42);
assert!(v.is_success());
assert!(!v.is_failure());
let v = Validation::failure(vec!["error"]);
assert!(!v.is_success());
assert!(v.is_failure());
}
Combining Validations
#![allow(unused)]
fn main() {
use stillwater::Validation;
// Combine with tuples (up to 12 items)
let result = Validation::all((
validate_email(email),
validate_password(password),
validate_age(age),
));
// Combine Vec of same type
let result = Validation::all_vec(vec![
validate_item(item1),
validate_item(item2),
validate_item(item3),
]);
}
Transforming Validations
#![allow(unused)]
fn main() {
use stillwater::Validation;
// Transform success value
let v = Validation::success(21);
let doubled = v.map(|x| x * 2);
assert_eq!(doubled, Validation::success(42));
// Transform error value
let v = Validation::failure(vec!["oops"]);
let formatted = v.map_err(|e| format!("Error: {:?}", e));
// Chain dependent validation
let result = validate_email(email)
.and_then(|email| check_email_available(email));
}
Validation Combinators
Stillwater provides declarative validation combinators that eliminate verbose and_then boilerplate:
Using ensure() with Predicates
The ensure() method validates a success value using composable predicates from the predicate module:
#![allow(unused)]
fn main() {
use stillwater::{Validation, predicate::*};
// Single validation
let result = Validation::success(String::from("hello"))
.ensure(len_min(3), "too short");
assert_eq!(result, Validation::Success(String::from("hello")));
let result = Validation::success(String::from("hi"))
.ensure(len_min(3), "too short");
assert_eq!(result, Validation::Failure("too short"));
// Chain multiple validations
let result = Validation::success(String::from("hello"))
.ensure(len_min(3), "too short")
.ensure(len_max(10), "too long")
.ensure(is_alphabetic(), "must be alphabetic");
// Result: Success("hello")
let result = Validation::success(String::from("hello123"))
.ensure(len_min(3), "too short") // passes
.ensure(len_max(10), "too long") // passes
.ensure(is_alphabetic(), "not alpha"); // fails
// Result: Failure("not alpha")
}
Using ensure_fn() with Closures
For inline predicates, use ensure_fn():
#![allow(unused)]
fn main() {
use stillwater::Validation;
let result = Validation::success(5)
.ensure_fn(|x| *x > 0, "must be positive");
assert_eq!(result, Validation::Success(5));
let result = Validation::success(-5)
.ensure_fn(|x| *x > 0, "must be positive");
assert_eq!(result, Validation::Failure("must be positive"));
}
Using ensure_with() for Lazy Errors
When you need the value to construct the error message:
#![allow(unused)]
fn main() {
use stillwater::{Validation, predicate::*};
let result = Validation::success(String::from("hi"))
.ensure_with(len_min(3), |s| format!("'{}' is too short", s));
assert_eq!(result, Validation::Failure("'hi' is too short".to_string()));
}
Using ensure_fn_with() with Closures and Lazy Errors
Combine closure predicates with error factories:
#![allow(unused)]
fn main() {
use stillwater::Validation;
let result = Validation::success(-5)
.ensure_fn_with(
|x| *x > 0,
|x| format!("{} is not positive", x)
);
assert_eq!(result, Validation::Failure("-5 is not positive".to_string()));
}
Using unless() for Inverse Validation
The unless() method fails when the predicate is TRUE (inverse of ensure_fn):
#![allow(unused)]
fn main() {
use stillwater::Validation;
// Fail if negative
let result = Validation::success(5)
.unless(|x| *x < 0, "must not be negative");
assert_eq!(result, Validation::Success(5));
let result = Validation::success(-5)
.unless(|x| *x < 0, "must not be negative");
assert_eq!(result, Validation::Failure("must not be negative"));
}
Using filter_or() Alias
filter_or() is an alias for ensure_fn() following functional programming conventions:
#![allow(unused)]
fn main() {
use stillwater::Validation;
let result = Validation::success(5)
.filter_or(|x| *x > 0, "must be positive");
assert_eq!(result, Validation::Success(5));
}
Why Use Validation Combinators?
Before (verbose):
#![allow(unused)]
fn main() {
validate_email(email)
.and_then(|email| {
if email.len() <= 100 {
Validation::success(email)
} else {
Validation::failure(vec!["email too long"])
}
})
.and_then(|email| {
if !email.starts_with("admin") {
Validation::success(email)
} else {
Validation::failure(vec!["reserved prefix"])
}
})
}
After (declarative):
#![allow(unused)]
fn main() {
use stillwater::predicate::*;
validate_email(email)
.ensure(len_max(100), vec!["email too long"])
.ensure_fn(|e| !e.starts_with("admin"), vec!["reserved prefix"])
}
Converting to Result
#![allow(unused)]
fn main() {
use stillwater::Validation;
let v = Validation::success(42);
let r: Result<i32, Vec<String>> = v.into_result();
assert_eq!(r, Ok(42));
let v = Validation::failure(vec!["error"]);
let r: Result<i32, Vec<String>> = v.into_result();
assert_eq!(r, Err(vec!["error"]));
}
Error Accumulation with Semigroup
For Validation::all() to work, your error type must implement Semigroup:
#![allow(unused)]
fn main() {
pub trait Semigroup {
fn combine(self, other: Self) -> Self;
}
}
Common implementations:
Vec<T>: Concatenate vectorsString: Concatenate strings(A, B) where A: Semigroup, B: Semigroup: Combine components
Example:
#![allow(unused)]
fn main() {
use stillwater::Semigroup;
impl Semigroup for Vec<ValidationError> {
fn combine(mut self, mut other: Self) -> Self {
self.extend(other);
self
}
}
}
See Semigroup guide for details.
Real-World Example
#![allow(unused)]
fn main() {
use stillwater::{Validation, Semigroup};
#[derive(Debug, PartialEq)]
enum ValidationError {
InvalidEmail(String),
PasswordTooShort { min: usize, actual: usize },
AgeTooYoung { min: u8, actual: u8 },
}
fn validate_email(email: &str) -> Validation<String, Vec<ValidationError>> {
if email.contains('@') && email.contains('.') {
Validation::success(email.to_string())
} else {
Validation::failure(vec![
ValidationError::InvalidEmail(email.to_string())
])
}
}
fn validate_password(pwd: &str) -> Validation<String, Vec<ValidationError>> {
const MIN_LEN: usize = 8;
if pwd.len() >= MIN_LEN {
Validation::success(pwd.to_string())
} else {
Validation::failure(vec![
ValidationError::PasswordTooShort {
min: MIN_LEN,
actual: pwd.len(),
}
])
}
}
fn validate_age(age: u8) -> Validation<u8, Vec<ValidationError>> {
const MIN_AGE: u8 = 18;
if age >= MIN_AGE {
Validation::success(age)
} else {
Validation::failure(vec![
ValidationError::AgeTooYoung {
min: MIN_AGE,
actual: age,
}
])
}
}
#[derive(Debug)]
struct User {
email: String,
password: String,
age: u8,
}
fn validate_registration(
email: &str,
password: &str,
age: u8,
) -> Validation<User, Vec<ValidationError>> {
Validation::all((
validate_email(email),
validate_password(password),
validate_age(age),
))
.map(|(email, password, age)| User { email, password, age })
}
// Usage
match validate_registration("invalid", "short", 15) {
Validation::Success(user) => println!("✓ Registered: {:?}", user),
Validation::Failure(errors) => {
println!("✗ {} errors:", errors.len());
for err in errors {
println!(" - {:?}", err);
}
}
}
// Output:
// ✗ 3 errors:
// - InvalidEmail("invalid")
// - PasswordTooShort { min: 8, actual: 5 }
// - AgeTooYoung { min: 18, actual: 15 }
}
Batch User Registration with Traverse
Extending the above example to validate multiple user registrations:
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
#[derive(Debug)]
struct RegistrationData {
email: String,
password: String,
age: u8,
}
fn validate_batch_registrations(
registrations: Vec<RegistrationData>
) -> Validation<Vec<User>, Vec<ValidationError>> {
traverse(registrations, |data| {
validate_registration(&data.email, &data.password, data.age)
})
}
// Usage
let batch = vec![
RegistrationData {
email: "alice@example.com".to_string(),
password: "securepass123".to_string(),
age: 25,
},
RegistrationData {
email: "invalid".to_string(),
password: "short".to_string(),
age: 15,
},
RegistrationData {
email: "bob@example.com".to_string(),
password: "goodpassword".to_string(),
age: 30,
},
];
match validate_batch_registrations(batch) {
Validation::Success(users) => {
println!("✓ {} users registered successfully", users.len());
}
Validation::Failure(errors) => {
println!("✗ Registration failed with {} errors:", errors.len());
for err in errors {
println!(" - {:?}", err);
}
// Output:
// ✗ Registration failed with 3 errors:
// - InvalidEmail("invalid")
// - PasswordTooShort { min: 8, actual: 5 }
// - AgeTooYoung { min: 18, actual: 15 }
}
}
}
This demonstrates how traverse makes it easy to validate collections while accumulating all errors across all items.
When to Use Validation
Use Validation when:
- Validating user input (forms, APIs)
- You want to report ALL errors at once
- Validations are independent (order doesn’t matter)
Use Result when:
- Operations depend on previous results
- Short-circuit is desired (fail fast)
- Single error is sufficient
Patterns
Independent Field Validation
#![allow(unused)]
fn main() {
use stillwater::Validation;
// All fields validated independently
Validation::all((
validate_email(data.email),
validate_phone(data.phone),
validate_address(data.address),
))
}
Dependent Validation
#![allow(unused)]
fn main() {
use stillwater::Validation;
// First validate, then check dependencies
validate_email(email)
.and_then(|email| {
check_email_not_taken(email)
})
}
Mixed Validation
#![allow(unused)]
fn main() {
use stillwater::Validation;
// Combine independent and dependent
Validation::all((
validate_email(email),
validate_password(password),
))
.and_then(|(email, password)| {
// Now check if combination is valid
check_credentials_not_weak(email, password)
})
}
Validating Collections
When validating collections, use traverse for cleaner, more efficient code:
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
fn validate_all_items(items: Vec<Item>) -> Validation<Vec<ValidItem>, Vec<Error>> {
traverse(items, validate_item)
}
}
The traverse function applies a validation to each element and accumulates all errors:
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
fn validate_positive(x: i32) -> Validation<i32, Vec<String>> {
if x > 0 {
Validation::success(x)
} else {
Validation::failure(vec![format!("{} is not positive", x)])
}
}
// All valid
let result = traverse(vec![1, 2, 3], validate_positive);
assert_eq!(result, Validation::Success(vec![1, 2, 3]));
// Multiple errors accumulated
let result = traverse(vec![1, -2, -3], validate_positive);
match result {
Validation::Failure(errors) => {
assert_eq!(errors.len(), 2); // Both negative numbers
}
_ => panic!("Expected failure"),
}
}
For more advanced patterns, see the Traverse Patterns guide.
Alternative using all_vec (less convenient):
#![allow(unused)]
fn main() {
use stillwater::Validation;
fn validate_all_items(items: Vec<Item>) -> Validation<Vec<ValidItem>, Vec<Error>> {
let validations: Vec<_> = items
.into_iter()
.map(|item| validate_item(item))
.collect();
Validation::all_vec(validations)
}
}
Building Complex Types
#![allow(unused)]
fn main() {
use stillwater::Validation;
struct Config {
host: String,
port: u16,
timeout: u64,
}
fn validate_config(input: ConfigInput) -> Validation<Config, Vec<Error>> {
Validation::all((
validate_host(&input.host),
validate_port(input.port),
validate_timeout(input.timeout),
))
.map(|(host, port, timeout)| Config { host, port, timeout })
}
}
Testing
Validation is pure - testing is trivial:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_email() {
let result = validate_email("user@example.com");
assert!(result.is_success());
}
#[test]
fn test_invalid_email() {
let result = validate_email("invalid");
assert!(result.is_failure());
}
#[test]
fn test_accumulation() {
let result = validate_registration("bad", "short", 15);
match result {
Validation::Failure(errors) => {
assert_eq!(errors.len(), 3);
}
_ => panic!("Expected failure"),
}
}
#[test]
fn test_partial_failure() {
// Valid email, invalid password and age
let result = validate_registration("user@example.com", "short", 15);
match result {
Validation::Failure(errors) => {
assert_eq!(errors.len(), 2);
}
_ => panic!("Expected failure"),
}
}
}
}
Advanced: Custom Error Types
You can use any error type that implements Semigroup:
#![allow(unused)]
fn main() {
use stillwater::{Validation, Semigroup};
#[derive(Debug, Clone)]
struct ValidationContext {
field: String,
message: String,
}
#[derive(Debug, Clone)]
struct ValidationErrors {
errors: Vec<ValidationContext>,
}
impl Semigroup for ValidationErrors {
fn combine(mut self, other: Self) -> Self {
self.errors.extend(other.errors);
self
}
}
fn validate_with_context(
field: &str,
value: &str,
) -> Validation<String, ValidationErrors> {
if value.is_empty() {
Validation::failure(ValidationErrors {
errors: vec![ValidationContext {
field: field.to_string(),
message: "Field is required".to_string(),
}],
})
} else {
Validation::success(value.to_string())
}
}
}
Performance Considerations
Validation has minimal overhead:
- Success case: just wraps a value (zero-cost)
- Failure case: creates error collection
- Combining: uses efficient vector extension
The main cost is creating error objects, which you’d do anyway.
Common Pitfalls
Don’t use ? for accumulation
#![allow(unused)]
fn main() {
// ❌ Wrong: short-circuits on first error
fn validate(data: Data) -> Validation<Valid, Vec<Error>> {
let email = validate_email(data.email)?; // Stops here!
let age = validate_age(data.age)?;
// ...
}
// ✓ Right: accumulates errors
fn validate(data: Data) -> Validation<Valid, Vec<Error>> {
Validation::all((
validate_email(data.email),
validate_age(data.age),
))
}
}
Remember to map after all()
#![allow(unused)]
fn main() {
// ❌ Wrong: returns tuple instead of User
fn validate(email: &str, age: u8) -> Validation<(String, u8), Vec<Error>> {
Validation::all((
validate_email(email),
validate_age(age),
))
}
// ✓ Right: map tuple to User
fn validate(email: &str, age: u8) -> Validation<User, Vec<Error>> {
Validation::all((
validate_email(email),
validate_age(age),
))
.map(|(email, age)| User { email, age })
}
}
Summary
- Validation accumulates all errors instead of short-circuiting
- Use Validation::all() for independent validations
- Use and_then() for dependent validations
- Error types must implement Semigroup
- Testing is easy because validation is pure
Next Steps
- Learn about Effect composition
- See full example
- Read the API docs