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 })
}
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)
}
}
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)
}
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 })
}
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);
}
}
}
How It Works (No Magic)#
Validation is a simple enum:
pub enum Validation<T, E> {
Success(T),
Failure(E),
}
The key insight is Validation::all(). It takes a tuple of validations and:
- If all succeed → returns
Successwith a tuple of values - If any fail → returns
Failurewith 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
}
}
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)
}
}
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 })
}
)
}
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))
}
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(),
})
}
}
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))
}
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()])
}
})
})
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)?;
// ...
}
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
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
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"
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 })
}
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.