DEV Community

Glen Baker
Glen Baker

Posted on • Originally published at entropicdrift.com

Stillwater Validation for Rustaceans: Accumulating Errors Instead of Failing Fast

Originally published on Entropic Drift


The Problem with Result for Validation

Most Rust validation code follows a familiar pattern: when a user submits a form with three errors, the API reports only the first one. They fix it, resubmit, and discover error number two. Fix that, resubmit again. Error three.

Three round trips for what should have been one.

This is due to Rust's Result type and how it short-circuits.

The Problem in Code

Here's how most of us write validation:

fn validate_user_registration(input: &RegistrationInput) -> Result<ValidatedUser, ValidationError> {
    let email = validate_email(&input.email)?;  // Stops here if invalid
    let age = validate_age(input.age)?;         // Never reached
    let username = validate_username(&input.username)?;  // Never reached

    Ok(ValidatedUser { email, age, username })
}
Enter fullscreen mode Exit fullscreen mode

If the email is invalid, we return immediately. The user never learns their age and username were also wrong.

This is Result doing exactly what it's designed to do. The ? operator is excellent for error propagation, but propagation and accumulation are different things.

The Manual Fix (And Why It's Painful)

You've probably written this code before:

fn validate_user_registration(input: &RegistrationInput) -> Result<ValidatedUser, Vec<String>> {
    let mut errors = Vec::new();

    let email = match validate_email(&input.email) {
        Ok(e) => Some(e),
        Err(e) => { errors.push(e); None }
    };

    let age = match validate_age(input.age) {
        Ok(a) => Some(a),
        Err(e) => { errors.push(e); None }
    };

    let username = match validate_username(&input.username) {
        Ok(u) => Some(u),
        Err(e) => { errors.push(e); None }
    };

    if errors.is_empty() {
        Ok(ValidatedUser {
            email: email.unwrap(),  // We know it's Some
            age: age.unwrap(),
            username: username.unwrap(),
        })
    } else {
        Err(errors)
    }
}
Enter fullscreen mode Exit fullscreen mode

22 lines to validate 3 fields. And those .unwrap() calls? They're safe here, but they make me nervous. The compiler doesn't prove they're safe—we're relying on our logic being correct.

Add more fields and this explodes:

// With 10 fields, this pattern becomes:
let mut errors = Vec::new();
let field1 = match validate_field1(...) { Ok(v) => Some(v), Err(e) => { errors.push(e); None } };
let field2 = match validate_field2(...) { Ok(v) => Some(v), Err(e) => { errors.push(e); None } };
let field3 = match validate_field3(...) { Ok(v) => Some(v), Err(e) => { errors.push(e); None } };
let field4 = match validate_field4(...) { Ok(v) => Some(v), Err(e) => { errors.push(e); None } };
let field5 = match validate_field5(...) { Ok(v) => Some(v), Err(e) => { errors.push(e); None } };
let field6 = match validate_field6(...) { Ok(v) => Some(v), Err(e) => { errors.push(e); None } };
let field7 = match validate_field7(...) { Ok(v) => Some(v), Err(e) => { errors.push(e); None } };
let field8 = match validate_field8(...) { Ok(v) => Some(v), Err(e) => { errors.push(e); None } };
let field9 = match validate_field9(...) { Ok(v) => Some(v), Err(e) => { errors.push(e); None } };
let field10 = match validate_field10(...) { Ok(v) => Some(v), Err(e) => { errors.push(e); None } };

if errors.is_empty() {
    Ok(ValidatedThing {
        field1: field1.unwrap(),
        field2: field2.unwrap(),
        field3: field3.unwrap(),
        // ... you get the idea
    })
} else {
    Err(errors)
}
Enter fullscreen mode Exit fullscreen mode

This is boilerplate hell. And every .unwrap() is a code smell that makes reviewers twitch.

Enter Validation

Stillwater provides a Validation type that handles error accumulation correctly:

use stillwater::Validation;

fn validate_user_registration(input: &RegistrationInput) -> Validation<ValidatedUser, Vec<String>> {
    Validation::all((
        validate_email(&input.email),
        validate_age(input.age),
        validate_username(&input.username),
    ))
    .map(|(email, age, username)| ValidatedUser { email, age, username })
}
Enter fullscreen mode Exit fullscreen mode

8 lines. No .unwrap(). No manual error collection. No Option wrappers.

When you call this with invalid data, you get all the errors:

let result = validate_user_registration(&bad_input);

match result {
    Validation::Success(user) => println!("Valid: {:?}", user),
    Validation::Failure(errors) => {
        // errors contains ALL validation failures, not just the first
        for error in errors {
            println!("Error: {}", error);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

How It Works (No Magic)

Validation is a simple enum:

pub enum Validation<T, E> {
    Success(T),
    Failure(E),
}
Enter fullscreen mode Exit fullscreen mode

The key insight is Validation::all(). It takes a tuple of validations and:

  1. If all succeed → returns Success with a tuple of values
  2. If any fail → returns Failure with combined errors

The "combining" uses a trait called Semigroup:

pub trait Semigroup {
    fn combine(self, other: Self) -> Self;
}

// Vec<T> combines by appending
impl<T> Semigroup for Vec<T> {
    fn combine(mut self, mut other: Self) -> Self {
        self.append(&mut other);
        self
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the mathematical foundation that makes error accumulation composable.

Writing Validation Functions

Your individual validators return Validation instead of Result:

fn validate_email(email: &str) -> Validation<Email, Vec<String>> {
    if email.contains('@') && email.len() >= 5 {
        Validation::Success(Email(email.to_string()))
    } else {
        Validation::Failure(vec!["Invalid email format".to_string()])
    }
}

fn validate_age(age: u32) -> Validation<Age, Vec<String>> {
    if age >= 18 && age <= 120 {
        Validation::Success(Age(age))
    } else {
        Validation::Failure(vec![format!("Age must be 18-120, got {}", age)])
    }
}

fn validate_username(username: &str) -> Validation<Username, Vec<String>> {
    let mut errors = Vec::new();

    if username.len() < 3 {
        errors.push("Username must be at least 3 characters".to_string());
    }
    if username.len() > 20 {
        errors.push("Username must be at most 20 characters".to_string());
    }
    if !username.chars().all(|c| c.is_alphanumeric() || c == '_') {
        errors.push("Username can only contain alphanumeric characters and underscores".to_string());
    }

    if errors.is_empty() {
        Validation::Success(Username(username.to_string()))
    } else {
        Validation::Failure(errors)
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice validate_username itself accumulates multiple errors. When combined with other validators, all errors flow through.

Scaling to Complex Forms

Real forms have nested objects. Validation composes:

struct OrderInput {
    customer: CustomerInput,
    shipping: ShippingInput,
    items: Vec<ItemInput>,
}

fn validate_order(input: &OrderInput) -> Validation<Order, Vec<String>> {
    Validation::all((
        validate_customer(&input.customer),
        validate_shipping(&input.shipping),
        validate_items(&input.items),
    ))
    .map(|(customer, shipping, items)| Order { customer, shipping, items })
}

fn validate_customer(input: &CustomerInput) -> Validation<Customer, Vec<String>> {
    Validation::all((
        validate_email(&input.email),
        validate_name(&input.name),
        validate_phone(&input.phone),
    ))
    .map(|(email, name, phone)| Customer { email, name, phone })
}

fn validate_items(items: &[ItemInput]) -> Validation<Vec<Item>, Vec<String>> {
    items
        .iter()
        .enumerate()
        .map(|(i, item)| {
            validate_item(item)
                .map_err(|errs| {
                    errs.into_iter()
                        .map(|e| format!("Item {}: {}", i, e))
                        .collect()
                })
        })
        .collect::<Vec<_>>()
        .into_iter()
        .fold(
            Validation::Success(Vec::new()),
            |acc, item| {
                Validation::all((acc, item.map(|i| vec![i])))
                    .map(|(mut items, new)| { items.extend(new); items })
            }
        )
}
Enter fullscreen mode Exit fullscreen mode

Or use the traverse helper for cleaner collection validation:

use stillwater::traverse;

fn validate_items(items: &[ItemInput]) -> Validation<Vec<Item>, Vec<String>> {
    traverse(items.iter(), |item| validate_item(item))
}
Enter fullscreen mode Exit fullscreen mode

The Error Type Flexibility

You're not limited to Vec<String>. Any Semigroup works:

// Structured errors
#[derive(Debug)]
struct FormErrors {
    field_errors: HashMap<String, Vec<String>>,
}

impl Semigroup for FormErrors {
    fn combine(mut self, other: Self) -> Self {
        for (field, errors) in other.field_errors {
            self.field_errors
                .entry(field)
                .or_default()
                .extend(errors);
        }
        self
    }
}

// Now your API can return structured errors
fn validate_email(email: &str) -> Validation<Email, FormErrors> {
    if valid {
        Validation::Success(Email(email.to_string()))
    } else {
        Validation::Failure(FormErrors {
            field_errors: [("email".to_string(), vec!["Invalid format".to_string()])].into(),
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Your frontend gets structured error data it can map to specific form fields.

Interop with Result

Validation converts to/from Result seamlessly:

// Result → Validation
let validation: Validation<_, Vec<String>> = Validation::from_result(
    some_result,
    |e| vec![e.to_string()]  // Convert error type
);

// Validation → Result
let result: Result<User, Vec<String>> = validation.into_result();

// Use with ? operator (converts to Result)
fn handler(input: Input) -> Result<Response, ApiError> {
    let validated = validate_input(&input)
        .into_result()
        .map_err(|errors| ApiError::Validation(errors))?;

    // Continue with validated data...
    Ok(Response::new(validated))
}
Enter fullscreen mode Exit fullscreen mode

When NOT to Use Validation

Validation is for independent checks that should all run. Don't use it for:

Dependent validations (where later checks depend on earlier ones):

// DON'T: end_date validation needs a valid start_date
Validation::all((
    validate_date(&input.start_date),
    validate_end_after_start(&input.start_date, &input.end_date),  // Needs valid start_date!
))

// DO: Use and_then for dependent validation
validate_date(&input.start_date)
    .and_then(|start| {
        validate_date(&input.end_date)
            .and_then(|end| {
                if end > start {
                    Validation::Success((start, end))
                } else {
                    Validation::Failure(vec!["End date must be after start date".to_string()])
                }
            })
    })
Enter fullscreen mode Exit fullscreen mode

Fail-fast scenarios (where continuing is pointless):

// If auth fails, don't bother validating the rest
// Use Result here, not Validation
fn process_request(req: Request) -> Result<Response, Error> {
    let user = authenticate(&req)?;  // Fail fast is correct here
    let validated = validate_payload(&req.body)?;
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Performance

Validation is zero-cost in the success path. The only overhead is when errors accumulate, and that's the cost you'd pay manually anyway.

There's no heap allocation for the Validation type itself—it's just an enum. Error accumulation allocates only when there are actual errors to collect.

Real-World Applications

The Validation type isn't just for form data—any domain where you need to find all problems rather than just the first one benefits from error accumulation.

Configuration Validation: The premortem library uses Stillwater's Validation to check application configuration. Instead of discovering configuration errors one deploy at a time (missing database host, then invalid port, then bad pool size), premortem performs a "premortem" — finding all configuration problems before your application starts:

// All configuration errors reported at once
Configuration errors (3):
  [config.toml:8] missing required field 'database.host'
  [env:APP_PORT] value "abc" is not a valid integer
  [config.toml:10] 'pool_size' value -5 must be >= 1
Enter fullscreen mode Exit fullscreen mode

Schema Validation: The postmortem library uses Stillwater's Validation for JSON schema validation. Instead of fixing validation errors one API request at a time, postmortem accumulates all schema violations:

let user_schema = Schema::object()
    .required("email", Schema::string().min_len(1))
    .required("age", Schema::integer().min(18))
    .required("password", Schema::string().min_len(8));

// Validation errors (3):
//   $.email: missing required field
//   $.age: value 15 must be >= 18
//   $.password: length 5 is less than minimum 8
Enter fullscreen mode Exit fullscreen mode

The same principle applies anywhere independent checks should all run: batch data import validation, CI pipeline checks, infrastructure validation, multi-tenant configuration checks, and more.

See Premortem vs Figment for a deeper look at configuration validation patterns.

The Bigger Picture

Validation is the entry point to Stillwater's functional programming toolkit. Once you're comfortable with it, you might explore:

  • Effect: Separating pure logic from I/O for testable async code
  • Retry: Composable retry policies with backoff strategies
  • ContextError: Error trails that preserve the full call stack context

But you don't need any of that to benefit from Validation. It stands alone as a solution to a specific, common problem: showing users all their errors at once.

Getting Started

Add to your Cargo.toml:

[dependencies]
stillwater = "0.13"
Enter fullscreen mode Exit fullscreen mode

Start with one form validation. Replace the manual error accumulation pattern with Validation::all(). See if your code gets cleaner.

use stillwater::Validation;

// Before: 22 lines of manual accumulation
// After: 8 lines of composed validation

fn validate(input: &Input) -> Validation<Valid, Vec<String>> {
    Validation::all((
        validate_field1(&input.field1),
        validate_field2(&input.field2),
        validate_field3(&input.field3),
    ))
    .map(|(f1, f2, f3)| Valid { f1, f2, f3 })
}
Enter fullscreen mode Exit fullscreen mode

Your users will appreciate seeing all validation errors at once.


Summary

Approach Lines (3 fields) Lines (10 fields) Type Safety Error Reporting
Result + ? 5 12 High First error only
Manual accumulation 22 65+ Medium (unwrap risk) All errors
Validation::all() 8 14 High (no unwrap) All errors

The Validation type isn't magic—it's a principled approach to a real problem. Error accumulation is fundamentally different from error propagation, and having a type that encodes that difference makes your code safer and provides a better user experience.

Give your users complete error feedback. Accumulate your errors.


Stillwater is a Rust library for validation, effect composition, and functional programming patterns. It's designed for Rustaceans who want practical FP without the academic overhead.


Want more content like this? Follow me on Dev.to or subscribe to Entropic Drift for posts on AI-powered development workflows, Rust tooling, and technical debt management.

Check out my open-source projects:

  • Debtmap - Technical debt analyzer
  • Prodigy - AI workflow orchestration

Top comments (0)