Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 vectors
  • String: 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