Stillwater Documentation
Stillwater is a Rust library for pragmatic functional programming focused on validation and effect composition.
Start Here
- Running the Examples - Local Cargo setup and chapter tests; no browser Playground required
- User Guide - Progressive tutorials for core concepts and patterns
- Patterns - Recipes for common validation and effect workflows
- FAQ - Answers to common questions
- Migration Guide - Upgrade notes for breaking changes
- Comparison - How Stillwater compares to related libraries
Core Chapters
- Semigroup
- Validation
- Effects
- Error Context
- IO Module
- Try Trait
- Compile-Time Resource Tracking
- API Tiers and Composition Decisions
Running the Examples
Run Stillwater examples locally with Cargo. Browser Run buttons are disabled: the public Rust Playground does not include Stillwater, even for examples using the 1.x API. Publishing 2.0 does not automatically install it in that service. Code remains selectable and copyable, and Rust examples remain covered by Cargo doctests.
Run examples from this checkout
Install Rust 1.89 or newer, then clone the repository:
git clone https://github.com/iepathos/stillwater.git
cd stillwater
cargo test --doc --all-features
This tests the Rust examples against the checked-out library and its declared dependencies, including before 2.0 is published. To test just the Semigroup chapter:
cargo test --doc --all-features book_doctests::semigroup
To run a complete application example:
cargo run --example user_registration --all-features
Other complete programs are in the repository’s examples/ directory. Use
--all-features when running them so feature-gated examples are available.
Copy a snippet into a standalone project
From the directory containing your stillwater checkout, create a sibling project:
cargo new stillwater-book-example
cd stillwater-book-example
Replace its Cargo.toml with:
[package]
name = "stillwater-book-example"
version = "0.1.0"
edition = "2021"
[dependencies]
stillwater = { path = "../stillwater" }
Replace src/main.rs with this complete version of the Semigroup vector example:
use stillwater::Semigroup;
fn main() {
let v1 = vec![1, 2, 3];
let v2 = vec![4, 5, 6];
assert_eq!(v1.combine(v2), vec![1, 2, 3, 4, 5, 6]);
let empty: Vec<i32> = vec![];
let values = vec![1, 2, 3];
assert_eq!(empty.combine(values), vec![1, 2, 3]);
println!("Semigroup examples passed.");
}
Then run:
cargo run
After 2.0 is published, you can replace the path dependency with
stillwater = "2.0" to use crates.io. The path-based instructions above work now
and are exercised automatically in a fresh project by CI.
Understand the different kinds of examples
- Most chapter snippets are function bodies. Put them inside
fn main()when copying into a binary project, unless they already define it. - Use the eye button to reveal hidden setup lines before copying examples that
need them. Async examples may use
tokio_test::block_on; a standalone project must addtokio-test = "0.4"as a dependency for that helper. The checkout’s doctest commands already provide this and other test dependencies. - Feature-specific APIs need their Cargo features enabled. See API tiers and the relevant chapter.
compile_failexamples intentionally demonstrate compiler rejection;no_runexamples are compile-checked but not executed by doctests.- Plain-text sketches and historical examples illustrate ideas, not complete programs. They are not presented as runnable applications.
The chapter doctests and complete application examples are the supported way to run broader examples with the correct dependencies and setup.
Stillwater User Guide
Welcome to the Stillwater user guide! This guide will help you understand and master the core concepts of Stillwater.
What is Stillwater?
Stillwater is a Rust library for pragmatic functional programming focused on two main problems:
- Validation: Accumulating ALL errors instead of failing on the first one
- Effects: Separating pure business logic from I/O operations
Guide Structure
This guide is organized into progressive chapters, each building on the previous:
Core Concepts
-
Semigroup - The foundation for combining errors
- What is a Semigroup?
- Why it matters for validation
- Implementing Semigroup for your types
-
Validation - Error accumulation
- The problem with Result
- Using Validation for error accumulation
- Combining validations
- Real-world examples
-
Effects - Pure core, imperative shell
- Separating logic from I/O
- Effect composition
- Testing effectful code
- Async support
-
Error Context - Better debugging
- Adding context to errors
- Error trails
- Best practices
-
IO Module - Ergonomic helpers
- IO::read, IO::write, IO::read_async, IO::write_async
- Dependency injection patterns
- Testing with mock environments
-
Helper Combinators - Common patterns
- traverse, sequence
- Convenience functions
- Building your own combinators
-
Try Trait - Nightly feature
- Using ? with Validation
- When to enable try_trait
- Migration path
-
Monoid - Identity elements for composition
- What is a Monoid?
- Extending Semigroup with identity
- Numeric monoids (Sum, Product, Max, Min)
- Using fold_all and reduce
- Real-world aggregation patterns
Effect Patterns
-
Reader Pattern - Dependency injection through environments
- Asking for environment values
- Dependency injection without singletons
- Testing with mock environments
-
Parallel Effects - Running independent effects concurrently
- Heterogeneous parallel effects
- Homogeneous parallel collections
- Error behavior and environment sharing
- Traverse Patterns - Working with collections
- Collection validation with error accumulation
- Effect processing over collections
- Batch operations
- Traverse vs sequence
- Real-world examples
- Retry and Resilience - Handling transient failures
- Retry policies and backoff strategies
- Conditional retry with retry_if
- Observability hooks for logging/metrics
- Timeout support
- Combining retry with timeout
Validation Patterns
- Homogeneous Validation - Type-consistent error accumulation
- Combining enum variants safely
- Preventing incompatible accumulation
- Validation patterns for homogeneous data
- Refined Types - Parse, don’t validate
Refined<T, P>for type-level invariants- Numeric predicates: Positive, NonNegative, NonZero, InRange
- String predicates: NonEmpty, Trimmed, MaxLength, MinLength
- Predicate combinators: And, Or, Not
- Validation integration for error accumulation
- Zero-cost: same memory layout as inner type
Testing & Quality
- Testing - Testing utilities and patterns
- MockEnv builder for test environments
- Assertion macros for Validation
- TestEffect for deterministic testing
- Property-based testing with proptest
- Testing best practices
Additional Advanced Topics
- Compile-Time Resource Tracking - Type-level resource safety
- Resource markers (FileRes, DbRes, TxRes, etc.)
- ResourceEffect trait with Acquires/Releases tracking
- Extension methods:
.acquires(),.releases(),.neutral() - resource_bracket for guaranteed cleanup
- Zero runtime overhead - purely type-level
How to Use This Guide
For Beginners
Start with chapters 1-3 in order. These cover the core concepts you need to be productive with Stillwater.
For Experienced Users
Jump to the chapters that interest you. Each chapter is self-contained with links to related concepts.
Running Examples
See Running the Examples for local setup, copy/paste instructions, and chapter doctests. Browser execution is disabled because the public Rust Playground does not include Stillwater. Rust snippets are checked with Cargo; plain-text sketches are not complete programs.
Run complete programs from the examples/ directory in a repository checkout:
cargo run --example form_validation --all-features
cargo run --example user_registration --all-features
cargo run --example recover_patterns --all-features
cargo run --example retry_patterns --all-features
cargo run --example resource_tracking --all-features
Quick Reference
When to Use What
| Use Case | Tool | Example |
|---|---|---|
| Form validation | Validation | Collect all field errors |
| API request validation | Validation | Return all validation errors |
| Collection validation | traverse | Validate multiple items, accumulate errors |
| Ordered batch processing | traverse_effect_sequential | Stop at first effect failure |
| Concurrent batch processing | traverse_effect_parallel | Run all effects concurrently |
| Choosing an API layer | API tiers | Start core; adopt advanced APIs deliberately |
| Database operations | Effect | Separate logic from I/O |
| File operations | Effect + IO | Testable file processing |
| Error debugging | ContextError | Add context trails |
| Error recovery | recover() / recover_with() | Fallback on specific errors |
| Cache fallback | recover() | Try cache, fallback to DB |
| Graceful degradation | recover_some() | Provide reduced functionality |
| Default values | fallback() | Use default on any error |
| Data aggregation | Monoid | Combine collections with fold_all |
| Numeric operations | Sum/Product | Aggregate numbers with identity |
| Retry transient errors | retry | Retry with backoff strategies |
| Conditional retry | retry_if | Retry only on certain errors |
| Timeout operations | with_timeout | Prevent hanging operations |
| Testing validations | assert_success! / assert_failure! | Concise test assertions |
| Testing effects | TestEffect | Deterministic effect testing |
| Mock environments | MockEnv | Build test dependencies |
| Property-based tests | proptest feature | Generate test cases |
| Resource acquisition | .acquires:: | Mark effect as acquiring resource |
| Resource release | .releases:: | Mark effect as releasing resource |
| Safe resource ops | resource_bracket | Guaranteed acquire/use/release |
| Resource neutrality | assert_resource_neutral | Compile-time leak detection |
| Type-level invariants | Refined<T, P> | Guarantee constraints at compile time |
| Parse, don’t validate | NonEmptyString, Port | Validate once, use safely everywhere |
Common Patterns
use stillwater::prelude::*;
// Pattern 1: Independent validations
Validation::all((
validate_email(input.email),
validate_age(input.age),
))
// Pattern 2: Dependent validations
validate_email(email)
.and_then(|email| check_email_available(email))
// Pattern 3: Effect with validation
from_validation(validate_user(input))
.and_then(|user| save_to_db(user))
// Pattern 4: Error recovery with fallback
fetch_from_cache(key)
.recover(
|e| matches!(e, CacheError::Miss),
|_| fetch_from_db(key)
)
Getting Help
- Check the FAQ for common questions
- Read the API documentation
- See PATTERNS.md for recipes
- Compare to other libraries
Next Steps
Ready to dive in? Start with Chapter 1: Semigroup!
Semigroup: The Foundation for Combining Values
Run these examples with Cargo using the local setup and chapter-test instructions.
What is a Semigroup?
A Semigroup is a simple but powerful concept: a type with an associative binary operation that combines two values into one.
In Rust terms, it’s a trait with one method:
pub trait Semigroup: Sized {
fn combine(self, other: Self) -> Self;
}
The key property is associativity: the order of combining doesn’t matter.
// These two operations must produce the same result:
a.combine(b).combine(c) == a.combine(b.combine(c))
Why Does This Matter?
Semigroup is the foundation for error accumulation in Stillwater. When validating multiple fields, we need to combine error collections:
// Validation 1 fails with: vec!["Email invalid"]
// Validation 2 fails with: vec!["Age too young"]
// Combined failure: vec!["Email invalid", "Age too young"]
To combine these errors, we need a Semigroup implementation for Vec<E>.
Built-in Implementations
Stillwater provides Semigroup implementations for common types:
Vectors
Concatenates two vectors:
use stillwater::Semigroup;
let v1 = vec![1, 2, 3];
let v2 = vec![4, 5, 6];
assert_eq!(v1.combine(v2), vec![1, 2, 3, 4, 5, 6]);
// Empty vectors work too
let empty: Vec<i32> = vec![];
let values = vec![1, 2, 3];
assert_eq!(empty.combine(values), vec![1, 2, 3]);
Strings
Concatenates two strings:
use stillwater::Semigroup;
let s1 = "Hello, ".to_string();
let s2 = "World!".to_string();
assert_eq!(s1.combine(s2), "Hello, World!");
Tuples
Combines tuples component-wise (up to 12 elements):
use stillwater::Semigroup;
let t1 = (vec![1], "a".to_string());
let t2 = (vec![2], "b".to_string());
assert_eq!(
t1.combine(t2),
(vec![1, 2], "ab".to_string())
);
// Works with larger tuples
let t1 = (vec![1], "a".to_string(), vec![2]);
let t2 = (vec![3], "b".to_string(), vec![4]);
assert_eq!(
t1.combine(t2),
(vec![1, 3], "ab".to_string(), vec![2, 4])
);
Implementing Semigroup for Custom Types
You can implement Semigroup for your own error types:
use stillwater::Semigroup;
#[derive(Debug, PartialEq)]
struct ValidationErrors {
errors: Vec<String>,
}
impl Semigroup for ValidationErrors {
fn combine(mut self, other: Self) -> Self {
self.errors.extend(other.errors);
self
}
}
// Usage
let e1 = ValidationErrors { errors: vec!["Email invalid".to_string()] };
let e2 = ValidationErrors { errors: vec!["Age too young".to_string()] };
let combined = e1.combine(e2);
assert_eq!(combined.errors, vec!["Email invalid", "Age too young"]);
More Complex Examples
You can implement Semigroup for domain-specific error types:
use stillwater::Semigroup;
#[derive(Debug, PartialEq)]
enum ValidationError {
InvalidEmail(String),
AgeTooYoung { min: u8, actual: u8 },
PasswordTooShort { min: usize, actual: usize },
}
#[derive(Debug, PartialEq)]
struct ValidationResult {
errors: Vec<ValidationError>,
}
impl Semigroup for ValidationResult {
fn combine(mut self, other: Self) -> Self {
self.errors.extend(other.errors);
self
}
}
Important: Ownership Semantics
The combine method takes ownership of both values:
use stillwater::Semigroup;
let v1 = vec![1, 2, 3];
let v2 = vec![4, 5, 6];
let result = v1.combine(v2);
// v1 and v2 are now moved, can't use them anymore
// This won't compile:
// println!("{:?}", v1);
If you need to preserve the original values, clone them first:
use stillwater::Semigroup;
let v1 = vec![1, 2, 3];
let v2 = vec![4, 5, 6];
let result = v1.clone().combine(v2.clone());
// v1 and v2 are still usable
assert_eq!(v1, vec![1, 2, 3]);
assert_eq!(v2, vec![4, 5, 6]);
assert_eq!(result, vec![1, 2, 3, 4, 5, 6]);
Associativity Law
All Semigroup implementations must be associative. This means:
use stillwater::Semigroup;
let a = vec![1, 2];
let b = vec![3, 4];
let c = vec![5, 6];
// These produce the same result
let left = a.clone().combine(b.clone()).combine(c.clone());
let right = a.combine(b.combine(c));
assert_eq!(left, right);
// Both equal [1, 2, 3, 4, 5, 6]
This property is crucial for validation: it means we can combine errors in any order and get the same result.
Why Not Monoid?
You might wonder why Stillwater uses Semigroup instead of Monoid (which adds an “empty” element). The reason is practical:
- Not all error types have a meaningful “empty” - What’s an empty custom error?
- Simpler API - Less complexity for users
- Validation::all handles the empty case - You don’t combine zero validations
If you need a Monoid, you can easily extend Semigroup:
use stillwater::Semigroup;
trait Monoid: Semigroup {
fn empty() -> Self;
}
impl<T> Monoid for Vec<T> {
fn empty() -> Self {
Vec::new()
}
}
Real-World Example: Form Validation Errors
Here’s how Semigroup enables multi-field form validation:
use stillwater::{Semigroup, Validation};
#[derive(Debug, PartialEq, Clone)]
enum FormError {
InvalidEmail(String),
PasswordTooShort,
AgeTooYoung,
}
// Vec<FormError> is already a Semigroup!
fn validate_form(
email: &str,
password: &str,
age: u8,
) -> Validation<(), Vec<FormError>> {
let email_check = if email.contains('@') {
Validation::success(())
} else {
Validation::failure(vec![FormError::InvalidEmail(email.to_string())])
};
let password_check = if password.len() >= 8 {
Validation::success(())
} else {
Validation::failure(vec![FormError::PasswordTooShort])
};
let age_check = if age >= 18 {
Validation::success(())
} else {
Validation::failure(vec![FormError::AgeTooYoung])
};
// Validation::all uses Semigroup to combine errors!
Validation::all((email_check, password_check, age_check))
.map(|_| ())
}
// Usage
match validate_form("invalid", "short", 15) {
Validation::Success(_) => println!("Valid!"),
Validation::Failure(errors) => {
println!("Errors: {:?}", errors);
// Prints all 3 errors:
// [InvalidEmail("invalid"), PasswordTooShort, AgeTooYoung]
}
}
Without Semigroup, we couldn’t combine the error vectors automatically!
Extended Implementations for Collections
Stillwater provides Semigroup implementations for standard Rust collection types, enabling powerful composition patterns for configuration merging, error aggregation, and data combining.
HashMaps and BTrees
HashMap<K, V: Semigroup>
Combines two maps by merging their entries. When keys conflict, their values are combined using the value’s Semigroup instance:
use std::collections::HashMap;
use stillwater::Semigroup;
let mut map1 = HashMap::new();
map1.insert("errors", vec!["error1"]);
map1.insert("warnings", vec!["warn1"]);
let mut map2 = HashMap::new();
map2.insert("errors", vec!["error2"]);
map2.insert("info", vec!["info1"]);
let combined = map1.combine(map2);
// Result:
// {
// "errors": ["error1", "error2"], // Combined with Vec semigroup
// "warnings": ["warn1"], // From map1 only
// "info": ["info1"] // From map2 only
// }
Use case: Configuration Merging
use std::collections::HashMap;
use stillwater::Semigroup;
#[derive(Clone)]
struct Config {
settings: HashMap<String, String>,
}
impl Semigroup for Config {
fn combine(self, other: Self) -> Self {
Config {
settings: self.settings.combine(other.settings),
}
}
}
// Layer configs from different sources
let default_config = Config {
settings: [("timeout", "30"), ("retries", "3")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
};
let user_config = Config {
settings: [("timeout", "60"), ("debug", "true")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
};
// user_config values override default_config values
// (String semigroup concatenates, but you'd typically use Last wrapper for configs)
BTreeMap<K, V: Semigroup>
Same as HashMap but maintains sorted keys:
use std::collections::BTreeMap;
use stillwater::Semigroup;
let mut map1 = BTreeMap::new();
map1.insert("a", vec![1, 2]);
map1.insert("b", vec![3]);
let mut map2 = BTreeMap::new();
map2.insert("a", vec![4, 5]);
map2.insert("c", vec![6]);
let combined = map1.combine(map2);
// Keys in sorted order: {a: [1,2,4,5], b: [3], c: [6]}
Sets
HashSet
Combines two sets using union:
use std::collections::HashSet;
use stillwater::Semigroup;
let set1: HashSet<_> = [1, 2, 3].iter().cloned().collect();
let set2: HashSet<_> = [3, 4, 5].iter().cloned().collect();
let combined = set1.combine(set2);
// Result: {1, 2, 3, 4, 5}
Use case: Feature Flags
use std::collections::HashSet;
use stillwater::Semigroup;
#[derive(Clone)]
struct Features {
enabled: HashSet<String>,
}
impl Semigroup for Features {
fn combine(self, other: Self) -> Self {
Features {
enabled: self.enabled.combine(other.enabled),
}
}
}
let base_features = Features {
enabled: ["logging", "metrics"].iter().map(|s| s.to_string()).collect(),
};
let premium_features = Features {
enabled: ["advanced_analytics", "priority_support"].iter().map(|s| s.to_string()).collect(),
};
let all_features = base_features.combine(premium_features);
// All features enabled
BTreeSet
Same as HashSet but maintains sorted elements:
use std::collections::BTreeSet;
use stillwater::Semigroup;
let set1: BTreeSet<_> = [1, 2, 3].iter().cloned().collect();
let set2: BTreeSet<_> = [3, 4, 5].iter().cloned().collect();
let combined = set1.combine(set2);
// Elements in sorted order: {1, 2, 3, 4, 5}
Option<T: Semigroup>
Lifts a Semigroup operation to Option, combining inner values when both are Some:
use stillwater::Semigroup;
let opt1 = Some(vec![1, 2]);
let opt2 = Some(vec![3, 4]);
assert_eq!(opt1.combine(opt2), Some(vec![1, 2, 3, 4]));
let none: Option<Vec<i32>> = None;
let some = Some(vec![1, 2]);
assert_eq!(none.clone().combine(some.clone()), some);
assert_eq!(some.clone().combine(none), some);
let none1: Option<Vec<i32>> = None;
let none2: Option<Vec<i32>> = None;
assert_eq!(none1.combine(none2), None);
Combination rules:
Some(a).combine(Some(b))=Some(a.combine(b))Some(a).combine(None)=Some(a)None.combine(Some(b))=Some(b)None.combine(None)=None
Use case: Optional Error Accumulation
use stillwater::Semigroup;
fn validate_optional_field(
value: Option<String>,
) -> Option<Vec<String>> {
value.and_then(|v| {
if v.is_empty() {
Some(vec!["Field cannot be empty".to_string()])
} else {
None // No errors
}
})
}
let error1 = Some(vec!["Error 1".to_string()]);
let error2 = None;
let error3 = Some(vec!["Error 2".to_string()]);
let all_errors = error1.combine(error2).combine(error3);
// Some(vec!["Error 1", "Error 2"])
Wrapper Types for Alternative Semantics
Sometimes you want different combining behavior. Stillwater provides wrapper types for common alternatives:
First - Keep First Value
Always keeps the first (left) value, discarding the second:
use stillwater::{First, Semigroup};
let first = First(1).combine(First(2));
assert_eq!(first.0, 1); // Keeps first
// Useful for configuration: first definition wins
let config_value = First("default").combine(First("override"));
assert_eq!(config_value.0, "default");
Use case: Default Values
use std::collections::HashMap;
use stillwater::{First, Semigroup};
// Use First wrapper to keep default config values
let defaults: HashMap<String, First<i32>> =
[("timeout", First(30)), ("retries", First(3))]
.iter()
.cloned()
.collect();
let user_config: HashMap<String, First<i32>> =
[("timeout", First(60))] // User only overrides timeout
.iter()
.cloned()
.collect();
// Combine: user config "wins" by being first
let final_config = user_config.combine(defaults);
// timeout is 60, retries is 3
Last - Keep Last Value
Always keeps the last (right) value, discarding the first:
use stillwater::{Last, Semigroup};
let last = Last(1).combine(Last(2));
assert_eq!(last.0, 2); // Keeps last
// Useful for configuration: last definition wins (override)
let config_value = Last("default").combine(Last("override"));
assert_eq!(config_value.0, "override");
Use case: Layered Configuration
use std::collections::HashMap;
use stillwater::{Last, Semigroup};
// Build config from multiple layers (last wins)
let default_cfg: HashMap<String, Last<String>> =
[("env", Last("production".into())), ("debug", Last("false".into()))]
.iter()
.cloned()
.collect();
let env_cfg: HashMap<String, Last<String>> =
[("debug", Last("true".into()))] // Override from environment
.iter()
.cloned()
.collect();
let final_cfg = default_cfg.combine(env_cfg);
// debug is "true" (env_cfg overrides)
// env is "production" (from defaults)
Intersection - Set Intersection
Alternative to the default union operation for sets:
use std::collections::HashSet;
use stillwater::{Intersection, Semigroup};
let set1: HashSet<_> = [1, 2, 3].iter().cloned().collect();
let set2: HashSet<_> = [2, 3, 4].iter().cloned().collect();
let i1 = Intersection(set1);
let i2 = Intersection(set2);
let result = i1.combine(i2);
let expected: HashSet<_> = [2, 3].iter().cloned().collect();
assert_eq!(result.0, expected); // Only common elements
Use case: Required Permissions
use std::collections::HashSet;
use stillwater::{Intersection, Semigroup};
// User must have ALL these permissions (intersection)
let admin_perms: HashSet<_> =
["read", "write", "delete", "admin"].iter().cloned().collect();
let user_perms: HashSet<_> =
["read", "write", "delete"].iter().cloned().collect();
let effective_perms = Intersection(admin_perms).combine(Intersection(user_perms));
// Result: ["read", "write", "delete"] - what user actually has
Real-World Example: Error Aggregation by Type
Here’s how these implementations enable sophisticated error handling:
use std::collections::HashMap;
use stillwater::Semigroup;
type ErrorsByType = HashMap<String, Vec<String>>;
fn validate_user_data(data: UserData) -> ErrorsByType {
let mut errors = HashMap::new();
// Validation errors
if !data.email.contains('@') {
errors.insert("validation".to_string(), vec!["Invalid email".to_string()]);
}
errors
}
fn check_permissions(user: &User) -> ErrorsByType {
let mut errors = HashMap::new();
if !user.has_permission("create") {
errors.insert("permission".to_string(), vec!["Unauthorized".to_string()]);
}
errors
}
// Combine error maps - errors of same type accumulate
let validation_errors = validate_user_data(data);
let permission_errors = check_permissions(&user);
let all_errors = validation_errors.combine(permission_errors);
// {
// "validation": ["Invalid email", ...],
// "permission": ["Unauthorized", ...]
// }
Testing Your Semigroup Implementation
When implementing Semigroup, test the associativity law:
use stillwater::Semigroup;
#[derive(Debug, PartialEq, Clone)]
struct MyErrors(Vec<String>);
impl Semigroup for MyErrors {
fn combine(mut self, other: Self) -> Self {
self.0.extend(other.0);
self
}
}
fn check_associativity() {
let a = MyErrors(vec!["a".to_string()]);
let b = MyErrors(vec!["b".to_string()]);
let c = MyErrors(vec!["c".to_string()]);
let left = a.clone().combine(b.clone()).combine(c.clone());
let right = a.combine(b.combine(c));
assert_eq!(left, right);
}
check_associativity();
Summary
- Semigroup is a type with an associative
combineoperation - Associativity means combining order doesn’t matter
- Built-in implementations for:
- Vec, String, and tuples (up to 12 elements)
- HashMap, BTreeMap (merge with value combining)
- HashSet, BTreeSet (union)
- Option (lifts inner Semigroup)
- Wrapper types for alternative semantics:
First<T>- keeps first valueLast<T>- keeps last valueIntersection<Set>- set intersection instead of union
- Custom implementations are easy to write
- Foundation for validation error accumulation and configuration merging
Next Steps
Now that you understand Semigroup, learn how it powers Validation!
Validation with Error Accumulation
The Problem
Standard Result types short-circuit on the first error:
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:
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
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
use stillwater::Validation;
match validation {
Validation::Success(value) => println!("Valid: {}", value),
Validation::Failure(errors) => println!("Errors: {:?}", errors),
}
Checking Status
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
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
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:
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():
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:
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:
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):
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:
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):
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):
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
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:
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:
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
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:
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
use stillwater::Validation;
// All fields validated independently
Validation::all((
validate_email(data.email),
validate_phone(data.phone),
validate_address(data.address),
))
Dependent Validation
use stillwater::Validation;
// First validate, then check dependencies
validate_email(email)
.and_then(|email| {
check_email_not_taken(email)
})
Mixed Validation
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:
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:
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):
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
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:
use stillwater::Validation;
fn validate_email(email: &str) -> Validation<&str, Vec<&str>> {
if email.contains('@') {
Validation::success(email)
} else {
Validation::failure(vec!["missing @"])
}
}
assert_eq!(validate_email("user@example.com").into_result(), Ok("user@example.com"));
assert_eq!(validate_email("invalid").into_result(), Err(vec!["missing @"]));
let result = Validation::<&str, Vec<&str>>::all((
validate_email("bad"),
validate_email("also bad"),
));
assert_eq!(result.into_result(), Err(vec!["missing @", "missing @"]));
Advanced: Custom Error Types
You can use any error type that implements Semigroup:
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
// ❌ 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()
// ❌ 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
Effects: Pure Core, Imperative Shell
Use ordinary Rust functions for domain decisions and Effect for composing fallible
operations at application boundaries. An effect is consumed when it runs; retrying means
constructing a fresh effect.
Start with a pure decision
Load the external facts a decision needs, pass input and facts to a pure function, then interpret the resulting plain plan. The decision needs no runtime or service mocks.
use stillwater::{NonEmptyVec, Validation};
#[derive(Debug, PartialEq)]
struct Plan { username: String }
#[derive(Debug, PartialEq)]
enum Rejection { EmptyUsername, UsernameTaken }
fn decide(username: String, taken: bool) -> Validation<Plan, NonEmptyVec<Rejection>> {
let name = if username.is_empty() {
Validation::fail(Rejection::EmptyUsername)
} else {
Validation::success(())
};
let available = if taken {
Validation::fail(Rejection::UsernameTaken)
} else {
Validation::success(())
};
Validation::<(), NonEmptyVec<Rejection>>::all((name, available))
.map(|_| Plan { username })
}
assert_eq!(decide("calm".into(), false).into_result(), Ok(Plan { username: "calm".into() }));
assert_eq!(
decide("".into(), true).into_result().unwrap_err().into_vec(),
vec![Rejection::EmptyUsername, Rejection::UsernameTaken],
);
The complete registration example connects this architecture to narrow repository, password-hasher, and email-sender traits. Its shell loads facts, calls the decision, then hashes, saves, and sends email in order.
Facts are snapshots. The repository must enforce uniqueness atomically when saving, using database constraints in a real adapter. A plan can therefore be rejected at commit time even if the earlier decision accepted it. Domain rejection, commit conflicts, and infrastructure failures have distinct outcomes.
Email failure leaves the user saved. Retrying the entire registration is not an email retry protocol; use an application-owned outbox or an explicit compensation protocol when delivery or rollback is a requirement. The example’s hasher is a test double.
Constructors and execution
use stillwater::prelude::*;
tokio_test::block_on(async {
let chain = pure::<_, String, ()>(20)
.map(|value| value + 1)
.and_then(|value| pure(value * 2));
assert_eq!(chain.run(&()).await, Ok(42));
let failure = fail::<i32, _, ()>("unavailable");
assert_eq!(failure.run(&()).await, Err("unavailable"));
let existing = from_result::<_, &str, ()>(Ok(7));
assert_eq!(existing.run(&()).await, Ok(7));
});
Use from_fn for synchronous fallible operations. Keep slow blocking work off an async
executor’s worker thread using the runtime’s blocking-work facilities.
use stillwater::prelude::*;
#[derive(Clone)]
struct Env { limit: usize }
tokio_test::block_on(async {
let effect = from_fn(|env: &Env| Ok::<_, &str>(env.limit))
.ensure(|limit| *limit > 0, "limit must be positive");
assert_eq!(effect.run(&Env { limit: 10 }).await, Ok(10));
});
ensure short-circuits on the first failed predicate. Use Validation in the pure
decision when independent checks must accumulate errors.
Owned and borrowed async work
from_async accepts a future whose type does not borrow the supplied environment.
Clone a cheap shared handle before building that future.
use std::sync::Arc;
use stillwater::prelude::*;
#[derive(Clone)]
struct Env { name: Arc<String> }
tokio_test::block_on(async {
let effect = from_async(|env: &Env| {
let name = Arc::clone(&env.name);
async move { Ok::<_, &str>(name.len()) }
});
assert_eq!(effect.run(&Env { name: Arc::new("water".into()) }).await, Ok(5));
});
Use from_async_ref when the operation must borrow the environment across an await.
It accepts a boxed future, adding one future allocation in the usual Box::pin usage.
use stillwater::prelude::*;
#[derive(Clone)]
struct Env { name: String }
tokio_test::block_on(async {
let effect = from_async_ref(|env: &Env| {
Box::pin(async move {
tokio::task::yield_now().await;
Ok::<_, &str>(env.name.len())
})
});
assert_eq!(effect.run(&Env { name: "water".into() }).await, Ok(5));
});
Concrete types and explicit type erasure
Most combinators store their inputs and closures directly. Return impl Effect when
callers do not need to name that concrete type. Use BoxedEffect at boundaries that
require a single erased type, such as heterogeneous collections or different branches.
use stillwater::prelude::*;
fn choose(cached: bool) -> BoxedEffect<i32, &'static str, ()> {
if cached {
pure(21).map(|value| value * 2).boxed()
} else {
fail("cache miss").boxed()
}
}
tokio_test::block_on(async {
assert_eq!(choose(true).run(&()).await, Ok(42));
assert_eq!(choose(false).run(&()).await, Err("cache miss"));
});
Concrete types do not promise allocation-free execution or a fixed performance ratio.
from_async_ref boxes its future; helpers returning BoxedEffect (including IO,
effect traversal, and retry) erase types internally. User operations, captured data,
and environment clones may allocate too. Benchmark the actual workflow.
Choose execution semantics explicitly
Use and_then when an operation needs an earlier output. Use sequential traversal for
ordered batches that stop on failure. Parallel traversal concurrently polls the batch,
waits for every result, and selects an error by input position.
These operations do not provide transactions or cancellation masking. Dropping the
parent future cancels unfinished work. Runtime bracket cleanup is an explicit protocol
for normal success/error paths, not a guarantee that async cleanup runs after cancellation
or panic; ordinary Rust ownership and Drop still matter.
See traversal, parallel effects, reader helpers, testing, and API tiers for focused examples.
Error Context at Boundaries
Keep domain errors structured for decisions and matching. Add contextual information when an infrastructure operation crosses an application boundary.
Wrap an error
use stillwater::ContextError;
let error = ContextError::new("connection refused")
.context("loading registration facts");
assert_eq!(error.inner(), &"connection refused");
assert_eq!(error.context_trail(), &["loading registration facts"]);
Add context to an effect
use stillwater::prelude::*;
tokio_test::block_on(async {
let effect = fail::<(), _, ()>("connection refused")
.context("loading registration facts")
.context_chain("registering user");
let error = effect.run(&()).await.unwrap_err();
assert_eq!(error.inner(), &"connection refused");
assert_eq!(
error.context_trail(),
&["loading registration facts", "registering user"],
);
});
The first context wraps the error in ContextError. Use context_chain to append
information to that same wrapper; calling context again would nest wrappers.
Preserve successful values
use stillwater::prelude::*;
tokio_test::block_on(async {
let effect = pure::<_, &str, ()>(42).context("calculating total");
assert_eq!(effect.run(&()).await, Ok(42));
});
Context strings can allocate. Put useful operation names and identifiers at boundaries, avoid secrets such as passwords or tokens, and retain the underlying error for callers. A context trail does not imply that preceding writes were rolled back.
See effects for separating domain rejection from infrastructure failures, and testing for testing error propagation.
IO Module: Ergonomic Effect Creation
The IO module provides convenient helpers for creating Effects from I/O operations.
Core Functions
IO::read - Read-only operations
For queries that don’t modify state:
use stillwater::IO;
struct Database { /* ... */ }
let effect = IO::read(|db: &Database| {
db.fetch_user(123)
});
IO::write - Mutating operations
For operations that modify state (uses interior mutability):
use stillwater::IO;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
struct Cache {
data: Arc<Mutex<HashMap<u64, String>>>,
}
let effect = IO::write(|cache: &Cache| {
cache.data.lock().unwrap().insert(key, value);
});
IO::read_async and IO::write_async - Async operations
For async I/O, use read_async for query-style operations and write_async for operations that mutate through interior mutability:
use stillwater::IO;
use std::future::ready;
let read_effect = IO::read_async(|db: &Database| {
let user = db.fetch_user(123);
ready(user)
});
let write_effect = IO::write_async(|cache: &Cache| {
cache.set(key, value);
ready(())
});
Environment Pattern
IO uses AsRef<T> for automatic dependency extraction:
struct AppEnv {
db: Database,
cache: Cache,
}
impl AsRef<Database> for AppEnv {
fn as_ref(&self) -> &Database { &self.db }
}
impl AsRef<Cache> for AppEnv {
fn as_ref(&self) -> &Cache { &self.cache }
}
// Type inference extracts the right dependency
let effect = IO::read(|db: &Database| db.fetch_user(123));
effect.run(&env).await // AppEnv automatically provides Database
Examples
See full examples in examples/io_patterns.rs.
Next Steps
- Learn about Helper Combinators
- Back to Effects guide
Helper Combinators
Use composition to describe a small operation clearly. Prefer a named domain struct or ordinary function when a long chain makes the data flow difficult to follow.
Accumulate independent validations
use stillwater::Validation;
let values = Validation::<i32, Vec<&str>>::all((
Validation::<_, Vec<&str>>::success(1),
Validation::success(2),
));
assert_eq!(values.into_result(), Ok((1, 2)));
let failures = Validation::<i32, Vec<&str>>::all_vec(vec![
Validation::failure(vec!["name"]),
Validation::failure(vec!["email"]),
]);
assert_eq!(failures.into_result(), Err(vec!["name", "email"]));
Map, chain, and translate errors
use stillwater::prelude::*;
tokio_test::block_on(async {
let effect = pure::<_, &str, ()>(21)
.map(|value| value * 2)
.and_then(|value| from_result(Ok(value + 1)))
.map_err(str::to_string);
assert_eq!(effect.run(&()).await, Ok(43));
});
map transforms a value; and_then constructs a dependent effect; map_err translates
an error at a boundary. None of these automatically accumulates failures.
Write a small reusable wrapper
Accept an effect through its trait instead of using the removed generic Effect struct.
use stillwater::prelude::*;
fn require_positive<E>(effect: E) -> impl Effect<Output = i32, Error = &'static str, Env = E::Env>
where
E: Effect<Output = i32, Error = &'static str>,
{
effect.ensure(|value| *value > 0, "must be positive")
}
tokio_test::block_on(async {
assert_eq!(require_positive(pure::<_, &str, ()>(1)).run(&()).await, Ok(1));
assert_eq!(require_positive(pure::<_, &str, ()>(0)).run(&()).await, Err("must be positive"));
});
Retry requires a factory because running an effect consumes it. Use the existing retry and timeout helpers rather than a wrapper that pretends to rerun the same effect. See effects for the core architecture.
Try Trait Support (Nightly Feature)
Stillwater provides experimental support for Rust’s ? operator with Validation on nightly Rust.
Enabling
Add to Cargo.toml:
[dependencies]
stillwater = { version = "0.1", features = ["try_trait"] }
Use nightly Rust and opt into the unstable implementation explicitly:
RUSTFLAGS="--cfg try_trait_nightly" cargo +nightly test --features try_trait
Applications that use ? with Validation must also add #![feature(try_trait_v2)]
to their crate root.
Using ? with Validation
#![feature(try_trait_v2)]
use stillwater::Validation;
fn validate_user(email: &str, age: u8) -> Validation<User, Vec<Error>> {
let email = validate_email(email)?; // ⚠️ Short-circuits!
let age = validate_age(age)?;
Ok(User { email, age })
}
Warning: Using ? with Validation short-circuits on first error, defeating the purpose of error accumulation!
Recommendation: Don’t use ? with Validation. Use Validation::all() instead:
// ✓ Better: accumulates all errors
Validation::all((
validate_email(email),
validate_age(age),
))
.map(|(email, age)| User { email, age })
Should You Use This Feature?
Pros:
- Familiar
?syntax - Cleaner for sequential operations
Cons:
- Requires nightly Rust
- Requires
RUSTFLAGS="--cfg try_trait_nightly" - Defeats Validation’s purpose
and_then()is just as readable
Recommendation: Wait for stable Rust support before using in production. The feature is mainly experimental.
Migration Path
When try_trait_v2 stabilizes:
- Update to stable Rust
- Remove
#![feature(try_trait_v2)] - Keep using
Validation::all()for accumulation - Use
?with Effect where it makes sense
Next Steps
- Check the FAQ for common questions
- See Patterns for practical recipes
- Read Comparison vs other libraries
Monoid: Identity Elements for Composition
The Monoid trait extends Semigroup by adding an identity element, enabling more powerful composition patterns without requiring explicit initial values.
Overview
A Monoid is a Semigroup with an identity element. While a Semigroup provides an associative combine operation, a Monoid additionally provides an empty() element that acts as a neutral value for combination.
Laws
For a type M to be a valid Monoid, it must satisfy:
-
Associativity (from Semigroup):
a.combine(b).combine(c) == a.combine(b.combine(c)) -
Right Identity:
a.combine(M::empty()) == a -
Left Identity:
M::empty().combine(a) == a
Basic Examples
Vec Monoid
Vectors form a monoid with the empty vector as identity:
use stillwater::{Monoid, Semigroup};
let v = vec![1, 2, 3];
let empty: Vec<i32> = Monoid::empty();
assert_eq!(v.clone().combine(empty.clone()), v); // right identity
assert_eq!(empty.combine(v.clone()), v); // left identity
String Monoid
Strings form a monoid with the empty string as identity:
use stillwater::{Monoid, Semigroup};
let s = "hello".to_string();
let empty: String = Monoid::empty();
assert_eq!(s.clone().combine(empty.clone()), s);
assert_eq!(empty.combine(s.clone()), s);
Option Monoid
Options lift a Semigroup into a Monoid with None as identity:
use stillwater::{Monoid, Semigroup};
let some_vec = Some(vec![1, 2, 3]);
let none: Option<Vec<i32>> = None;
// None is identity
assert_eq!(some_vec.clone().combine(none.clone()), some_vec);
// Some values combine their contents
let v1 = Some(vec![1, 2]);
let v2 = Some(vec![3, 4]);
assert_eq!(v1.combine(v2), Some(vec![1, 2, 3, 4]));
Numeric Monoids
Since Rust primitives can’t implement external traits and numbers have multiple valid monoid instances (addition vs multiplication), Stillwater provides wrapper types.
Sum Monoid
Addition with 0 as identity:
use stillwater::monoid::{Sum, fold_all};
use stillwater::Semigroup;
let total = Sum(5).combine(Sum(10));
assert_eq!(total, Sum(15));
// Identity
let s = Sum(42);
let empty: Sum<i32> = Monoid::empty();
assert_eq!(s.combine(empty), Sum(42));
// Fold multiple values
let numbers = vec![Sum(1), Sum(2), Sum(3), Sum(4)];
let result = fold_all(numbers);
assert_eq!(result, Sum(10));
Product Monoid
Multiplication with 1 as identity:
use stillwater::monoid::{Product, fold_all};
let result = Product(5).combine(Product(10));
assert_eq!(result, Product(50));
// Fold multiple values
let numbers = vec![Product(2), Product(3), Product(4)];
let total = fold_all(numbers);
assert_eq!(total, Product(24));
Max and Min
Maximum and minimum operations (note: these are Semigroups but not Monoids without bounded types):
use stillwater::monoid::{Max, Min};
use stillwater::Semigroup;
let max = Max(5).combine(Max(10));
assert_eq!(max, Max(10));
let min = Min(5).combine(Min(10));
assert_eq!(min, Min(5));
For unbounded types, use Option<Max<T>> or Option<Min<T>> to get a Monoid:
use stillwater::{Monoid, Semigroup};
use stillwater::monoid::Max;
let m1: Option<Max<i32>> = Some(Max(5));
let m2: Option<Max<i32>> = Some(Max(10));
let empty: Option<Max<i32>> = Monoid::empty();
assert_eq!(m1.combine(m2), Some(Max(10)));
assert_eq!(m1.combine(empty), m1);
Utility Functions
fold_all
The fold_all function leverages the identity element to fold a collection without requiring an initial value:
use stillwater::monoid::fold_all;
// Combine vectors
let vecs = vec![vec![1, 2], vec![3, 4], vec![5]];
let result: Vec<i32> = fold_all(vecs);
assert_eq!(result, vec![1, 2, 3, 4, 5]);
// Combine strings
let strings = vec![
"Hello".to_string(),
" ".to_string(),
"World".to_string(),
];
let result = fold_all(strings);
assert_eq!(result, "Hello World");
reduce
Alias for fold_all:
use stillwater::monoid::reduce;
let vecs = vec![vec![1], vec![2], vec![3]];
let result: Vec<i32> = reduce(vecs);
assert_eq!(result, vec![1, 2, 3]);
Tuple Monoids
Tuples of monoids are themselves monoids, combining component-wise:
use stillwater::{Monoid, Semigroup};
use stillwater::monoid::fold_all;
let t1 = (vec![1], "a".to_string());
let t2 = (vec![2], "b".to_string());
let result = t1.combine(t2);
assert_eq!(result, (vec![1, 2], "ab".to_string()));
// Identity
let empty: (Vec<i32>, String) = Monoid::empty();
assert_eq!(empty, (vec![], "".to_string()));
// Fold multiple tuples
let tuples = vec![
(vec![1], "a".to_string()),
(vec![2], "b".to_string()),
(vec![3], "c".to_string()),
];
let result = fold_all(tuples);
assert_eq!(result, (vec![1, 2, 3], "abc".to_string()));
Integration with Validation
Monoids work seamlessly with Validation for combining results:
use stillwater::{Validation, Monoid};
use stillwater::monoid::fold_all;
let validations = vec![
Validation::success(vec![1]),
Validation::success(vec![2, 3]),
Validation::success(vec![4]),
];
let result = fold_all(validations);
assert_eq!(result, Validation::success(vec![1, 2, 3, 4]));
Parallel Reduction
Monoids enable parallel reduction because the identity element allows splitting work:
use stillwater::monoid::{Sum, fold_all};
// These can be computed in parallel and combined
let chunk1 = vec![Sum(1), Sum(2), Sum(3)];
let chunk2 = vec![Sum(4), Sum(5), Sum(6)];
let result1 = fold_all(chunk1);
let result2 = fold_all(chunk2);
let total = result1.combine(result2);
assert_eq!(total, Sum(21));
When to Use Monoid vs Semigroup
Use Monoid when:
- You need a default/empty value
- You want to fold without an initial value
- You’re implementing parallel reduction
- You want more ergonomic API (
fold_allvsfold)
Use Semigroup when:
- No natural identity element exists (e.g., non-empty lists)
- You always have at least one value to start with
- The type doesn’t support a meaningful empty state
Custom Monoid Implementations
Implement Monoid for your own types by first implementing Semigroup:
use stillwater::{Semigroup, Monoid};
#[derive(Debug, Clone, PartialEq)]
struct ValidationErrors(Vec<String>);
impl Semigroup for ValidationErrors {
fn combine(mut self, other: Self) -> Self {
self.0.extend(other.0);
self
}
}
impl Monoid for ValidationErrors {
fn empty() -> Self {
ValidationErrors(Vec::new())
}
}
// Now you can use fold_all
let errors = vec![
ValidationErrors(vec!["error1".to_string()]),
ValidationErrors(vec!["error2".to_string()]),
];
let result = fold_all(errors);
assert_eq!(result.0, vec!["error1", "error2"]);
Common Patterns
Accumulating Results
use stillwater::monoid::{Sum, fold_all};
fn calculate_total(items: Vec<i32>) -> Sum<i32> {
fold_all(items.into_iter().map(Sum))
}
let total = calculate_total(vec![1, 2, 3, 4, 5]);
assert_eq!(total, Sum(15));
Combining Configurations
use stillwater::{Semigroup, Monoid, monoid::fold_all};
#[derive(Debug, Clone, PartialEq)]
struct Config {
values: Vec<String>,
}
impl Semigroup for Config {
fn combine(mut self, other: Self) -> Self {
self.values.extend(other.values);
self
}
}
impl Monoid for Config {
fn empty() -> Self {
Config { values: Vec::new() }
}
}
let configs = vec![
Config { values: vec!["a".to_string()] },
Config { values: vec!["b".to_string(), "c".to_string()] },
];
let merged = fold_all(configs);
assert_eq!(merged.values, vec!["a", "b", "c"]);
Best Practices
- Verify laws: Ensure your implementation satisfies identity and associativity
- Use property-based tests: Test laws with many random inputs
- Choose appropriate wrapper: Use Sum vs Product based on your domain
- Leverage fold_all: More ergonomic than manual folding
- Document identity: Make it clear what the empty value represents
- Consider performance: Monoid operations should be cheap to enable frequent combination
See Also
- Semigroup Guide - Foundation for Monoid
- Validation Guide - Using monoids for error accumulation
- Effects Guide - Combining effects with monoids
Reader Helpers and Explicit Environments
An effect’s environment contains the dependencies needed by its imperative boundary. Keep business decisions in ordinary functions accepting plain data. Reader helpers provide access to configuration and services without introducing global state.
Project a value with asks
use stillwater::prelude::*;
#[derive(Clone)]
struct Config { retry_limit: usize, region: String }
tokio_test::block_on(async {
let effect = asks::<_, &str, Config, _>(|config| config.region.clone());
let config = Config { retry_limit: 3, region: "west".into() };
assert_eq!(effect.run(&config).await, Ok("west".to_string()));
});
Use from_fn for fallible synchronous access and from_async_ref for service calls
that borrow the environment across an await. asks itself produces a successful value.
Clone the environment with ask
use stillwater::prelude::*;
#[derive(Clone, Debug, PartialEq)]
struct Config { limit: usize }
tokio_test::block_on(async {
let env = Config { limit: 3 };
assert_eq!(ask::<&str, Config>().run(&env).await, Ok(env));
});
ask clones the whole environment. Store shared services behind cheap handles such as
Arc; do not assume that a Clone bound means cloning is free.
Scope configuration with local
use stillwater::prelude::*;
#[derive(Clone)]
struct Config { limit: usize }
tokio_test::block_on(async {
let read_limit = asks::<_, &str, Config, _>(|config| config.limit);
let scoped = local(|config: &Config| Config { limit: config.limit + 1 }, read_limit);
let env = Config { limit: 3 };
assert_eq!(scoped.run(&env).await, Ok(4));
assert_eq!(env.limit, 3);
});
The transformed environment is local to the inner effect. This does not clone or isolate
external state behind shared handles: two environments containing the same Arc still
refer to the same service.
The registration example uses an environment of narrow service traits. See effects for owned and borrowed async constructors, and API tiers for choosing the smallest API.
Parallel Effect Execution
Overview
Stillwater provides free functions for running independent effects concurrently while preserving the same environment and error model used by sequential effects.
There are two families of parallel helpers:
- Fixed-arity helpers:
par2,par3, andpar4for heterogeneous effects without boxing. - Collection helpers:
par_all,par_try_all,race, andpar_all_limitfor homogeneousVec<BoxedEffect<...>>batches.
This guide focuses on when to use each helper and how to structure real application code around them.
Why Parallel Effects?
Many real-world operations are independent and can run concurrently:
- Fetching multiple records from a database
- Making several API calls at once
- Loading independent configuration sources
- Validating independent inputs
- Processing batches with a concurrency limit
Sequential execution waits for each operation before starting the next:
let user = fetch_user(id).run(&env).await?;
let settings = fetch_settings(id).run(&env).await?;
let preferences = fetch_preferences(id).run(&env).await?;
Parallel execution starts independent work together:
use stillwater::effect::prelude::*;
let (user, settings, preferences) = par3(
fetch_user(id),
fetch_settings(id),
fetch_preferences(id),
&env,
).await;
let profile = UserProfile {
user: user?,
settings: settings?,
preferences: preferences?,
};
Choosing A Helper
| Need | Helper | Shape |
|---|---|---|
| 2-4 effects with different output types | par2, par3, par4 | Returns a tuple of Results |
| A batch where all errors should be reported | par_all | Result<Vec<T>, Vec<E>> |
| A batch where one error is enough | par_try_all | Result<Vec<T>, E> |
| The first completed result should decide | race | Result<T, E> |
| A large batch needs bounded concurrency | par_all_limit | Result<Vec<T>, Vec<E>> |
Collection helpers require boxed effects because a Vec needs one concrete item type:
use stillwater::effect::prelude::*;
let effects: Vec<BoxedEffect<User, DbError, AppEnv>> = user_ids
.into_iter()
.map(|id| fetch_user(id).boxed())
.collect();
let users = par_all(effects, &env).await?;
Heterogeneous Parallel Effects
Use par2, par3, or par4 when effects have different output types, or when you want to avoid boxing.
use stillwater::effect::prelude::*;
let (price, inventory, shipping) = par3(
fetch_price(item_id),
fetch_inventory(item_id),
fetch_shipping_options(item_id),
&env,
).await;
let quote = Quote {
price: price?,
inventory: inventory?,
shipping: shipping?,
};
These helpers return a tuple of results instead of short-circuiting. That makes each outcome explicit:
let (database, cache) = par2(check_database(), check_cache(), &env).await;
match (database, cache) {
(Ok(db), Ok(cache)) => Health::healthy(db, cache),
(db_result, cache_result) => Health::degraded(db_result.err(), cache_result.err()),
}
This is useful for diagnostics and health checks where you want to inspect every independent subsystem.
par_all - Collect All Results Or All Errors
Use par_all when every operation should run to completion and callers benefit from a complete error report.
use stillwater::effect::prelude::*;
async fn validate_import(records: Vec<Record>, env: &AppEnv) -> Result<Vec<ValidRecord>, Vec<ValidationError>> {
let effects: Vec<BoxedEffect<ValidRecord, ValidationError, AppEnv>> = records
.into_iter()
.map(|record| validate_record(record).boxed())
.collect();
par_all(effects, env).await
}
If any effect fails, par_all returns all failures:
let effects: Vec<BoxedEffect<i32, String, ()>> = vec![
pure(1).boxed(),
fail("bad input".to_string()).boxed(),
fail("missing field".to_string()).boxed(),
];
let result = par_all(effects, &()).await;
assert_eq!(
result,
Err(vec!["bad input".to_string(), "missing field".to_string()])
);
This is the right choice for form validation, import validation, batch reporting, and admin tools where users need a full list of failures.
par_try_all - Return A Single Error
Use par_try_all when one error is enough for the caller.
use stillwater::effect::prelude::*;
async fn load_required_services(env: &AppEnv) -> Result<Vec<ServiceStatus>, ServiceError> {
let checks: Vec<BoxedEffect<ServiceStatus, ServiceError, AppEnv>> = vec![
check_database().boxed(),
check_cache().boxed(),
check_queue().boxed(),
];
par_try_all(checks, env).await
}
par_try_all awaits the batch and then collects with normal Result semantics, returning the first error in result order. It is not a cancellation primitive.
let effects: Vec<BoxedEffect<i32, String, ()>> = vec![
pure(1).boxed(),
fail("first error".to_string()).boxed(),
fail("second error".to_string()).boxed(),
];
let result = par_try_all(effects, &()).await;
assert_eq!(result, Err("first error".to_string()));
Use par_all when you need every error. Use par_try_all when the caller only needs to know that the batch failed.
race - First Completed Result
Use race when the first completed result should decide the outcome.
use stillwater::effect::prelude::*;
async fn fetch_from_fastest_replica(
key: String,
env: &AppEnv,
) -> Result<Data, RaceError<FetchError>> {
let effects: Vec<BoxedEffect<Data, FetchError, AppEnv>> = vec![
fetch_from_replica_a(key.clone()).boxed(),
fetch_from_replica_b(key.clone()).boxed(),
fetch_from_replica_c(key).boxed(),
];
race(effects, env).await
}
race returns the first completed result, whether success or error. It does not wait to find the first success.
let effects: Vec<BoxedEffect<i32, String, ()>> = vec![
fail("fast failure".to_string()).boxed(),
pure(42).boxed(),
];
let result = race(effects, &()).await;
assert_eq!(result, Err(RaceError::Inner("fast failure".to_string())));
This behavior is useful for deadline effects, fastest-result wins workflows, or cases where a fast failure should abort the attempt. For fallback semantics where failures should be ignored until every source fails, compose effects with or_else, fallback_to, or explicit retry/fallback logic instead of race.
par_all_limit - Bounded Concurrency
Use par_all_limit for large batches or limited resources such as connection pools, file descriptors, or API rate limits.
use stillwater::effect::prelude::*;
async fn process_queue(
queue: Vec<WorkItem>,
max_concurrent: usize,
env: &AppEnv,
) -> Result<Vec<ProcessedItem>, Vec<ProcessingError>> {
let effects: Vec<BoxedEffect<ProcessedItem, ProcessingError, AppEnv>> = queue
.into_iter()
.map(|item| process_item(item).boxed())
.collect();
par_all_limit(effects, max_concurrent, env).await
}
The function still runs every effect and collects all errors, but it only keeps limit futures in flight at once.
let effects: Vec<BoxedEffect<i32, String, ()>> = (1..=10)
.map(|n| pure(n).boxed())
.collect();
let result = par_all_limit(effects, 3, &()).await;
assert_eq!(result.as_ref().map(|values| values.len()), Ok(10));
Environment Access
Parallel helpers receive a shared &Env. Boxed collection helpers require Env: Clone + Send + Sync + 'static, so application environments usually store services in cheap-to-clone handles:
use std::sync::Arc;
#[derive(Clone)]
struct AppEnv {
config: Arc<Config>,
db: Arc<DatabasePool>,
http: Arc<HttpClient>,
}
Each effect still controls how it uses the environment:
use stillwater::effect::prelude::*;
fn fetch_user(id: UserId) -> impl Effect<Output = User, Error = DbError, Env = AppEnv> {
from_async(move |env: &AppEnv| {
let db = env.db.clone();
async move { db.fetch_user(id).await }
})
}
Composing Parallel And Sequential Work
Parallel work often appears inside a larger sequential workflow. Use normal Rust control flow around the async helper calls:
use stillwater::effect::prelude::*;
async fn build_dashboard(user_id: UserId, env: &AppEnv) -> Result<Dashboard, AppError> {
let user = fetch_user(user_id).run(env).await?;
let (activity, recommendations, alerts) = par3(
fetch_activity(user.id),
fetch_recommendations(user.id),
fetch_alerts(user.id),
env,
).await;
Ok(Dashboard {
user,
activity: activity?,
recommendations: recommendations?,
alerts: alerts?,
})
}
For a second parallel phase, build another effect collection after the first phase succeeds:
async fn load_and_save(ids: Vec<UserId>, env: &AppEnv) -> Result<Vec<Receipt>, Vec<AppError>> {
let load_effects: Vec<BoxedEffect<User, AppError, AppEnv>> = ids
.into_iter()
.map(|id| fetch_user(id).boxed())
.collect();
let users = par_all(load_effects, env).await?;
let save_effects: Vec<BoxedEffect<Receipt, AppError, AppEnv>> = users
.into_iter()
.map(|user| save_user_snapshot(user).boxed())
.collect();
par_all(save_effects, env).await
}
Practical Patterns
Parallel Validation
Use par_all when expensive validation checks can run independently and the user should see all failures:
async fn validate_signup(data: SignupData, env: &AppEnv) -> Result<ValidSignup, Vec<SignupError>> {
let effects: Vec<BoxedEffect<FieldCheck, SignupError, AppEnv>> = vec![
validate_email(data.email).boxed(),
validate_username(data.username).boxed(),
validate_password(data.password).boxed(),
];
let checks = par_all(effects, env).await?;
Ok(ValidSignup::from_checks(checks))
}
Health Checks
Use fixed-arity helpers when each subsystem has a distinct result:
async fn health(env: &AppEnv) -> HealthReport {
let (database, cache, queue) = par3(
check_database(),
check_cache(),
check_queue(),
env,
).await;
HealthReport {
database,
cache,
queue,
}
}
Rate-Limited API Imports
Use par_all_limit when the remote system enforces a concurrency cap:
async fn import_customers(customers: Vec<Customer>, env: &AppEnv) -> ImportSummary {
let effects: Vec<BoxedEffect<ImportReceipt, ImportError, AppEnv>> = customers
.into_iter()
.map(|customer| send_customer(customer).boxed())
.collect();
match par_all_limit(effects, 10, env).await {
Ok(receipts) => ImportSummary::success(receipts),
Err(errors) => ImportSummary::failure(errors),
}
}
Fastest Completed Source
Use race only when “first completed” is the desired behavior:
async fn query_fastest_index(
term: SearchTerm,
env: &AppEnv,
) -> Result<SearchResults, RaceError<SearchError>> {
let effects: Vec<BoxedEffect<SearchResults, SearchError, AppEnv>> = vec![
query_primary_index(term.clone()).boxed(),
query_replica_index(term).boxed(),
];
race(effects, env).await
}
If a fast failure should not win, use a fallback chain:
fn query_with_fallback(term: SearchTerm) -> impl Effect<Output = SearchResults, Error = SearchError, Env = AppEnv> {
query_primary_index(term.clone())
.fallback_to(query_replica_index(term))
}
Performance Considerations
Actual Concurrency
The parallel helpers use async concurrency. They do not spawn OS threads by themselves; each effect must be asynchronous or otherwise yield for concurrency to matter.
use stillwater::effect::prelude::*;
use std::time::{Duration, Instant};
let start = Instant::now();
let effects: Vec<BoxedEffect<(), String, ()>> = (0..3)
.map(|_| {
from_async(|_: &()| async {
tokio::time::sleep(Duration::from_millis(100)).await;
Ok::<_, String>(())
})
.boxed()
})
.collect();
par_all(effects, &()).await.unwrap();
let elapsed = start.elapsed();
assert!(elapsed < Duration::from_millis(200));
Boxing Cost
Fixed-arity helpers avoid boxing and are the best fit for small, known sets of independent effects.
Collection helpers require boxing because a vector needs one concrete type. Prefer collection helpers for dynamic or large batches where the allocation cost is dominated by I/O.
Memory Usage
par_allandpar_try_allkeep the whole batch in flight.racekeeps the whole batch in flight until the first result completes.par_all_limitkeeps at mostlimiteffects in flight and is the safer default for large batches.
Testing Parallel Effects
Parallel effects use the same environment pattern as sequential effects:
use stillwater::prelude::*;
tokio_test::block_on(async {
let effects: Vec<BoxedEffect<u64, &str, ()>> = vec![
pure(1).boxed(),
fail("missing").boxed(),
pure(3).boxed(),
];
let users = par_all(effects, &()).await;
assert_eq!(users, Err(vec!["missing"]));
let effects = vec![pure::<_, &str, ()>(1).boxed(), pure(3).boxed()];
assert_eq!(par_all(effects, &()).await, Ok(vec![1, 3]));
});
This checks result collection, not overlap. For concurrency tests, use a barrier or channel to control child progress and a timeout to detect deadlock. Avoid exact timing assertions.
Common Pitfalls
Do Not Parallelize Dependent Operations
// Wrong: sending the email needs the user returned by create_user.
let effects: Vec<BoxedEffect<(), AppError, AppEnv>> = vec![
create_user(data).map(|_| ()).boxed(),
send_welcome_email(user_id).boxed(),
];
// Right: compose dependent work sequentially.
create_user(data)
.and_then(|user| send_welcome_email(user.id))
Do Not Use race For “First Success”
race returns the first completed result. A fast error wins over a slower success. If you need “try primary, then backup,” use fallback_to or or_else.
Use Arc, Not Rc, In Shared Environments
// Wrong: Rc is not Send + Sync.
struct AppEnv {
db: Rc<DatabasePool>,
}
// Right: Arc works in async shared environments.
#[derive(Clone)]
struct AppEnv {
db: Arc<DatabasePool>,
}
Box At Collection Boundaries
Keep child effects concrete where useful, and box them when a collection requires type erasure. Borrowed-future constructors and other helpers may allocate internally as well:
fn fetch_user(id: UserId) -> impl Effect<Output = User, Error = DbError, Env = AppEnv> {
from_async(move |env: &AppEnv| {
let db = env.db.clone();
async move { db.fetch_user(id).await }
})
}
let effects: Vec<BoxedEffect<User, DbError, AppEnv>> = ids
.into_iter()
.map(|id| fetch_user(id).boxed())
.collect();
Summary
- Use
par2,par3, andpar4for small heterogeneous sets without boxing. - Use
par_allwhen all errors should be reported. - Use
par_try_allwhen one error is enough, but do not treat it as cancellation. - Use
racewhen the first completed result should decide the outcome. - Use
par_all_limitto protect connection pools, memory, rate limits, and other bounded resources.
Traverse and Sequence Patterns
Traversal maps a function over inputs and combines its results. Sequencing combines results or effects that have already been constructed.
Validation accumulates errors
traverse and sequence in this module operate on Validation. They preserve input
order and combine errors using Semigroup.
use stillwater::{Validation, traverse::{traverse, sequence}};
fn positive(value: i32) -> Validation<i32, Vec<i32>> {
if value > 0 { Validation::success(value) } else { Validation::failure(vec![value]) }
}
assert_eq!(traverse([1, 2, 3], positive), Validation::Success(vec![1, 2, 3]));
assert_eq!(traverse([1, -2, -3], positive), Validation::Failure(vec![-2, -3]));
assert_eq!(traverse([], positive), Validation::Success(vec![]));
let checks = [positive(-1), positive(2), positive(-3)];
assert_eq!(sequence(checks), Validation::Failure(vec![-1, -3]));
Sequential effect traversal
traverse_effect_sequential calls the factory for one item, awaits that child, and
only then proceeds. After an error, later child effects are neither constructed nor run.
use std::sync::{Arc, Mutex};
use stillwater::prelude::*;
tokio_test::block_on(async {
let constructed = Arc::new(Mutex::new(Vec::new()));
let seen = Arc::clone(&constructed);
let effect = traverse_effect_sequential([1, 2, 3], move |item| {
seen.lock().unwrap().push(item);
if item == 2 { fail("stop").boxed() } else { pure(item).boxed() }
});
assert!(constructed.lock().unwrap().is_empty());
assert_eq!(effect.run(&()).await, Err("stop"));
assert_eq!(*constructed.lock().unwrap(), vec![1, 2]);
});
Parallel effect traversal
traverse_effect_parallel constructs all children when the returned effect executes,
then polls them concurrently. It waits for the entire batch, even after an error.
Successful values retain input order; if multiple children fail, the error at the
earliest input position is returned.
use stillwater::prelude::*;
tokio_test::block_on(async {
let effect = traverse_effect_parallel([1, 2, 3], |item| {
from_async(move |_: &()| async move {
tokio::task::yield_now().await;
Ok::<_, &'static str>(item * 2)
}).boxed()
});
assert_eq!(effect.run(&()).await, Ok(vec![2, 4, 6]));
});
Concurrent polling does not move CPU work onto separate threads. A child that blocks
its poll can block progress for the whole batch. For large I/O batches, use
par_all_limit or chunking to bound concurrency. Neither parallel traversal variant
provides rollback or keeps children running after the parent future is dropped.
Sequence existing effects
use stillwater::prelude::*;
tokio_test::block_on(async {
let ordered = sequence_effect_sequential(vec![
pure::<_, &str, ()>(1).boxed(),
pure(2).boxed(),
]);
assert_eq!(ordered.run(&()).await, Ok(vec![1, 2]));
let concurrent = sequence_effect_parallel(vec![
fail::<i32, _, ()>("first input").boxed(),
fail("second input").boxed(),
]);
assert_eq!(concurrent.run(&()).await, Err("first input"));
});
Sequential sequencing stops running children after the first error, but their construction has already happened. Use traversal when child construction must also be deferred.
Construction, memory, and testing
All four effect helpers eagerly collect their input iterator when called. Side effects
in an iterator’s next method therefore occur before execution. Traversal defers the
supplied child factory, not input enumeration. Inputs must be finite.
The effect helpers return BoxedEffect, require boxed children, and clone the environment
for execution. Prefer cheap shared service handles in the environment. Validation
traversal also materializes an intermediate vector; it makes no single-allocation or
speedup guarantee over a manual map and sequence.
Regression tests in
src/traverse.rs
cover child construction timing, strict sequential ordering, actual overlap, empty inputs,
input-ordered errors despite out-of-order completion, and completion after errors.
Use synchronization to control completion order in tests instead of assuming timer precision.
For the 1.x behavior and upgrade details, see migration.
Retry and Resilience Patterns
Overview
Stillwater provides retry helpers for Effect-based computations. Retry policies are pure data structures, and retry execution happens at the effect boundary through free functions in stillwater::effect::retry or the effect prelude when the async feature is enabled.
Why Retry Patterns?
Network requests fail. Databases have hiccups. External APIs rate-limit you. Robust applications need to handle transient failures gracefully:
Without retry:
let data = fetch_data().run(&env).await?;
With retry:
use stillwater::effect::retry::retry;
use stillwater::RetryPolicy;
use std::time::Duration;
let data = retry(
|| fetch_data(),
RetryPolicy::exponential(Duration::from_millis(100))
.with_max_retries(3),
)
.run(&env)
.await?
.into_value();
RetryPolicy
RetryPolicy is a pure data structure describing retry behavior. It is composable and testable without executing any effects.
Creating Policies
use stillwater::RetryPolicy;
use std::time::Duration;
let constant = RetryPolicy::constant(Duration::from_millis(100))
.with_max_retries(5);
let linear = RetryPolicy::linear(Duration::from_millis(100))
.with_max_retries(5);
let exponential = RetryPolicy::exponential(Duration::from_millis(100))
.with_max_retries(5);
let fibonacci = RetryPolicy::fibonacci(Duration::from_millis(100))
.with_max_retries(5);
Policy Configuration
use stillwater::RetryPolicy;
use std::time::Duration;
let policy = RetryPolicy::exponential(Duration::from_millis(100))
.with_max_retries(5)
.with_max_delay(Duration::from_secs(30));
Jitter Support
Jitter adds randomness to delays, preventing the “thundering herd” problem when many clients retry simultaneously. Enable it with the jitter feature:
stillwater = { version = "2.0", features = ["jitter"] }
use stillwater::RetryPolicy;
use std::time::Duration;
let policy = RetryPolicy::exponential(Duration::from_millis(100))
.with_jitter(0.25)
.with_max_retries(5);
Retry Functions
retry - Basic Retry
Retries an effect until it succeeds or retries are exhausted. The factory creates a fresh effect for each attempt.
use stillwater::effect::prelude::*;
use stillwater::RetryPolicy;
use std::time::Duration;
let effect = retry(
|| pure::<_, String, ()>(42),
RetryPolicy::exponential(Duration::from_millis(100))
.with_max_retries(3),
);
let success = effect.run(&()).await.unwrap();
assert_eq!(success.into_value(), 42);
retry_if - Conditional Retry
Only retries when a predicate returns true for the error. Use this to distinguish transient errors from permanent failures.
use stillwater::effect::prelude::*;
use stillwater::RetryPolicy;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
enum ApiError {
Transient,
Permanent,
}
let effect = retry_if(
|| fail::<(), _, ()>(ApiError::Permanent),
RetryPolicy::constant(Duration::from_millis(10)).with_max_retries(3),
|err| matches!(err, ApiError::Transient),
);
assert_eq!(effect.run(&()).await, Err(ApiError::Permanent));
retry_with_hooks - Retry With Observability
retry_with_hooks invokes a synchronous callback before each retry. Use the hook for logging, metrics, or lightweight alerting.
#[cfg(all(feature = "async", feature = "tracing"))]
{
use stillwater::effect::prelude::*;
use stillwater::{RetryEvent, RetryPolicy};
use std::time::Duration;
let effect = retry_with_hooks(
|| pure::<_, String, ()>(42),
RetryPolicy::exponential(Duration::from_millis(100)).with_max_retries(3),
|event: &RetryEvent<'_, String>| {
tracing::warn!(
attempt = event.attempt,
next_delay = ?event.next_delay,
"retrying failed operation"
);
},
);
}
The RetryEvent contains:
attempt- Which attempt just failed, using 1-based numberingerror- The error that occurrednext_delay- How long until the next retry, orNonewhen exhaustedelapsed- Total time elapsed since the first attempt
Timeout Support
with_timeout
Wrap an effect with a timeout:
use stillwater::effect::prelude::*;
use stillwater::TimeoutError;
use std::time::Duration;
let effect = with_timeout(
from_async(|_: &()| async {
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<_, String>(42)
}),
Duration::from_millis(1),
);
match effect.run(&()).await {
Err(TimeoutError::Timeout { duration }) => {
assert_eq!(duration, Duration::from_millis(1));
}
other => panic!("expected timeout, got {:?}", other),
}
Combining Retry With Timeout
A common pattern is a per-attempt timeout inside a retry factory:
use stillwater::effect::prelude::*;
use stillwater::{RetryPolicy, TimeoutError};
use std::time::Duration;
let effect = retry(
|| {
with_timeout(fetch_data(), Duration::from_secs(5))
.map_err(|err| match err {
TimeoutError::Timeout { .. } => ApiError::Transient("timeout".into()),
TimeoutError::Inner(err) => err,
})
},
RetryPolicy::exponential(Duration::from_millis(100))
.with_max_retries(3),
);
Result Types
RetryExhausted<E>
retry and retry_with_hooks return retry metadata on both outcomes:
Ok(RetryExhausted<T>)when an attempt eventually succeedsErr(RetryExhausted<E>)when retries are exhausted
The wrapper currently stores the inner value in final_error and exposes into_value() for extraction.
use std::time::Duration;
pub struct RetryExhausted<E> {
pub final_error: E,
pub attempts: u32,
pub total_duration: Duration,
}
TimeoutError<E>
with_timeout wraps timeout and inner errors:
pub enum TimeoutError<E> {
Timeout { duration: Duration },
Inner(E),
}
Real-World Patterns
HTTP Client With Retry
use stillwater::effect::prelude::*;
use stillwater::RetryPolicy;
use std::time::Duration;
#[derive(Debug, Clone)]
enum HttpError {
Timeout,
ServerError(u16),
ClientError(u16),
}
fn is_retryable(err: &HttpError) -> bool {
matches!(err, HttpError::Timeout | HttpError::ServerError(_))
}
let effect = retry_if(
|| http_get(url),
RetryPolicy::exponential(Duration::from_millis(100))
.with_max_retries(5)
.with_max_delay(Duration::from_secs(30)),
is_retryable,
);
Database Connection With Hooks
use stillwater::effect::prelude::*;
use stillwater::RetryPolicy;
use std::time::Duration;
let effect = retry_with_hooks(
|| connect_to_db(),
RetryPolicy::exponential(Duration::from_secs(1))
.with_max_retries(10)
.with_max_delay(Duration::from_secs(60)),
|event| {
if event.attempt >= 3 {
tracing::error!(attempt = event.attempt, "database connection still failing");
}
},
);
Robust API Call
Combine per-attempt timeout, conditional retry, max delay, jitter, and hooks when calling an unreliable external service:
use stillwater::effect::prelude::*;
use stillwater::{RetryEvent, RetryPolicy, TimeoutError};
use std::time::Duration;
#[derive(Debug, Clone)]
enum ApiError {
Transient(String),
Permanent(String),
}
fn is_retryable(err: &ApiError) -> bool {
matches!(err, ApiError::Transient(_))
}
let policy = RetryPolicy::exponential(Duration::from_millis(500))
.with_max_retries(5)
.with_max_delay(Duration::from_secs(30))
.with_jitter(0.25);
let effect = retry_with_hooks(
|| {
with_timeout(call_api(), Duration::from_secs(10))
.map_err(|err| match err {
TimeoutError::Timeout { .. } => ApiError::Transient("timeout".into()),
TimeoutError::Inner(err) => err,
})
.and_then(|response| {
if response.status().is_server_error() {
fail(ApiError::Transient(format!("status {}", response.status())))
} else if response.status().is_client_error() {
fail(ApiError::Permanent(format!("status {}", response.status())))
} else {
pure(response)
}
})
},
policy,
|event: &RetryEvent<'_, ApiError>| {
tracing::warn!(
attempt = event.attempt,
next_delay = ?event.next_delay,
"retrying API call"
);
},
);
For conditional retry without hooks, use retry_if around the same per-attempt effect:
let effect = retry_if(
|| {
with_timeout(call_api(), Duration::from_secs(10))
.map_err(|err| match err {
TimeoutError::Timeout { .. } => ApiError::Transient("timeout".into()),
TimeoutError::Inner(err) => err,
})
},
RetryPolicy::exponential(Duration::from_millis(500)).with_max_retries(5),
is_retryable,
);
Policy Testing
RetryPolicy is just data, so you can test retry timing without running any effects:
use stillwater::RetryPolicy;
use std::time::Duration;
let policy = RetryPolicy::exponential(Duration::from_millis(100))
.with_max_retries(3)
.with_max_delay(Duration::from_millis(250));
assert_eq!(policy.delay_for_attempt(0), Some(Duration::from_millis(100)));
assert_eq!(policy.delay_for_attempt(1), Some(Duration::from_millis(200)));
assert_eq!(policy.delay_for_attempt(2), Some(Duration::from_millis(250)));
assert_eq!(policy.delay_for_attempt(3), None);
Circuit-Breaker Integration
Stillwater does not include a circuit breaker in the retry module. Keep circuit state in your environment and use from_fn or check before retrying:
use stillwater::effect::prelude::*;
fn guarded_call() -> impl Effect<Output = ApiResponse, Error = ApiError, Env = AppEnv> {
from_fn(|env: &AppEnv| {
if env.circuit_breaker.is_open("api") {
Err(ApiError::Permanent("circuit open".into()))
} else {
Ok(())
}
})
.and_then(|_| call_api())
}
let effect = retry_if(
|| guarded_call(),
RetryPolicy::exponential(Duration::from_millis(250)).with_max_retries(3),
|err| matches!(err, ApiError::Transient(_)),
);
Behavior Notes
retryandretry_with_hookspreserve retry metadata on success and failure.retry_ifreturns the original success or error type directly.- Retry factories should create a fresh effect for each attempt.
- Hooks are synchronous; keep them lightweight and non-blocking.
- Jitter requires the
jitterfeature. - Timeout helpers require the
asyncfeature.
Best Practices
- Use exponential backoff for most network and service calls.
- Add jitter when many clients might retry simultaneously.
- Set
max_delayto prevent unreasonably long waits. - Use
retry_ifto avoid retrying permanent errors such as auth failures. - Add per-attempt timeouts for operations that might hang.
- Use
retry_with_hooksfor retry logs and metrics.
See Also
- examples/retry_patterns.rs - Comprehensive retry examples
- Parallel Effects - Running effects concurrently
- Error Context - Adding context to errors
Homogeneous Validation
When you have an enum where each variant forms a Semigroup, but different variants cannot be combined, use homogeneous validation to ensure type consistency before combining.
The Problem
Many Rust programs use discriminated unions (enums) where each variant represents a different type of data, but each variant can be combined with other values of the same variant:
enum Aggregate {
Sum(f64), // Sum + Sum = Sum (valid)
Count(usize), // Count + Count = Count (valid)
// But: Sum + Count = ??? (type error!)
}
In a typical scenario, you might write:
impl Semigroup for Aggregate {
fn combine(self, other: Self) -> Self {
match (self, other) {
(Aggregate::Sum(a), Aggregate::Sum(b)) => Aggregate::Sum(a + b),
(Aggregate::Count(a), Aggregate::Count(b)) => Aggregate::Count(a + b),
_ => panic!("Type mismatch!"), // 💥 Crashes at runtime!
}
}
}
Why This Matters
This pattern appears frequently in real-world code:
Aggregation Pipelines: MapReduce systems where parallel workers combine results
// Workers return different aggregate types
let results = vec![worker1.result(), worker2.result(), worker3.result()];
// If types don't match, the program crashes!
JSON/Config Merging: Combining configuration from multiple sources
// serde_json::Value has variants for Object, Array, Number, etc.
// Merging an Object with an Array doesn't make sense
Database Query Results: Combining results from sharded queries
enum QueryResult {
Rows(Vec<Row>),
Count(usize),
Affected(usize),
}
// Each shard should return the same type
Plugin Systems: Aggregating outputs from multiple plugins
enum PluginOutput {
Metrics(Vec<Metric>),
Logs(Vec<LogEntry>),
Events(Vec<Event>),
}
The Traditional Bad Options
When faced with this problem, developers typically choose one of two bad options:
- Panic on mismatch - Crashes the program at runtime
- Silent coercion - Produces wrong results without warning
Pure Core, Imperative Shell
Stillwater follows the principle of “pure core, imperative shell”, where:
- Pure core: Business logic (like
Semigroup::combine) is pure and total - Imperative shell: Validation happens at I/O boundaries
This means:
- Keep
Semigroup::combinesimple and panic-free (after validation) - Validate homogeneity at system boundaries (YAML → types, I/O → domain, workers → aggregation)
- Accumulate ALL errors (don’t fail fast)
- Provide clear error messages for debugging
Solution: Homogeneous Validation
Stillwater provides utilities to validate homogeneity before combining:
use stillwater::validation::homogeneous::validate_homogeneous;
use stillwater::Validation;
use std::mem::discriminant;
let items = vec![
Aggregate::Count(5),
Aggregate::Sum(10.0), // Wrong type!
Aggregate::Count(3),
];
let result = validate_homogeneous(
items,
|a| discriminant(a),
|idx, _got, _expected| format!("Type mismatch at index {}", idx),
);
match result {
Validation::Success(items) => {
// All items have the same type, safe to combine
let combined = items.into_iter()
.reduce(|a, b| a.combine(b))
.unwrap();
}
Validation::Failure(errors) => {
// errors = ["Type mismatch at index 1"]
// All errors reported at once!
eprintln!("Validation errors: {:?}", errors);
}
}
Key Features
- Error Accumulation: Reports ALL type mismatches, not just the first one
- Flexible Error Types: You provide the error constructor
- Generic Discriminant: Works with
std::mem::discriminantor custom logic - Explicit work: A traversal checks discriminants and collects errors; benchmark the actual workload
API Overview
Core Functions
validate_homogeneous
Validates that all items in a collection have the same discriminant:
pub fn validate_homogeneous<T, D, E>(
items: Vec<T>,
discriminant: impl Fn(&T) -> D,
make_error: impl Fn(usize, &T, &T) -> E,
) -> Validation<Vec<T>, Vec<E>>
where
D: Eq,
Parameters:
items: Collection to validatediscriminant: Function to extract discriminant for comparisonmake_error: Function to create error for type mismatch
Returns:
Validation::Success(items)if all items have the same discriminantValidation::Failure(errors)with ALL mismatches if heterogeneous
combine_homogeneous
Convenience function that validates and combines in one step:
pub fn combine_homogeneous<T, D, E>(
items: Vec<T>,
discriminant: impl Fn(&T) -> D,
make_error: impl Fn(usize, &T, &T) -> E,
) -> Validation<T, Vec<E>>
where
T: Semigroup,
D: Eq,
This is equivalent to:
validate_homogeneous(items, discriminant, make_error)
.map(|items| items.into_iter().reduce(|a, b| a.combine(b)).unwrap())
Helper Types
DiscriminantName Trait
Provides human-readable names for discriminants:
pub trait DiscriminantName {
fn discriminant_name(&self) -> &'static str;
}
Example implementation:
impl DiscriminantName for Aggregate {
fn discriminant_name(&self) -> &'static str {
match self {
Aggregate::Count(_) => "Count",
Aggregate::Sum(_) => "Sum",
Aggregate::Average(_, _) => "Average",
}
}
}
TypeMismatchError
A standardized error type for type mismatches:
pub struct TypeMismatchError {
pub index: usize,
pub expected: String,
pub got: String,
}
Use with DiscriminantName:
let result = validate_homogeneous(
items,
|a| std::mem::discriminant(a),
TypeMismatchError::new,
);
Examples
Example 1: Aggregation Pipeline
use stillwater::validation::homogeneous::combine_homogeneous;
use stillwater::{Semigroup, Validation};
use std::mem::discriminant;
#[derive(Clone, Debug, PartialEq)]
enum AggregateResult {
Count(usize),
Sum(f64),
Average(f64, usize),
}
impl Semigroup for AggregateResult {
fn combine(self, other: Self) -> Self {
match (self, other) {
(AggregateResult::Count(a), AggregateResult::Count(b)) => {
AggregateResult::Count(a + b)
}
(AggregateResult::Sum(a), AggregateResult::Sum(b)) => {
AggregateResult::Sum(a + b)
}
(AggregateResult::Average(s1, c1), AggregateResult::Average(s2, c2)) => {
AggregateResult::Average(s1 + s2, c1 + c2)
}
_ => unreachable!("Validated before combining"),
}
}
}
// Aggregate results from parallel workers
let worker_results = vec![
AggregateResult::Count(10),
AggregateResult::Count(20),
AggregateResult::Count(30),
];
let result = combine_homogeneous(
worker_results,
|r| discriminant(r),
|idx, _, _| format!("Worker {} returned different type", idx),
);
match result {
Validation::Success(combined) => {
println!("Total count: {:?}", combined);
}
Validation::Failure(errors) => {
eprintln!("Validation errors: {:?}", errors);
}
}
Example 2: JSON Config Merging
use serde_json::Value;
use stillwater::validation::homogeneous::{validate_homogeneous, DiscriminantName};
use std::mem::discriminant;
impl DiscriminantName for Value {
fn discriminant_name(&self) -> &'static str {
match self {
Value::Null => "Null",
Value::Bool(_) => "Bool",
Value::Number(_) => "Number",
Value::String(_) => "String",
Value::Array(_) => "Array",
Value::Object(_) => "Object",
}
}
}
// Load configs from multiple sources
let configs = vec![
load_default_config(), // Object
load_user_config(), // Object
load_env_config(), // Object?
];
// Ensure all are objects before merging
let result = validate_homogeneous(
configs,
|v| discriminant(v),
|idx, got, expected| {
format!(
"Config {}: expected {}, got {}",
idx,
expected.discriminant_name(),
got.discriminant_name()
)
},
);
match result {
Validation::Success(configs) => {
let merged = merge_json_objects(configs);
// Use merged config
}
Validation::Failure(errors) => {
eprintln!("Config validation errors: {:?}", errors);
}
}
Example 3: Integration with Effect
Load values in the shell, then lift the pure validation into the effect’s error channel.
use stillwater::prelude::*;
use stillwater::validation::homogeneous::validate_homogeneous;
#[derive(Clone)]
struct Env { results: Vec<i32> }
tokio_test::block_on(async {
let effect = from_fn(|env: &Env| Ok::<_, Vec<String>>(env.results.clone()))
.and_then(|results| from_validation(validate_homogeneous(
results,
|value| value % 2,
|index, _, _| format!("parity mismatch at {index}"),
)));
let result = effect.run(&Env { results: vec![2, 4, 3] }).await;
assert_eq!(result, Err(vec!["parity mismatch at 2".to_string()]));
});
Best Practices
1. Validate at Boundaries
Always validate at system boundaries, not in the middle of business logic:
✅ Good: Validate at I/O boundaries
IO::read(load_results)
.and_then(|results| validate_and_process(results))
❌ Bad: Validate in the middle of logic
fn process(items: Vec<T>) {
// ... business logic ...
validate_homogeneous(items, ...); // Too late!
// ... more logic ...
}
2. Keep Semigroup Pure
After validation, your Semigroup::combine can safely use unreachable!():
impl Semigroup for MyEnum {
fn combine(self, other: Self) -> Self {
match (self, other) {
(A(x), A(y)) => A(x + y),
(B(x), B(y)) => B(x + y),
_ => unreachable!("Call validate_homogeneous first"),
}
}
}
3. Provide Helpful Error Messages
Use DiscriminantName to create clear error messages:
impl DiscriminantName for MyEnum {
fn discriminant_name(&self) -> &'static str {
match self {
MyEnum::VariantA(_) => "VariantA",
MyEnum::VariantB(_) => "VariantB",
}
}
}
let result = validate_homogeneous(
items,
|e| discriminant(e),
TypeMismatchError::new,
);
4. Accumulate All Errors
Take advantage of error accumulation to report all issues at once:
match validate_homogeneous(...) {
Validation::Failure(errors) => {
// All errors are available
for error in errors {
eprintln!("Error: {}", error);
}
// Send to monitoring, add to DLQ, etc.
}
_ => { /* ... */ }
}
5. Compose with Other Validations
Homogeneous validation composes naturally with other validations:
let type_check = validate_homogeneous(items, discriminant, make_error);
let range_check = validate_ranges(items);
// Combine validations
let all_checks = type_check.and(range_check);
Edge Cases
Empty Collections
Empty collections always validate successfully:
let empty: Vec<MyEnum> = vec![];
let result = validate_homogeneous(empty, discriminant, make_error);
assert!(result.is_success());
Why? There are no pairs to compare, so the homogeneity property is trivially true.
Single-Item Collections
Single-item collections always validate successfully:
let single = vec![MyEnum::A(42)];
let result = validate_homogeneous(single, discriminant, make_error);
assert!(result.is_success());
Why? The item is homogeneous with itself.
After Validation
Once validation succeeds, you’re guaranteed that all items have the same discriminant. It’s safe to use unreachable!() or panic!() in the combine method for mismatched cases.
Performance
Homogeneous validation traverses the supplied collection:
- Single pass: O(n) traversal of the collection
- No allocations: Besides the error vector if validation fails
- Inlined: Discriminant and error functions are typically inlined
- Lazy evaluation: Only evaluates discriminant when needed
Measure discriminant evaluation and error construction in the actual workload; no fixed overhead ratio is guaranteed.
Common Patterns
Pattern 1: MapReduce Aggregation
// Validate before reducing
let aggregated = combine_homogeneous(
worker_results,
|r| discriminant(r),
|idx, _, _| format!("Worker {} mismatch", idx),
)?;
Pattern 2: Config Merging
// Validate configs are same type before merging
validate_homogeneous(configs, discriminant, make_error)
.and_then(|configs| merge_configs(configs))
Pattern 3: Database Sharding
// Validate shard results are consistent
combine_homogeneous(
shard_results,
|r| discriminant(r),
|idx, _, _| format!("Shard {} inconsistent", idx),
)
Pattern 4: Plugin Composition
// Validate all plugins return same output type
validate_homogeneous(
plugin_outputs,
|o| discriminant(o),
|idx, _, _| format!("Plugin {} incompatible", idx),
)
Related Concepts
- Semigroup: Homogeneous validation enables safe Semigroup usage with enums
- Validation: Uses the Validation type for error accumulation
- Effect: Composes naturally at I/O boundaries
- Pure core, imperative shell: Validation at boundaries, pure logic in core
Further Reading
- Validation Guide - Learn about error accumulation
- Semigroup Guide - Understand combining operations
- Effect Guide - Compose with I/O operations
- Philosophy - Pure core, imperative shell pattern
Refined Types: Parse, Don’t Validate
The Problem
Validation checks are often scattered throughout codebases:
fn process_user(name: String, age: i32) -> Result<User, Error> {
if name.is_empty() {
return Err(Error::EmptyName);
}
if age <= 0 {
return Err(Error::InvalidAge);
}
// What if we call another function?
// Does it also need to check name and age?
save_user(&name, age)?;
Ok(User { name, age })
}
fn save_user(name: &str, age: i32) -> Result<(), Error> {
// Do we need to validate again? Maybe...
// The type system doesn't tell us if name is valid
db.insert(name, age)
}
This leads to:
- Redundant validation checks
- Uncertainty about whether data is valid
- Runtime errors when validation is forgotten
- No compiler help to catch missing checks
The Solution: Refined Types
Refined types encode invariants in the type system. Once validated at the boundary, the type guarantees validity:
use stillwater::refined::{Refined, NonEmpty, Positive};
type NonEmptyString = Refined<String, NonEmpty>;
type PositiveI32 = Refined<i32, Positive>;
fn process_user(name: NonEmptyString, age: PositiveI32) -> User {
// name is GUARANTEED non-empty by construction
// age is GUARANTEED positive by construction
// No runtime checks needed!
save_user(&name, age)
}
fn save_user(name: &NonEmptyString, age: &PositiveI32) {
// Types guarantee validity - impossible to have invalid data here
db.insert(name.get(), *age.get())
}
Core API
Creating Refined Values
use stillwater::refined::{Refined, NonEmpty, Positive};
type NonEmptyString = Refined<String, NonEmpty>;
type PositiveI32 = Refined<i32, Positive>;
// Validate at the boundary
let name = NonEmptyString::new("Alice".to_string());
assert!(name.is_ok());
let empty = NonEmptyString::new("".to_string());
assert!(empty.is_err());
// Access the inner value (zero-cost)
let name = NonEmptyString::new("Alice".to_string()).unwrap();
println!("Name: {}", name.get()); // Reference
println!("Length: {}", name.len()); // Deref allows direct access
// Consume the wrapper
let inner: String = name.into_inner();
Transforming Refined Values
use stillwater::refined::{Refined, Positive};
type PositiveI32 = Refined<i32, Positive>;
let n = PositiveI32::new(42).unwrap();
// try_map re-checks the predicate after transformation
let doubled = n.try_map(|x| x * 2);
assert!(doubled.is_ok());
let negated = PositiveI32::new(5).unwrap().try_map(|x| -x);
assert!(negated.is_err()); // -5 is not positive
Unsafe Construction (Use with Care)
use stillwater::refined::{Refined, Positive};
type PositiveI32 = Refined<i32, Positive>;
// Only use when you KNOW the value is valid
// No predicate check is performed!
let n = PositiveI32::new_unchecked(42);
Built-in Predicates
Numeric Predicates
use stillwater::refined::{Refined, Positive, NonNegative, Negative, NonZero, InRange};
// Positive: value > 0
type PositiveI32 = Refined<i32, Positive>;
assert!(PositiveI32::new(1).is_ok());
assert!(PositiveI32::new(0).is_err());
// NonNegative: value >= 0
type NonNegativeI32 = Refined<i32, NonNegative>;
assert!(NonNegativeI32::new(0).is_ok());
assert!(NonNegativeI32::new(-1).is_err());
// Negative: value < 0
type NegativeI32 = Refined<i32, Negative>;
assert!(NegativeI32::new(-1).is_ok());
assert!(NegativeI32::new(0).is_err());
// NonZero: value != 0
type NonZeroI32 = Refined<i32, NonZero>;
assert!(NonZeroI32::new(1).is_ok());
assert!(NonZeroI32::new(-1).is_ok());
assert!(NonZeroI32::new(0).is_err());
// InRange: MIN <= value <= MAX
type Percentage = Refined<i32, InRange<0, 100>>;
assert!(Percentage::new(50).is_ok());
assert!(Percentage::new(101).is_err());
String Predicates
use stillwater::refined::{Refined, NonEmpty, Trimmed, MaxLength, MinLength};
// NonEmpty: string is not empty
type NonEmptyString = Refined<String, NonEmpty>;
assert!(NonEmptyString::new("hello".to_string()).is_ok());
assert!(NonEmptyString::new("".to_string()).is_err());
// Trimmed: no leading/trailing whitespace
type TrimmedString = Refined<String, Trimmed>;
assert!(TrimmedString::new("hello".to_string()).is_ok());
assert!(TrimmedString::new(" hello ".to_string()).is_err());
// MaxLength<N>: length <= N
type ShortString = Refined<String, MaxLength<10>>;
assert!(ShortString::new("hello".to_string()).is_ok());
assert!(ShortString::new("this is too long".to_string()).is_err());
// MinLength<N>: length >= N
type Password = Refined<String, MinLength<8>>;
assert!(Password::new("secure_password".to_string()).is_ok());
assert!(Password::new("short".to_string()).is_err());
Collection Predicates
use stillwater::refined::{Refined, NonEmpty, MaxSize, MinSize};
// NonEmpty for Vec
type NonEmptyList = Refined<Vec<i32>, NonEmpty>;
assert!(NonEmptyList::new(vec![1, 2, 3]).is_ok());
assert!(NonEmptyList::new(vec![]).is_err());
// MaxSize<N>: size <= N
type SmallVec = Refined<Vec<i32>, MaxSize<5>>;
assert!(SmallVec::new(vec![1, 2, 3]).is_ok());
assert!(SmallVec::new(vec![1, 2, 3, 4, 5, 6]).is_err());
// MinSize<N>: size >= N
type AtLeastTwo = Refined<Vec<i32>, MinSize<2>>;
assert!(AtLeastTwo::new(vec![1, 2]).is_ok());
assert!(AtLeastTwo::new(vec![1]).is_err());
Predicate Combinators
And: Both Must Hold
use stillwater::refined::{Refined, And, NonEmpty, Trimmed, MaxLength};
// String must be non-empty AND trimmed
type CleanString = Refined<String, And<NonEmpty, Trimmed>>;
assert!(CleanString::new("hello".to_string()).is_ok());
assert!(CleanString::new("".to_string()).is_err());
assert!(CleanString::new(" hello ".to_string()).is_err());
// Chain multiple with nested And
type ValidUsername = Refined<String, And<And<NonEmpty, Trimmed>, MaxLength<20>>>;
Or: At Least One Must Hold
use stillwater::refined::{Refined, Or, Positive, Negative};
// Value must be positive OR negative (i.e., non-zero)
type NonZeroAlt = Refined<i32, Or<Positive, Negative>>;
assert!(NonZeroAlt::new(5).is_ok());
assert!(NonZeroAlt::new(-5).is_ok());
assert!(NonZeroAlt::new(0).is_err());
Not: Must NOT Hold
use stillwater::refined::{Refined, Not, Positive};
// Value must NOT be positive (i.e., <= 0)
type NotPositive = Refined<i32, Not<Positive>>;
assert!(NotPositive::new(0).is_ok());
assert!(NotPositive::new(-5).is_ok());
assert!(NotPositive::new(5).is_err());
Custom Predicates
Define your own predicates by implementing the Predicate trait:
use stillwater::refined::{Refined, Predicate};
// Custom predicate for even numbers
pub struct Even;
impl Predicate<i32> for Even {
type Error = &'static str;
fn check(value: &i32) -> Result<(), Self::Error> {
if value % 2 == 0 {
Ok(())
} else {
Err("value must be even")
}
}
fn description() -> &'static str {
"even number"
}
}
type EvenI32 = Refined<i32, Even>;
assert!(EvenI32::new(42).is_ok());
assert!(EvenI32::new(41).is_err());
Type Aliases
Stillwater provides convenient aliases for common patterns:
use stillwater::refined::{
NonEmptyString, TrimmedString, NonEmptyTrimmedString,
PositiveI32, NonNegativeI64, NonZeroU32,
Port, Percentage,
BoundedString, BoundedVec,
};
// String aliases
let name = NonEmptyString::new("Alice".to_string()).unwrap();
let clean = NonEmptyTrimmedString::new("hello".to_string()).unwrap();
// Numeric aliases
let age = PositiveI32::new(25).unwrap();
let count = NonZeroU32::new(100).unwrap();
// Domain-specific aliases
let port = Port::new(443).unwrap(); // 1-65535
let progress = Percentage::new(75).unwrap(); // 0-100
// Bounded types with const generics
type Username = BoundedString<20>;
type SmallList = BoundedVec<i32, 10>;
Validation Integration
Single Validation
use stillwater::refined::{Refined, Positive};
use stillwater::Validation;
type PositiveI32 = Refined<i32, Positive>;
// Returns Validation<PositiveI32, &'static str>
let result = PositiveI32::validate(42);
assert!(result.is_success());
let result = PositiveI32::validate(-5);
assert!(result.is_failure());
Error Accumulation
use stillwater::refined::{NonEmptyString, PositiveI32};
use stillwater::Validation;
fn validate_user(
name: String,
age: i32,
) -> Validation<(NonEmptyString, PositiveI32), Vec<&'static str>> {
let v1 = NonEmptyString::validate_vec(name);
let v2 = PositiveI32::validate_vec(age);
v1.and(v2)
}
// All errors collected
let result = validate_user("".to_string(), -5);
match result {
Validation::Failure(errors) => {
assert_eq!(errors.len(), 2); // Both errors collected
}
_ => panic!(),
}
Field Context
use stillwater::refined::{NonEmptyString, FieldError, ValidationFieldExt};
use stillwater::Validation;
// Add field context to errors
let result = NonEmptyString::validate("".to_string())
.with_field("username");
match result {
Validation::Failure(err) => {
assert_eq!(err.field, "username");
println!("{}", err); // "username: string cannot be empty"
}
_ => panic!(),
}
Effect Integration
Use refined types in effect chains:
use stillwater::effect::prelude::*;
use stillwater::refined::{refine, NonEmpty, Refined};
type NonEmptyString = Refined<String, NonEmpty>;
// Validate in effect chains
let effect = pure::<_, &str, ()>("hello".to_string())
.and_then(|s| refine::<_, NonEmpty, ()>(s))
.map(|refined| refined.get().len());
let result = effect.run(&()).await;
assert_eq!(result, Ok(5));
Real-World Example
use stillwater::refined::{
Refined, And, NonEmpty, Trimmed, MaxLength, MinLength, Port,
};
use stillwater::Validation;
// Domain types with encoded invariants
type Username = Refined<String, And<And<NonEmpty, Trimmed>, MaxLength<30>>>;
type Password = Refined<String, And<MinLength<8>, MaxLength<128>>>;
type PositiveI32 = Refined<i32, Positive>;
// User with guaranteed-valid fields
struct User {
username: Username,
password: Password,
age: PositiveI32,
port: Option<Port>,
}
fn validate_registration(
username: String,
password: String,
age: i32,
port: Option<u16>,
) -> Validation<User, Vec<String>> {
let v_username = Username::validate(username)
.map_err(|e| vec![format!("username: {}", e)]);
let v_password = Password::validate(password)
.map_err(|e| vec![format!("password: {}", e)]);
let v_age = PositiveI32::validate(age)
.map_err(|e| vec![format!("age: {}", e)]);
let v_port = match port {
Some(p) => Port::validate(p)
.map(Some)
.map_err(|e| vec![format!("port: {}", e)]),
None => Validation::Success(None),
};
v_username
.and(v_password)
.and(v_age)
.and(v_port)
.map(|(((username, password), age), port)| User {
username,
password,
age,
port,
})
}
// Functions that work with validated data need no checks
fn process_user(user: User) {
// Types guarantee validity - no runtime checks needed!
println!("Processing user: {}", user.username.get());
}
Zero-Cost Abstraction
Refined<T, P> has the same memory layout as T. The predicate P exists only at compile time via PhantomData:
use std::mem::size_of;
use stillwater::refined::{Refined, Positive};
type PositiveI32 = Refined<i32, Positive>;
// Same size as the wrapped type
assert_eq!(size_of::<PositiveI32>(), size_of::<i32>());
Best Practices
- Validate at the boundary: Parse input into refined types as early as possible
- Use type aliases: Create domain-specific aliases for readability
- Combine predicates: Use
And,Or,Notfor complex constraints - Accumulate errors: Use
validate_vec()withValidation::and()for all errors - Add field context: Use
with_field()for user-friendly error messages
Next Steps
- See examples/refined.rs for more examples
- See the Validation guide for error accumulation patterns
- See the Effects guide for effect integration
Testing with Stillwater
Test domain decisions as ordinary functions, then test the shell’s observable ordering, error propagation, and persisted state using explicit service implementations.
The snippets below execute their assertions as Cargo doctests. In application test files, place equivalent bodies in ordinary Rust or Tokio tests. Do not hide doctest examples inside test-only modules: a passing doctest can otherwise contain unchecked code.
Pure decisions and accumulated errors
use stillwater::{Validation, assert_success, assert_failure, assert_validation_errors};
fn adult(age: u8) -> Validation<u8, Vec<&'static str>> {
if age >= 18 { Validation::success(age) } else { Validation::failure(vec!["underage"]) }
}
assert_success!(adult(18));
assert_failure!(adult(17));
assert_validation_errors!(adult(17), vec!["underage"]);
let checks = Validation::<u8, Vec<&str>>::all((adult(16), adult(17)));
assert_eq!(checks.into_result(), Err(vec!["underage", "underage"]));
Prefer asserting exact outputs and errors over checking only success/failure. Include boundary values and verify that independent failures accumulate.
Explicit test environments
Small environments often need no builder. MockEnv is available when tuple composition
helps construct a fixture; each with adds one nested pair.
use stillwater::testing::MockEnv;
let (((), minimum_age), users) = MockEnv::new()
.with(|| 18_u8)
.with(|| Vec::<String>::new())
.build();
assert_eq!(minimum_age, 18);
assert!(users.is_empty());
Shell ordering and failure boundaries
use std::sync::{Arc, Mutex};
use stillwater::prelude::*;
#[derive(Clone, Default)]
struct Env { events: Arc<Mutex<Vec<&'static str>>> }
tokio_test::block_on(async {
let env = Env::default();
let save = from_fn(|env: &Env| {
env.events.lock().unwrap().push("save");
Err::<(), _>("database unavailable")
});
let workflow = save.and_then(|()| from_fn(|env: &Env| {
env.events.lock().unwrap().push("email");
Ok(())
}));
assert_eq!(workflow.run(&env).await, Err("database unavailable"));
assert_eq!(*env.events.lock().unwrap(), vec!["save"]);
});
The registration example also tests stale availability facts at commit time, rejected inputs, hashing failure, save failure, and email failure after persistence. Check persisted state as well as return values: an error does not imply that earlier operations were rolled back.
Property tests
Add proptest as a development dependency. Stillwater’s optional proptest feature
also supplies Arbitrary implementations for supported library types. A runner can
execute a property directly, which works in a doctest as well as in a normal test.
use proptest::{prelude::*, test_runner::TestRunner};
use stillwater::Validation;
TestRunner::default().run(&any::<i32>(), |value| {
let result = Validation::<_, Vec<String>>::success(value).map(|x| x);
prop_assert_eq!(result, Validation::Success(value));
Ok(())
}).unwrap();
Choose domain properties too: for example, rejected registration decisions never emit plans, and independent validation errors retain deterministic order. Bound arithmetic inputs or use checked operations so overflow does not accidentally become the property.
Run the relevant targets
cargo test
cargo test --all-features
cargo test --doc --all-features
cargo test --example user_registration --all-features
Default Cargo/nextest discovery does not run the tests embedded in this example. CI selects it explicitly. The documentation contract also rejects test-only attributes inside executable snippets and checks that every book chapter is in the Cargo manifest.
Compile-Time Resource Tracking
Stillwater’s resource tracking layer lets you describe resource acquisition and release in types. It is useful when an effect opens something that must be closed, such as a file handle, database transaction, lock, socket, or custom resource.
This feature is opt-in. Existing Effect code continues to work without resource annotations.
When to Use It
Use resource tracking when:
- a workflow has explicit acquire/use/release phases
- a missing cleanup step would be a correctness bug
- the resource protocol matters, such as begin/commit/rollback
- you want function signatures to document resource behavior
For ordinary scoped cleanup, the runtime bracket helpers are often enough. Resource tracking adds type-level documentation and compile-time neutrality checks on top.
Core Types
Resource tracking is built from marker types and type-level sets:
use stillwater::effect::resource::*;
// Built-in resource markers
FileRes;
DbRes;
LockRes;
SocketRes;
TxRes;
// Type-level resource sets
Empty; // no resources
Has<FileRes>; // one tracked resource
Tracked effects implement ResourceEffect, which extends Effect with two associated resource sets:
trait ResourceEffect {
type Acquires;
type Releases;
}
These sets describe what an effect acquires and releases at the type level. The tracking wrapper has no runtime behavior beyond running the original effect.
Annotating Effects
Use extension methods to mark effects as acquiring, releasing, or neutral with respect to resources:
use stillwater::effect::resource::*;
use stillwater::{pure, Effect};
fn open_file(path: &str) -> impl ResourceEffect<
Output = String,
Error = String,
Env = (),
Acquires = Has<FileRes>,
Releases = Empty,
> {
pure::<_, String, ()>(format!("FileHandle({path})"))
.acquires::<FileRes>()
}
fn close_file(handle: String) -> impl ResourceEffect<
Output = (),
Error = String,
Env = (),
Acquires = Empty,
Releases = Has<FileRes>,
> {
let _ = handle;
pure::<_, String, ()>(()).releases::<FileRes>()
}
fn read_contents(handle: &str) -> impl ResourceEffect<
Output = String,
Error = String,
Env = (),
Acquires = Empty,
Releases = Empty,
> {
pure::<_, String, ()>(format!("Contents of {handle}")).neutral()
}
The function signatures make the resource contract explicit:
open_fileacquiresFileResclose_filereleasesFileResread_contentsis resource-neutral
Prefer Bracket for Resource Safety
The most ergonomic way to acquire and release a resource safely is the bracket builder:
use stillwater::effect::resource::*;
use stillwater::{pure, Effect};
let effect = bracket::<FileRes>()
.acquire(pure::<_, String, ()>("file_handle".to_string()))
.release(|handle: String| async move {
let _ = handle;
Ok(())
})
.use_fn(|handle: &String| {
pure::<_, String, ()>(format!("Processed: {handle}"))
});
let result = effect.run(&()).await;
The bracket shape is:
- acquire the resource
- use the resource
- release the resource even if use fails
The bracketed effect is resource-neutral as a whole: it does not leak its acquired resource through the type signature.
Protocol Enforcement
Resource tracking is also useful for protocols such as transactions:
use stillwater::effect::resource::*;
use stillwater::{pure, Effect};
fn begin_transaction() -> impl ResourceEffect<
Output = String,
Error = String,
Env = (),
Acquires = Has<TxRes>,
Releases = Empty,
> {
pure::<_, String, ()>("tx_12345".to_string()).acquires::<TxRes>()
}
fn commit_transaction(tx: String) -> impl ResourceEffect<
Output = (),
Error = String,
Env = (),
Acquires = Empty,
Releases = Has<TxRes>,
> {
let _ = tx;
pure::<_, String, ()>(()).releases::<TxRes>()
}
fn run_in_transaction() -> impl ResourceEffect<
Output = String,
Error = String,
Env = (),
Acquires = Empty,
Releases = Empty,
> {
bracket::<TxRes>()
.acquire(begin_transaction())
.release(|tx| async move { commit_transaction(tx).run(&()).await })
.use_fn(|tx| pure::<_, String, ()>(format!("used {tx}")))
}
The return type says run_in_transaction is neutral: it begins and ends the transaction internally.
Compile-Time Neutrality Checks
Use assert_resource_neutral when an API must not expose outstanding resources:
use stillwater::effect::resource::*;
fn must_be_neutral<E>(effect: E) -> E
where
E: ResourceEffect<Acquires = Empty, Releases = Empty>,
{
assert_resource_neutral(effect)
}
If the effect still acquires or releases a resource, the code will fail to compile.
Custom Resource Markers
Define a marker when the built-in resource kinds do not fit your domain:
use stillwater::effect::resource::ResourceKind;
pub struct PoolCheckout;
impl ResourceKind for PoolCheckout {
const NAME: &'static str = "PoolCheckout";
}
You can then use .acquires::<PoolCheckout>(), .releases::<PoolCheckout>(), and bracket::<PoolCheckout>() the same way as built-in markers.
Example
Run the full example:
cargo run --example resource_tracking
The example covers basic annotations, bracket usage, transaction protocol enforcement, multiple resource types, custom resource markers, and compile-time neutrality assertions.
For API-level details, see stillwater::effect::resource.
API Tiers and Composition Decisions
Stillwater keeps a broad API, but not every module belongs in the default mental model. Use the smallest tier that solves the application problem.
Core
Start here for most applications:
ValidationandNonEmptyVecfor independent checks and accumulated errors.Effect, free constructors, and essential combinators for imperative boundaries.- Reader helpers (
ask,asks,local) for explicit dependencies. ContextErrorfor adding boundary context..boxed()andBoxedEffectonly where type erasure is required.
The recommended architecture is data first: load facts in the shell, pass them to a pure
function that returns a decision or plan, then interpret that plan at the boundary. See the
user_registration example
for the canonical shape. Facts are snapshots: the repository still enforces uniqueness
atomically when committing a plan and reports conflicts separately.
Operational
Reach for these when runtime behavior is part of the requirement:
- Explicit sequential or parallel traversal and sequencing.
- Parallel execution, races, concurrency limits, retry, and timeout.
- Runtime
brackethelpers for resources whose cleanup is itself effectful. tracingintegration for production diagnostics.
The Cargo feature named async enables Tokio-backed retry and timeout helpers. Core
Effect composition is always asynchronous and does not require that feature.
Advanced
These modules remain supported, but should be adopted for a specific need rather than as the default application architecture:
IOis a narrow, infallible projection helper. Prefer ordinary Effect constructors for fallible service calls and do not treatIOas a second general effect system.WriterEffectaccumulates typed output as part of a result. Use it when the accumulated value is domain data; usetracingfor operational logs and spans.SinkEffectstreams emissions to a supplied consumer. Use it when values must be handled incrementally instead of retained in memory.- Type-level resource tracking proves acquire/release accounting in composition. Prefer
ordinary Rust RAII for ownership-bound cleanup and runtime
bracketwhen cleanup is asynchronous. Usebracket_fullwhen cleanup errors must be represented in the result. Async brackets do not mask cancellation; see the migration guide.
Decision table
| Need | Default choice |
|---|---|
| Accumulate independent validation errors | Validation |
| Describe an I/O boundary | Effect plus a narrow environment trait |
| Operational logs and spans | tracing |
| Typed audit output returned with a value | WriterEffect |
| Incremental emissions | SinkEffect |
| Ownership-bound cleanup | RAII (Drop) |
| Async/fallible cleanup protocol | Runtime bracket |
| Compile-time acquire/release accounting | Type-level resource API |
| Sync environment access | from_fn |
| Async work owning cheap cloned handles | from_async |
| Async work borrowing the environment | from_async_ref |
These tiers classify the existing surface for 2.0. They do not introduce feature gates or move APIs to companion crates.
Performance Guide
Stillwater aims for low-overhead validation, effects, and error context. Effect composition uses concrete types without combinator boxing by default. This guide documents how those properties are measured rather than treating “zero cost” as a blanket promise.
Running benchmarks locally
# All benchmark suites (validation, effects, context, parallel)
cargo bench --features async
# Individual suites
cargo bench --bench validation
cargo bench --bench effects --features async
cargo bench --bench context
cargo bench --bench parallel --features async
Criterion writes HTML reports under target/criterion/. Open any report/index.html in a browser for detailed plots.
For a quick smoke run (pass --sample-size only to criterion benches, not the library test harness):
cargo bench --features async \
--bench validation --bench effects --bench context --bench parallel \
-- --sample-size 10
Benchmark categories
| Suite | File | Compares |
|---|---|---|
| Validation | benches/validation.rs | Validation::all_vec vs manual Result accumulation |
| Effects | benches/effects.rs | Combinator chain (map + and_then) vs hand-written async |
| Context | benches/context.rs | ContextError layering vs equivalent manual struct |
| Parallel | benches/parallel.rs | par_all / par2 vs sequential run |
Each suite pairs Stillwater APIs with hand-written equivalents so overhead is measurable, not assumed.
Expected characteristics
On typical release builds:
- Validation — Within a few percent of manual accumulation for success paths; error accumulation adds work proportional to error count in both paths.
- Effects — Combinator chains should match manual async code when effects use concrete types (no
.boxed()in the hot path). - Context —
ContextErrorshould be within a few percent of a manualVec<String>trail; both allocate context strings. - Parallel —
par_allon cheappureeffects measures scheduling overhead; use I/O-heavy effects locally to validate real speedup.
The project success metric is within 5% of hand-written equivalent code on CPU-bound paths. Absolute numbers vary by machine; compare relative ratios and track trends over time.
CI integration
The Benchmarks workflow:
- On pull requests: runs benchmarks with reduced samples as a compile-and-run smoke test.
- On push to master: runs full benchmarks and stores results via github-action-benchmark for historical comparison.
Regressions are detected by comparing new results to the stored baseline on the default branch.
Size smoke tests
Checks in src/effect/combinators/size_tests.rs measure a few concrete struct sizes.
They can catch some size growth; a small struct can still contain pointers to heap
allocations. They do not prove inline storage, allocation counts, or runtime overhead.
Use allocation instrumentation for allocation claims and runtime benchmarks for hot paths.
Practices for low-overhead usage:
- Prefer concrete effect types; use
.boxed()where type erasure is needed. Borrowed async construction boxes its future, and helpers such asIO, traversal, and retry return boxed effects internally. Account for those costs too. - Use
Validation::all_vec/ tuplevalidate_allfor accumulation instead of fail-fast?when you need every error. - Add
ContextErrorat boundaries, not inside tight inner loops.
Updating this document
After significant API changes, re-run cargo bench --features async on release mode and note any ratio shifts in PR descriptions. Do not commit machine-specific timings here; rely on CI history for regression tracking.
Common Patterns and Recipes
This document collects common patterns and recipes for using Stillwater effectively.
Validation Patterns
Pattern 1: Independent Field Validation
When validating multiple independent fields:
use stillwater::Validation;
fn validate_user_registration(input: UserInput) -> Validation<User, Vec<Error>> {
Validation::all((
validate_email(&input.email),
validate_password(&input.password),
validate_age(input.age),
validate_username(&input.username),
))
.map(|(email, password, age, username)| {
User { email, password, age, username }
})
}
Pattern 2: Dependent Validation
When one validation depends on another’s result:
use stillwater::Validation;
fn validate_and_check_unique(email: &str) -> Validation<Email, Vec<Error>> {
validate_email_format(email)
.and_then(|email| check_email_not_taken(email))
}
Pattern 3: Validating Collections
Validate all items in a collection:
use stillwater::Validation;
fn validate_all(items: Vec<Item>) -> Validation<Vec<ValidItem>, Vec<Error>> {
let validations: Vec<_> = items
.into_iter()
.map(|item| validate_item(item))
.collect();
Validation::all_vec(validations)
}
Pattern 4: Conditional Validation
Validate different fields based on conditions:
use stillwater::Validation;
fn validate_payment(method: PaymentMethod, data: PaymentData) -> Validation<Payment, Vec<Error>> {
match method {
PaymentMethod::CreditCard => {
Validation::all((
validate_card_number(&data.card_number),
validate_cvv(&data.cvv),
validate_expiry(&data.expiry),
))
.map(|(card, cvv, expiry)| Payment::CreditCard { card, cvv, expiry })
}
PaymentMethod::BankTransfer => {
Validation::all((
validate_account_number(&data.account),
validate_routing_number(&data.routing),
))
.map(|(account, routing)| Payment::BankTransfer { account, routing })
}
}
}
Effect Patterns
All Effect patterns use the free function style with the prelude import:
use stillwater::effect::prelude::*;
Pattern 1: Read, Transform, Write
Classic pattern for processing data:
use stillwater::effect::prelude::*;
fn process_user_data(id: u64) -> impl Effect<Output = (), Error = Error, Env = Env> {
from_fn(|env: &Env| env.db.fetch_user(id))
.map(|user| transform_user_data(user)) // Pure transformation
.and_then(|transformed| {
from_fn(|env: &Env| env.db.save_user(&transformed))
})
}
Pattern 2: Validate Then Execute
Validate input, then perform I/O if valid:
use stillwater::effect::prelude::*;
fn create_user(input: UserInput) -> impl Effect<Output = User, Error = Error, Env = Env> {
from_validation(validate_user(input))
.and_then(|valid| {
from_fn(|env: &Env| env.db.insert_user(&valid))
})
}
Pattern 3: Try Cache, Fall Back to DB
Common caching pattern:
use stillwater::effect::prelude::*;
fn get_user(id: u64) -> impl Effect<Output = User, Error = Error, Env = Env> {
from_fn(|env: &Env| env.cache.get_user(id))
.and_then(move |cached| {
match cached {
Some(user) => pure(user).boxed(),
None => {
from_fn(move |env: &Env| env.db.fetch_user(id))
.and_then(move |user| {
from_fn(move |env: &Env| env.cache.set_user(id, user.clone()))
.map(|_| user)
})
.boxed()
}
}
})
}
Pattern 4: Sequential Operations with Context
Add context at each step:
use stillwater::effect::prelude::*;
fn process_order(id: u64) -> impl Effect<Output = Receipt, Error = Error, Env = Env> {
fetch_order(id)
.context("fetching order")
.and_then(|order| {
validate_order(&order)
.context("validating order")
})
.and_then(|order| {
charge_payment(&order)
.context("processing payment")
})
.and_then(|charge| {
generate_receipt(charge)
.context("generating receipt")
})
}
Pattern 5: Parallel Operations (using tokio)
When effects are independent:
use stillwater::effect::prelude::*;
use tokio;
async fn load_dashboard(user_id: u64, env: &Env) -> Result<Dashboard, Error> {
let (user, projects, notifications) = tokio::try_join!(
fetch_user(user_id).execute(env),
fetch_projects(user_id).execute(env),
fetch_notifications(user_id).execute(env),
)?;
Ok(Dashboard { user, projects, notifications })
}
Pattern 6: Combining Independent Effects with Zip
Use zip when you need both results from independent effects:
use stillwater::prelude::*;
// Basic zip: combine two independent effects into a tuple
fn load_user_with_settings(id: UserId) -> impl Effect<Output = (User, Settings), Error = AppError, Env = AppEnv> {
fetch_user(id).zip(fetch_settings(id))
}
// zip_with: combine with a function directly (more efficient than zip + map)
fn calculate_total(order_id: OrderId) -> impl Effect<Output = Money, Error = AppError, Env = AppEnv> {
fetch_price(order_id)
.zip_with(fetch_quantity(order_id), |price, qty| price * qty)
}
// zip3 through zip8: flat tuple results for multiple effects
fn load_profile(id: UserId) -> impl Effect<Output = Profile, Error = AppError, Env = AppEnv> {
zip3(
fetch_user(id),
fetch_settings(id),
fetch_preferences(id),
)
.map(|(user, settings, prefs)| Profile { user, settings, prefs })
}
// Chained zips create nested tuples
fn chained_example() -> impl Effect<Output = i32, Error = String, Env = ()> {
pure(1)
.zip(pure(2))
.zip(pure(3))
.map(|((a, b), c)| a + b + c) // Note the nested tuple
}
Key points:
zipexpresses independence - neither effect depends on the other’s output- Uses fail-fast semantics (first error wins), same as
and_then - For error accumulation with independent operations, use
Validation::all()instead zip3throughzip8return flat tuples for cleaner pattern matching
Testing Patterns
Pattern 1: Testing Pure Functions
Pure functions need no mocking:
use stillwater::Validation;
fn validate_email(email: &str) -> Validation<&str, Vec<&str>> {
if email.contains('@') {
Validation::success(email)
} else {
Validation::failure(vec!["missing @"])
}
}
assert_eq!(validate_email("user@example.com").into_result(), Ok("user@example.com"));
assert_eq!(validate_email("invalid").into_result(), Err(vec!["missing @"]));
Pattern 2: Testing Effects with Mock Environment
use stillwater::effect::prelude::*;
struct MockEnv {
users: HashMap<u64, User>,
}
impl MockEnv {
fn fetch_user(&self, id: u64) -> Result<User, Error> {
self.users.get(&id).cloned().ok_or(Error::NotFound)
}
}
#[tokio::test]
async fn test_user_workflow() {
let mut env = MockEnv {
users: HashMap::new(),
};
env.users.insert(1, User { name: "Alice".into() });
let effect = from_fn(|env: &MockEnv| env.fetch_user(1));
let result = effect.execute(&env).await;
assert!(result.is_ok());
}
Pattern 3: Testing Error Cases
use stillwater::prelude::*;
use std::collections::HashMap;
#[derive(Clone, Default)]
struct Env { users: HashMap<u64, String> }
tokio_test::block_on(async {
let effect = from_fn(|env: &Env| env.users.get(&999).cloned().ok_or("not found"));
assert_eq!(effect.run(&Env::default()).await, Err("not found"));
});
Error Handling Patterns
Pattern 1: Domain-Specific Errors
#[derive(Debug)]
enum UserError {
NotFound(u64),
InvalidEmail(String),
PermissionDenied,
}
impl std::fmt::Display for UserError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UserError::NotFound(id) => write!(f, "User {} not found", id),
UserError::InvalidEmail(email) => write!(f, "Invalid email: {}", email),
UserError::PermissionDenied => write!(f, "Permission denied"),
}
}
}
impl std::error::Error for UserError {}
Pattern 2: Error Conversion
use stillwater::effect::prelude::*;
fn fetch_user(id: u64) -> impl Effect<Output = User, Error = AppError, Env = Env> {
from_fn(|env: &Env| {
env.db.fetch_user(id)
.map_err(|e| AppError::Database(e.to_string()))
})
}
Pattern 3: Error Context Trails
use stillwater::effect::prelude::*;
fn complex_operation() -> impl Effect<Output = Result, Error = ContextError<Error>, Env = Env> {
step1()
.context("performing step 1")
.and_then(|r1| {
step2(r1).context("performing step 2")
})
.and_then(|r2| {
step3(r2).context("performing step 3")
})
.context("complex operation")
}
Composition Patterns
Pattern 1: Building Complex Validations
fn validate_address(addr: &Address) -> Validation<ValidAddress, Vec<Error>> {
Validation::all((
validate_street(&addr.street),
validate_city(&addr.city),
validate_zip(&addr.zip),
validate_country(&addr.country),
))
.map(|(street, city, zip, country)| {
ValidAddress { street, city, zip, country }
})
}
fn validate_contact(contact: &Contact) -> Validation<ValidContact, Vec<Error>> {
Validation::all((
validate_email(&contact.email),
validate_phone(&contact.phone),
validate_address(&contact.address),
))
.map(|(email, phone, address)| {
ValidContact { email, phone, address }
})
}
Pattern 2: Effect Pipelines
use stillwater::effect::prelude::*;
fn user_registration_pipeline(input: UserInput) -> impl Effect<Output = User, Error = Error, Env = Env> {
validate_input(input)
.and_then(|valid| check_uniqueness(valid))
.and_then(|valid| create_user(valid))
.and_then(|user| send_welcome_email(user))
.and_then(|user| create_default_settings(user))
.context("user registration")
}
Resource Management Patterns
The bracket pattern ensures resources are properly released even when errors occur.
Pattern 1: Single Resource with Guaranteed Cleanup
use stillwater::effect::bracket::bracket;
use stillwater::prelude::*;
fn with_database_connection<T>(
f: impl FnOnce(&Connection) -> impl Effect<Output = T, Error = AppError, Env = AppEnv>
) -> impl Effect<Output = T, Error = AppError, Env = AppEnv> {
bracket(
from_fn(|env: &AppEnv| env.pool.get_connection()), // Acquire
|conn| async move { conn.release().await }, // Release (always runs)
f, // Use
)
}
// Usage
let result = with_database_connection(|conn| {
from_fn(move |_| conn.query("SELECT * FROM users"))
}).execute(&env).await;
Pattern 2: Multiple Resources with LIFO Cleanup
Resources are released in reverse order of acquisition (Last In, First Out):
use stillwater::effect::bracket::bracket2;
fn with_db_and_file(
path: &Path,
) -> impl Effect<Output = Data, Error = AppError, Env = AppEnv> {
bracket2(
open_database(), // Acquired first
open_file(path), // Acquired second
|db| async move { db.close().await }, // Released second
|file| async move { file.close().await }, // Released first (LIFO)
|db, file| process_data(db, file),
)
}
Pattern 3: Fluent Builder for Multiple Resources
The acquiring builder provides ergonomic multi-resource management:
use stillwater::effect::bracket::acquiring;
fn complex_operation() -> impl Effect<Output = Result, Error = AppError, Env = AppEnv> {
acquiring(
open_connection(),
|conn| async move { conn.close().await },
)
.and(acquire_lock(), |lock| async move { lock.release().await })
.and(open_file(), |file| async move { file.close().await })
.with_flat3(|conn, lock, file| {
// All three resources available here
// Cleanup happens in reverse order: file, lock, conn
do_work(conn, lock, file)
})
}
Pattern 4: Explicit Error Handling with BracketError
When you need to distinguish between use errors and cleanup errors:
use stillwater::effect::bracket::{bracket_full, BracketError};
fn with_explicit_errors() -> impl Effect<Output = Data, Error = BracketError<AppError>, Env = AppEnv> {
bracket_full(
acquire_resource(),
|r| async move { r.cleanup().await },
|r| use_resource(r),
)
}
// Handle all error cases explicitly
let result = with_explicit_errors().execute(&env).await;
match result {
Ok(data) => println!("Success: {:?}", data),
Err(BracketError::AcquireError(e)) => {
// Resource was never acquired, no cleanup needed
log::error!("Failed to acquire: {:?}", e);
}
Err(BracketError::UseError(e)) => {
// Use failed, but cleanup succeeded
log::error!("Operation failed: {:?}", e);
}
Err(BracketError::CleanupError(e)) => {
// Use succeeded, but cleanup failed - may need manual intervention
log::warn!("Cleanup failed: {:?}", e);
}
Err(BracketError::Both { use_error, cleanup_error }) => {
// Both failed - log both for debugging
log::error!("Use failed: {:?}, cleanup also failed: {:?}", use_error, cleanup_error);
}
}
Pattern 5: Partial Acquisition Rollback
When acquiring multiple resources, earlier acquisitions are rolled back if later ones fail:
use stillwater::effect::bracket::acquiring;
// If file acquisition fails, connection is automatically released
let effect = acquiring(
open_connection(), // Succeeds
|c| async move { c.close().await },
)
.and(
open_file(path), // Fails!
|f| async move { f.close().await },
)
.with(|(conn, file)| use_both(conn, file));
// Connection is properly cleaned up even though file failed
let result = effect.execute(&env).await; // Returns file acquisition error
Pattern 6: Connection Pool Pattern
Encapsulate resource management in reusable abstractions:
use stillwater::effect::bracket::Resource;
struct ConnectionPool {
// ... pool internals
}
impl ConnectionPool {
fn connection(&self) -> Resource<Connection, PoolError, AppEnv> {
Resource::new(
from_fn(|env: &AppEnv| env.pool.checkout()),
|conn| async move { conn.checkin().await },
)
}
}
// Usage - cleanup is automatic
let pool = ConnectionPool::new();
let result = pool.connection()
.with(|conn| from_fn(move |_| conn.query("SELECT 1")))
.execute(&env)
.await;
Performance Patterns
Pattern 1: Avoid Excessive Boxing
use stillwater::effect::prelude::*;
// Instead of creating many small effects:
let effect = pure::<_, String, ()>(1)
.map(|x| x + 1)
.map(|x| x * 2)
.map(|x| x - 3);
// Combine transformations:
let effect = pure::<_, String, ()>(1)
.map(|x| (x + 1) * 2 - 3);
Pattern 2: Batch Operations
use stillwater::effect::prelude::*;
// Instead of many individual queries:
for id in ids {
fetch_user(id).execute(&env).await?;
}
// Batch fetch:
let users = fetch_users_batch(ids).execute(&env).await?;
Summary
These patterns cover common use cases. Mix and match them based on your needs!
Next Steps
- See FAQ for common questions
- Read Comparison vs other libraries
- Check examples/ for working code
Frequently Asked Questions
General
What is Stillwater?
Stillwater is a Rust library providing pragmatic functional programming abstractions, focused on validation error accumulation and effect composition using the “pure core, imperative shell” pattern.
Why “Stillwater”?
“Still” represents pure logic (calm, unchanging, referentially transparent). “Water” represents effects (flowing, dynamic, performing I/O). Together: “pure core, imperative shell.”
Is this a monad library?
Sort of. Validation is an Applicative Functor, Effect is a Reader/IO monad. But we focus on practical patterns, not category theory. You don’t need to understand monads to use Stillwater effectively.
What’s the learning curve?
Low. If you understand Result and async/await, you can use Stillwater. The core concepts are:
- Validation accumulates errors (vs Result which short-circuits)
- Effect separates pure logic from I/O (for testability)
Advanced patterns are optional.
Validation
Why not just use Result?
Result short-circuits on the first error. Validation accumulates all errors, providing better UX for forms and APIs where users need to see all validation failures at once.
Can I use the ? operator with Validation?
On nightly with the try_trait feature and try_trait_nightly cfg, yes! But be aware: ? fails fast (no accumulation). Use Validation::all() for error accumulation. See Try Trait guide.
What if I need more than 12 validations?
Use Validation::all_vec() for homogeneous collections, or nest tuples: Validation::all((all1, all2, all3)).
How do I convert Validation to Result?
let result: Result<T, E> = validation.into_result();
Do I need to implement Semigroup for every error type?
Only if you want to use Validation::all(). Vec<T>, String, and tuples already implement Semigroup. Most of the time you’ll use Vec<YourError> which works out of the box.
Effects
Why Effect instead of just async fn?
Effect separates pure logic from I/O, making code more testable and composable. Pure functions need zero mocks! You can test business logic without databases, file systems, or network calls.
Do I need tokio?
For async Effects, yes (or async-std). For sync-only code, no runtime needed.
Can I use Effect with sync code?
Yes! Use from_fn() for sync operations. They’ll be wrapped in ready futures.
How do I test Effects?
Create simple mock environments (just data structures). Pure functions in your Effects need no mocking. See testing_patterns example.
Does Effect have performance overhead?
No. Stillwater follows the futures crate pattern: combinators return concrete types such
as Map and AndThen, so composition does not box by default. Captured application values,
service calls, boxed collections, and from_async_ref may still allocate.
When you need type erasure (collections, recursion, match arms), use .boxed() which allocates once. For I/O-bound work, this is negligible.
When should I use .boxed()?
Use .boxed() in exactly three cases:
- Collections: Storing different effects in
Vec,HashMap, etc. - Recursion: Breaking infinite type recursion
- Match arms: When different branches return different effect types
// Collections
let effects: Vec<BoxedEffect<i32, String, ()>> = vec![
pure(1).boxed(),
pure(2).map(|x| x * 2).boxed(),
];
// Recursion
fn countdown(n: i32) -> BoxedEffect<i32, String, ()> {
if n <= 0 { pure(0).boxed() }
else { pure(n).and_then(move |_| countdown(n - 1)).boxed() }
}
Why did the API change in 0.11.0?
Version 0.11.0 introduced a concrete-combinator Effect design following the futures crate pattern. The old API boxed every combinator; the new API uses concrete types by default.
Key changes:
Effect::pure(x)→pure(x)Effect<T, E, Env>struct →impl Effect<Output=T, Error=E, Env=Env>trait- Automatic boxing → Explicit
.boxed()when needed
See Migration Guide for detailed upgrade instructions.
How do I migrate from 0.10.x to 0.11.0?
See the Migration Guide for step-by-step instructions. Key steps:
- Update imports to use
stillwater::prelude::* - Change return types to
impl Effect<...> - Replace
Effect::pure,Effect::failwithpure,fail - Add
.boxed()where type erasure is needed
Error Handling
Should I always use ContextError?
No. Use it at I/O boundaries and major operation boundaries where context helps debugging. Don’t add context to pure functions or in hot loops.
Can I mix Validation and Effect?
Yes! Common pattern:
from_validation(validate_data(data))
.and_then(|valid| save_to_db(valid))
Validate first (pure, accumulates errors), then lift to Effect for I/O.
How do I handle errors from third-party libraries?
Map them to your error types:
from_async_ref(|env: &AppEnv| Box::pin(async move {
env.db
.query()
.await
.map_err(|err| MyError::Database(err.to_string()))
}))
Performance
Is there overhead?
Effect composition does not box by default:
- Each combinator returns a concrete type (like
Map<AndThen<Pure<...>, F>, G>) - The compiler can fully inline the effect chain
- The combinator layer itself does not allocate
Validation is an enum and Effect combinators are ordinary concrete structs. Application values, service calls, async implementations, and error collections can still allocate; benchmark the complete workload when overhead matters.
When does allocation happen?
Stillwater allocates for explicit effect type erasure and for APIs whose contracts require it:
- Storing effects in collections
- Recursive effects
- Match arms with different effect types
from_async_ref, which boxes a future once per run so it can borrow the environment
For I/O-bound applications (API calls, database queries), boxing overhead is negligible compared to actual work.
Can I use Stillwater in hot loops?
Yes. Prefer concrete effects in hot paths and benchmark the full workload; avoiding .boxed()
removes type-erasure allocation but does not make captured application work allocation-free.
Can I use no_std?
Not currently. Effect requires std for async/boxing. Future versions may add no_std support for Validation.
Comparison
vs anyhow/thiserror
Those are for error handling. Stillwater is for validation (accumulation) and effect composition (separation). Use together! Stillwater for business logic, anyhow for error propagation.
vs frunk
frunk focuses on HLists and type-level programming. Stillwater focuses on practical validation and effects with a lower learning curve.
vs monadic
monadic uses macros for do-notation (rdrdo!). Stillwater uses method chaining (more idiomatic Rust).
vs hand-rolling
Hand-rolling works but requires boilerplate. Stillwater provides tested, composable abstractions that follow best practices.
vs just using Result everywhere?
Result is perfect for operations that should fail fast. Use Result for that! Use Validation when you want ALL errors (forms, API validation). Use Effect when you want testable I/O separation.
Contributing
How can I help?
See CONTRIBUTING.md. We welcome:
- Bug reports
- Documentation improvements
- Examples
- Feature requests
- Performance improvements
What’s the roadmap?
See the specification index for proposal statuses. Drafts are not release commitments. Current areas under consideration include:
- Saga-style compensation for multi-step workflows
- Serde and framework integration
- Benchmarks and performance validation
- Additional Result ergonomics
Circuit breakers and flat tuple Effect combinators are parked, with reactivation criteria recorded in their specifications.
Is this production-ready?
Stillwater 2.0 is the next major release candidate. Its test suite covers library behavior, executable documentation, and the canonical registration example. Release readiness also requires CI and package verification; examples are not production service adapters.
Common Issues
“Cannot infer type for Effect”
Specify type parameters explicitly on constructor functions:
// Instead of:
let effect = pure(42);
// Do:
let effect = pure::<_, String, ()>(42);
“expected struct, found opaque type”
You’re returning impl Effect but the caller expects a concrete type. Either:
- Use
.boxed()to getBoxedEffect - Update the caller to accept
impl Effect
“recursive type has infinite size”
Use .boxed() for recursive effects:
fn countdown(n: i32) -> BoxedEffect<i32, String, ()> {
if n <= 0 { pure(0).boxed() }
else { pure(n).and_then(move |_| countdown(n - 1)).boxed() }
}
“Validation::all doesn’t compile”
Make sure your error type implements Semigroup:
use stillwater::Semigroup;
impl Semigroup for MyError {
fn combine(self, other: Self) -> Self {
// Combine errors
}
}
Or use Vec<MyError> which already implements Semigroup.
“Effect.run() returns nested Results”
Check your function signatures. from_fn expects functions returning Result<T, E>, not bare values:
// Wrong:
from_fn(|db: &Db| db.fetch_user(id)) // If fetch_user returns User directly
// Right:
from_fn(|db: &Db| Ok(db.fetch_user(id)))
// Or if fetch_user returns Result:
from_fn(|db: &Db| db.fetch_user(id))
Getting More Help
- Read the User Guide
- Check PATTERNS.md for recipes
- See examples/ for working code
- Open an issue on GitHub
Migration Guide
Stillwater 1.x to 2.0
The core Effect trait remains asynchronous. This release removes deprecated
compatibility APIs and gives effect traversal explicit execution names.
Choose traversal semantics deliberately
| 1.x API | Preserve concurrent execution | Opt into ordered, fail-fast execution |
|---|---|---|
traverse_effect(items, f) | traverse_effect_parallel(items, f) | traverse_effect_sequential(items, f) |
sequence_effect(effects) | sequence_effect_parallel(effects) | sequence_effect_sequential(effects) |
The 1.x implementation ran children concurrently and awaited all results, despite documentation claiming sequential fail-fast behavior. The parallel variants preserve that execution policy. Choosing sequential is an intentional behavior change: later operations will not run after an earlier failure.
Traversal factory timing also changes. In 1.x, traverse_effect called f immediately
when building the traversal. Both new variants defer f until execution; parallel
constructs the full batch before polling children, while sequential constructs each
child only after the previous one succeeds. Move required construction-time work into
an explicit step rather than relying on the old factory timing.
All variants still eagerly enumerate their input iterators when called. They clone the environment for execution and return boxed effects. Concurrent completion order does not change output ordering or which error is returned: the first error by input position wins after all children finish. Dropping the parent future cancels unfinished children.
use stillwater::prelude::*;
tokio_test::block_on(async {
let batch = traverse_effect_parallel([1, 2, 3], |value| {
pure::<_, &str, ()>(value * 2).boxed()
});
assert_eq!(batch.run(&()).await, Ok(vec![2, 4, 6]));
let ordered = sequence_effect_sequential(vec![
pure::<_, &str, ()>(1).boxed(),
fail("stop").boxed(),
pure(3).boxed(),
]);
assert_eq!(ordered.run(&()).await, Err("stop"));
});
Replace legacy aliases and constructors
LegacyEffect and LegacyConstructors have been removed, not just deprecated.
Use free constructors, return impl Effect for concrete composition, and use
BoxedEffect when a single erased type is needed. RunStandalone remains supported.
use stillwater::prelude::*;
fn concrete() -> impl Effect<Output = i32, Error = &'static str, Env = ()> {
pure(42)
}
fn erased() -> BoxedEffect<i32, &'static str, ()> {
concrete().boxed()
}
tokio_test::block_on(async {
assert_eq!(concrete().run_standalone().await, Ok(42));
assert_eq!(erased().run(&()).await, Ok(42));
});
Replace bracket_simple
The removed function accepted (acquire, use_fn, release_fn). The replacement
bracket accepts (acquire, release_fn, use_fn).
The use callback now receives &R; release receives ownership of R and returns a
future with output Result<(), E>. It is an async callback, not an Effect.
The error type also needs Debug. If use needs owned data, explicitly clone the
necessary data before constructing its effect; the resource itself no longer needs
Clone just to enter the use callback.
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use stillwater::prelude::*;
tokio_test::block_on(async {
let released = Arc::new(AtomicBool::new(false));
let observed = Arc::clone(&released);
let effect = bracket(
pure::<_, &str, ()>(String::from("connection")),
move |_resource| async move {
observed.store(true, Ordering::SeqCst);
Ok(())
},
|resource: &String| pure(resource.len()),
);
assert_eq!(effect.run(&()).await, Ok(10));
assert!(released.load(Ordering::SeqCst));
});
bracket logs cleanup errors and returns the use result. Choose bracket_full when
cleanup failure must be returned to the caller:
use stillwater::prelude::*;
tokio_test::block_on(async {
let effect = bracket_full(
pure::<_, &str, ()>(()),
|_| async { Err("cleanup failed") },
|_| pure(42),
);
assert_eq!(
effect.run(&()).await,
Err(BracketError::CleanupError("cleanup failed")),
);
});
These async brackets do not mask cancellation or guarantee cleanup after panic. Use normal ownership/RAII where possible and design asynchronous cleanup protocols explicitly.
Features and allocation expectations
The async feature enables Tokio-backed retry and timeout helpers; it does not
switch Effect between sync and async modes. IO keeps its existing infallible outer
error channel and is not a replacement for fallible Effect constructors.
Most combinators store concrete fields without adding boxes. from_async_ref accepts
a boxed borrowed future; IO, traversal, and retry helpers return boxed effects.
Calls to .boxed() are therefore not the only possible allocation sites.
Upgrading directly from pre-0.11 releases
Those releases used an Effect<T, E, Env> struct and associated constructors.
Use the current replacements above rather than migrating through removed aliases.
| Historical spelling | Current spelling |
|---|---|
Effect::pure(value) | pure(value) |
Effect::fail(error) | fail(error) |
Effect::from_fn(f) | from_fn(f) |
Effect<T, E, Env> | impl Effect<Output = T, Error = E, Env = Env> or BoxedEffect<T, E, Env> |
Use owned or borrowed async constructors according to whether the returned future borrows its environment. See the API tiers for the recommended 2.0 surface.
Comparison to Other Libraries
This document compares Stillwater to other Rust libraries providing similar functionality, with extensive before/after code examples demonstrating real-world improvements.
Quick Comparison Table
| Feature | Stillwater | frunk | monadic | anyhow | validator |
|---|---|---|---|---|---|
| Error accumulation | ✓ | ✓ | ✗ | ✗ | ✗ |
| Effect composition | ✓ | ✗ | ✓ | ✗ | ✗ |
| Async support | ✓ | ✗ | ✗ | ✓ | ✗ |
| Learning curve | Low | High | Medium | Low | Low |
| Type-level programming | ✗ | ✓ | ✗ | ✗ | ✗ |
| Macro DSL | ✗ | ✗ | ✓ | ✓ | ✓ |
| Pure Rust idioms | ✓ | Partial | Partial | ✓ | ✓ |
| Dependencies | 0 (core) | Many | Few | Few | Many |
Before/After Examples: Form Validation
Basic 3-Field Validation
Problem: Collecting ALL validation errors for a user registration form, not just the first one.
Before (Traditional Rust - 22 lines):
fn validate_user_registration(
email: &str,
password: &str,
age: u8,
) -> Result<ValidatedUser, Vec<String>> {
let mut errors = Vec::new();
let validated_email = match validate_email(email) {
Ok(e) => Some(e),
Err(e) => { errors.push(e); None }
};
let validated_password = match validate_password(password) {
Ok(p) => Some(p),
Err(e) => { errors.push(e); None }
};
let validated_age = match validate_age(age) {
Ok(a) => Some(a),
Err(e) => { errors.push(e); None }
};
if errors.is_empty() {
Ok(ValidatedUser {
email: validated_email.unwrap(),
password: validated_password.unwrap(),
age: validated_age.unwrap(),
})
} else {
Err(errors)
}
}
After (With Stillwater - 8 lines, 64% reduction):
use stillwater::{Validation, validation::ValidateAll};
fn validate_user_registration(
email: &str,
password: &str,
age: u8,
) -> Validation<ValidatedUser, Vec<String>> {
(
validate_email(email),
validate_password(password),
validate_age(age),
)
.validate_all()
.map(|(email, password, age)| ValidatedUser { email, password, age })
}
Key Improvements:
- 64% less code (22 → 8 lines)
- No mutable state or
Optionunwrapping - Declarative tuple-based composition
- Type-safe error accumulation via
Semigroup
5-Field Validation with Nested Objects
Problem: Validating a complex order form with shipping address.
Before (Traditional Rust - 42 lines):
fn validate_order(input: &OrderInput) -> Result<ValidatedOrder, Vec<String>> {
let mut errors = Vec::new();
let customer_name = match validate_name(&input.customer_name) {
Ok(n) => Some(n),
Err(e) => { errors.push(e); None }
};
let email = match validate_email(&input.email) {
Ok(e) => Some(e),
Err(e) => { errors.push(e); None }
};
let phone = match validate_phone(&input.phone) {
Ok(p) => Some(p),
Err(e) => { errors.push(e); None }
};
// Nested address validation
let street = match validate_street(&input.address.street) {
Ok(s) => Some(s),
Err(e) => { errors.push(format!("address.street: {}", e)); None }
};
let city = match validate_city(&input.address.city) {
Ok(c) => Some(c),
Err(e) => { errors.push(format!("address.city: {}", e)); None }
};
let postal_code = match validate_postal_code(&input.address.postal_code) {
Ok(p) => Some(p),
Err(e) => { errors.push(format!("address.postal_code: {}", e)); None }
};
if errors.is_empty() {
Ok(ValidatedOrder {
customer_name: customer_name.unwrap(),
email: email.unwrap(),
phone: phone.unwrap(),
address: ValidatedAddress {
street: street.unwrap(),
city: city.unwrap(),
postal_code: postal_code.unwrap(),
},
})
} else {
Err(errors)
}
}
After (With Stillwater - 18 lines, 57% reduction):
use stillwater::{Validation, validation::ValidateAll};
fn validate_order(input: &OrderInput) -> Validation<ValidatedOrder, Vec<String>> {
let address = (
validate_street(&input.address.street)
.map_err(|e| vec![format!("address.street: {}", e)]),
validate_city(&input.address.city)
.map_err(|e| vec![format!("address.city: {}", e)]),
validate_postal_code(&input.address.postal_code)
.map_err(|e| vec![format!("address.postal_code: {}", e)]),
)
.validate_all()
.map(|(street, city, postal_code)| ValidatedAddress { street, city, postal_code });
(
validate_name(&input.customer_name),
validate_email(&input.email),
validate_phone(&input.phone),
address,
)
.validate_all()
.map(|(customer_name, email, phone, address)| {
ValidatedOrder { customer_name, email, phone, address }
})
}
Key Improvements:
- 57% less code (42 → 18 lines)
- Composable nested validation
- Field path prefixes handled cleanly
- No nested conditionals
Conditional Validation
Problem: Password confirmation must match, but only validate strength if they match.
Before (Traditional Rust - 20 lines):
fn validate_password_change(
current: &str,
new_password: &str,
confirm: &str,
) -> Result<ValidatedPassword, Vec<String>> {
let mut errors = Vec::new();
// First check if passwords match
if new_password != confirm {
errors.push("Passwords do not match".to_string());
}
// Validate current password
if current.is_empty() {
errors.push("Current password required".to_string());
}
// Only validate strength if passwords match
if new_password == confirm {
if new_password.len() < 8 {
errors.push("Password must be at least 8 characters".to_string());
}
if !new_password.chars().any(|c| c.is_uppercase()) {
errors.push("Password must contain uppercase".to_string());
}
}
if errors.is_empty() {
Ok(ValidatedPassword { password: new_password.to_string() })
} else {
Err(errors)
}
}
After (With Stillwater - 14 lines, 30% reduction):
use stillwater::{Validation, validation::ValidateAll};
fn validate_password_change(
current: &str,
new_password: &str,
confirm: &str,
) -> Validation<ValidatedPassword, Vec<String>> {
let passwords_match = if new_password == confirm {
Validation::success(new_password.to_string())
} else {
Validation::failure(vec!["Passwords do not match".to_string()])
};
(
validate_non_empty(current, "Current password required"),
passwords_match.and_then(validate_password_strength),
)
.validate_all()
.map(|(_, password)| ValidatedPassword { password })
}
Key Improvements:
- Conditional validation via
and_then - Early exit for mismatched passwords
- Clean separation of concerns
Before/After Examples: Error Context
Error Trail for Debugging
Problem: When a deeply nested operation fails, you need the full context trail.
Before (Traditional Rust - 24 lines):
#[derive(Debug)]
struct ContextError {
message: String,
context: Vec<String>,
}
fn process_order(order_id: u64) -> Result<Receipt, ContextError> {
let order = fetch_order(order_id)
.map_err(|e| ContextError {
message: e,
context: vec!["fetching order".to_string()],
})?;
let inventory = check_inventory(&order.items)
.map_err(|e| ContextError {
message: e,
context: vec![
"checking inventory".to_string(),
format!("for order {}", order_id),
],
})?;
let receipt = create_receipt(&order, &inventory)
.map_err(|e| ContextError {
message: e,
context: vec![
"creating receipt".to_string(),
format!("for order {}", order_id),
],
})?;
Ok(receipt)
}
After (With Stillwater - 16 lines, 33% reduction):
use stillwater::effect::prelude::*;
use stillwater::effect::context::{EffectContext, EffectContextChain};
use stillwater::context::ContextError;
fn process_order(order_id: u64) -> impl Effect<Output = Receipt, Error = ContextError<String>, Env = AppEnv> {
fetch_order_effect(order_id)
.context("fetching order")
.context_chain(format!("processing order {}", order_id))
.and_then(move |order| {
check_inventory_effect(order)
.context("checking inventory")
.context_chain(format!("processing order {}", order_id))
.and_then(move |order| {
create_receipt_effect(order)
.context("creating receipt")
.context_chain(format!("processing order {}", order_id))
})
})
}
Key Improvements:
.context()wraps any error inContextError<E>with the given message.context_chain()adds additional context to an existingContextError- Error trail shows:
["fetching order", "processing order 999"]on failure - Each operation gets its own context; outer context added via
context_chain
Note:
.context()creates a newContextErrorwrapping the inner error. Use.context_chain()to add to an existingContextError’s trail.
Before/After Examples: Dependency Injection
Parameter Threading (3 Dependencies, 4 Functions)
Problem: Passing database, cache, and email service through multiple function calls.
Before (Traditional Rust - 32 lines):
async fn process_user_signup(
db: &Database,
cache: &Cache,
email_service: &EmailService,
input: SignupInput,
) -> Result<User, Error> {
let validated = validate_signup(&input)?;
let user = create_user(db, &validated).await?;
cache_user_session(cache, &user).await?;
send_welcome_email(email_service, &user).await?;
Ok(user)
}
async fn create_user(db: &Database, input: &ValidatedSignup) -> Result<User, Error> {
db.insert_user(input).await
}
async fn cache_user_session(cache: &Cache, user: &User) -> Result<(), Error> {
cache.set(&user.session_id, &user.id).await
}
async fn send_welcome_email(email_service: &EmailService, user: &User) -> Result<(), Error> {
email_service.send_template("welcome", &user.email).await
}
After (With Stillwater Reader Pattern - 24 lines, 25% reduction):
use stillwater::effect::prelude::*;
fn process_user_signup(input: SignupInput) -> impl Effect<Output = User, Error = Error, Env = AppEnv> {
from_result(validate_signup(&input))
.and_then(create_user)
.and_then(|user| {
let user_for_email = user.clone();
let user_to_return = user.clone();
cache_user_session(&user)
.and_then(move |_| send_welcome_email(&user_for_email))
.map(move |_| user_to_return)
})
}
fn create_user(input: ValidatedSignup) -> impl Effect<Output = User, Error = Error, Env = AppEnv> {
from_async(move |env: &AppEnv| {
let db = env.db.clone();
async move { db.insert_user(&input).await }
})
}
fn cache_user_session(user: &User) -> impl Effect<Output = (), Error = Error, Env = AppEnv> {
let session_id = user.session_id.clone();
let user_id = user.id;
from_async(move |env: &AppEnv| {
let cache = env.cache.clone();
async move { cache.set(&session_id, &user_id).await }
})
}
fn send_welcome_email(user: &User) -> impl Effect<Output = (), Error = Error, Env = AppEnv> {
let email = user.email.clone();
from_async(move |env: &AppEnv| {
let email_service = env.email.clone();
async move { email_service.send_template("welcome", &email).await }
})
}
Key Improvements:
- No parameter threading (dependencies accessed via environment)
- Functions are self-contained and composable
- Adding a new dependency requires only changing
AppEnv - Testing is trivial (just provide a test environment)
Note: When composing effects that need the same value, clone upfront to satisfy Rust’s ownership rules. The helper functions already extract only what they need.
Testing with Explicit Dependencies
Ordinary Rust functions and Stillwater effects can both use hand-written service implementations; neither requires a mocking framework. Stillwater’s contribution is a consistent environment boundary for the composed operations.
use stillwater::prelude::*;
use std::collections::HashMap;
#[derive(Clone)]
struct Env { users: HashMap<u64, String> }
tokio_test::block_on(async {
let env = Env { users: HashMap::from([(123, "Test".to_string())]) };
let effect = from_fn(|env: &Env| env.users.get(&123).cloned().ok_or("not found"));
assert_eq!(effect.run(&env).await, Ok("Test".to_string()));
});
Use recording service implementations to verify calls and persistent state. The registration example demonstrates the larger workflow and its failure boundaries.
Before/After Examples: Async Composition
Retry with Exponential Backoff
Problem: Retry a flaky network call with exponential backoff.
Before (Traditional Rust - 35 lines):
use std::time::Duration;
use tokio::time::sleep;
async fn fetch_with_retry(url: &str, max_retries: u32) -> Result<Response, Error> {
let mut attempt = 0;
let mut delay = Duration::from_millis(100);
loop {
match http_client.get(url).await {
Ok(response) => return Ok(response),
Err(e) => {
attempt += 1;
if attempt > max_retries {
return Err(Error::RetriesExhausted {
attempts: attempt,
last_error: Box::new(e),
});
}
// Exponential backoff with jitter
let jitter = rand::random::<u64>() % 50;
sleep(delay + Duration::from_millis(jitter)).await;
delay = std::cmp::min(delay * 2, Duration::from_secs(30));
}
}
}
}
After (With Stillwater - 14 lines, 60% reduction):
use stillwater::effect::prelude::*;
use stillwater::effect::retry::retry;
use stillwater::retry::RetryExhausted;
use stillwater::RetryPolicy;
use std::time::Duration;
fn fetch_with_retry(url: String)
-> impl Effect<Output = RetryExhausted<Response>, Error = RetryExhausted<Error>, Env = AppEnv>
{
retry(
move || {
let url = url.clone();
from_async(move |env: &AppEnv| {
let client = env.http.clone();
async move { client.get(&url).await }
})
},
RetryPolicy::exponential(Duration::from_millis(100))
.with_max_retries(3)
.with_max_delay(Duration::from_secs(30)),
)
}
// Usage: extract the response from RetryExhausted
// let result = fetch_with_retry(url).run(&env).await?;
// let response = result.into_value(); // Get the Response
// let attempts = result.attempts; // How many attempts it took
Key Improvements:
- 60% less code (35 → 14 lines)
- Built-in jitter and backoff calculations
- Configurable retry policy
RetryExhaustedtracks attempt count and total duration on both success and failure
Note: Both success and failure are wrapped in
RetryExhausted<T>which provides.into_value(),.attempts, and.total_durationfor observability.
Parallel Operations with Timeout
Problem: Fetch user data from multiple services concurrently with a timeout.
Before (Traditional Rust - 25 lines):
use tokio::time::timeout;
use futures::future::try_join3;
async fn fetch_user_dashboard(user_id: u64) -> Result<Dashboard, Error> {
let timeout_duration = Duration::from_secs(5);
let (profile, orders, recommendations) = timeout(
timeout_duration,
try_join3(
fetch_profile(user_id),
fetch_recent_orders(user_id),
fetch_recommendations(user_id),
)
)
.await
.map_err(|_| Error::Timeout)?
.map_err(Error::from)?;
Ok(Dashboard {
profile,
orders,
recommendations,
})
}
After (With Stillwater - 17 lines, 32% reduction):
use stillwater::effect::prelude::*;
use stillwater::effect::retry::with_timeout;
use stillwater::TimeoutError;
use std::time::Duration;
fn fetch_user_dashboard(user_id: u64) -> impl Effect<Output = Dashboard, Error = TimeoutError<Error>, Env = AppEnv> {
with_timeout(
zip3(
fetch_profile(user_id),
fetch_recent_orders(user_id),
fetch_recommendations(user_id),
)
.map(|(profile, orders, recommendations)| Dashboard {
profile,
orders,
recommendations,
}),
Duration::from_secs(5),
)
}
Key Improvements:
- 32% less code
zip3for type-safe parallel composition (returns an Effect)- Integrated timeout handling
- Clear error type showing timeout vs inner error
Note: Use
zip3for Effect composition. Thepar3function is an async helper that returns a tuple of Results directly, useful when you need individual error handling.
Real-World Scenarios
Scenario 1: API Request Handler
Problem: Handle an API request with validation, business logic, and error handling.
Before (Traditional Rust - 45 lines):
async fn handle_create_order(
db: &Database,
cache: &Cache,
email: &EmailService,
request: CreateOrderRequest,
) -> Result<ApiResponse<Order>, ApiError> {
// Validate input
let mut validation_errors = Vec::new();
if request.items.is_empty() {
validation_errors.push("Order must have at least one item");
}
if request.customer_id == 0 {
validation_errors.push("Invalid customer ID");
}
if request.items.iter().any(|i| i.quantity == 0) {
validation_errors.push("Item quantity must be positive");
}
if !validation_errors.is_empty() {
return Err(ApiError::ValidationFailed(validation_errors));
}
// Check customer exists
let customer = db.get_customer(request.customer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::NotFound("Customer not found"))?;
// Check inventory
for item in &request.items {
let stock = cache.get_stock(item.product_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if stock < item.quantity {
return Err(ApiError::BusinessLogic("Insufficient stock"));
}
}
// Create order
let order = db.create_order(&customer, &request.items)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
// Send confirmation (fire and forget)
let _ = email.send_order_confirmation(&customer.email, &order).await;
Ok(ApiResponse::success(order))
}
After (With Stillwater - 30 lines, 33% reduction):
use stillwater::{Validation, validation::ValidateAll};
use stillwater::effect::prelude::*;
fn handle_create_order(request: CreateOrderRequest) -> impl Effect<Output = ApiResponse<Order>, Error = ApiError, Env = AppEnv> {
// Validation phase
let validated = (
validate_non_empty_items(&request.items),
validate_customer_id(request.customer_id),
validate_item_quantities(&request.items),
)
.validate_all()
.map_err(ApiError::ValidationFailed);
from_validation(validated)
.and_then(move |_| {
// Business logic phase
fetch_customer(request.customer_id)
.and_then(move |customer| {
check_inventory(&request.items)
.and_then(move |_| create_order(&customer, &request.items))
.and_then(move |order| {
send_confirmation(&customer.email, &order)
.map(move |_| ApiResponse::success(order))
})
})
})
.map_err(|e| ApiError::Internal(e.to_string()))
}
Key Improvements:
- Clear separation: validation → business logic
- All validation errors collected upfront
- No explicit error mapping at each step
- Composable, testable functions
Scenario 2: Database Transaction
Problem: Execute multiple database operations atomically.
Before (Traditional Rust - 30 lines):
async fn transfer_funds(
db: &Database,
from_account: u64,
to_account: u64,
amount: Decimal,
) -> Result<Transfer, Error> {
let tx = db.begin_transaction().await?;
let result = async {
// Check source balance
let from = tx.get_account(from_account).await?;
if from.balance < amount {
return Err(Error::InsufficientFunds);
}
// Debit source
tx.update_balance(from_account, from.balance - amount).await?;
// Credit destination
let to = tx.get_account(to_account).await?;
tx.update_balance(to_account, to.balance + amount).await?;
// Record transfer
let transfer = tx.create_transfer(from_account, to_account, amount).await?;
Ok(transfer)
}.await;
match result {
Ok(transfer) => {
tx.commit().await?;
Ok(transfer)
}
Err(e) => {
tx.rollback().await?;
Err(e)
}
}
}
After (With Stillwater bracket - 24 lines, 20% reduction):
use stillwater::effect::prelude::*;
use stillwater::effect::bracket::{bracket_full, BracketError};
fn transfer_funds(
from_account: u64,
to_account: u64,
amount: Decimal,
) -> impl Effect<Output = Transfer, Error = BracketError<Error>, Env = AppEnv> {
bracket_full(
// Acquire: begin transaction
from_async(|env: &AppEnv| {
let db = env.db.clone();
async move { db.begin_transaction().await }
}),
// Release: always attempt commit (rollback on drop if uncommitted)
|tx| async move { tx.commit().await },
// Use: perform transfer operations
|tx| {
check_balance(tx, from_account, amount)
.and_then(move |_| debit_account(tx, from_account, amount))
.and_then(move |_| credit_account(tx, to_account, amount))
.and_then(move |_| record_transfer(tx, from_account, to_account, amount))
},
)
}
Key Improvements:
- Resource safety via
bracket_fullpattern BracketErrordistinguishes acquire/use/cleanup failures- Transaction auto-rollbacks on drop if not committed
- Composable operations within transaction
Note:
bracket_fullreturnsBracketError<E>which tells you exactly which phase failed. Use plainbracketif you only need the use error (cleanup errors are logged).
Scenario 3: Configuration Validation
Problem: Validate application configuration at startup.
Before (Traditional Rust - 40 lines):
fn validate_config(config: &RawConfig) -> Result<ValidatedConfig, Vec<ConfigError>> {
let mut errors = Vec::new();
let port = match config.port {
Some(p) if p > 0 && p < 65536 => Some(p as u16),
Some(p) => { errors.push(ConfigError::InvalidPort(p)); None }
None => { errors.push(ConfigError::MissingPort); None }
};
let database_url = match &config.database_url {
Some(url) if url.starts_with("postgres://") => Some(url.clone()),
Some(url) => { errors.push(ConfigError::InvalidDatabaseUrl(url.clone())); None }
None => { errors.push(ConfigError::MissingDatabaseUrl); None }
};
let log_level = match config.log_level.as_deref() {
Some("debug") | Some("info") | Some("warn") | Some("error") => {
Some(config.log_level.clone().unwrap())
}
Some(level) => { errors.push(ConfigError::InvalidLogLevel(level.to_string())); None }
None => Some("info".to_string()) // Default
};
let max_connections = match config.max_connections {
Some(n) if n > 0 && n <= 1000 => Some(n),
Some(n) => { errors.push(ConfigError::InvalidMaxConnections(n)); None }
None => Some(10) // Default
};
if errors.is_empty() {
Ok(ValidatedConfig {
port: port.unwrap(),
database_url: database_url.unwrap(),
log_level: log_level.unwrap(),
max_connections: max_connections.unwrap(),
})
} else {
Err(errors)
}
}
After (With Stillwater - 22 lines, 45% reduction):
use stillwater::{Validation, validation::ValidateAll};
fn validate_config(config: &RawConfig) -> Validation<ValidatedConfig, Vec<ConfigError>> {
(
validate_port(config.port),
validate_database_url(&config.database_url),
validate_log_level(&config.log_level).map(|v| v.unwrap_or("info".to_string())),
validate_max_connections(config.max_connections).map(|v| v.unwrap_or(10)),
)
.validate_all()
.map(|(port, database_url, log_level, max_connections)| {
ValidatedConfig { port, database_url, log_level, max_connections }
})
}
fn validate_port(port: Option<i32>) -> Validation<u16, Vec<ConfigError>> {
match port {
Some(p) if p > 0 && p < 65536 => Validation::success(p as u16),
Some(p) => Validation::failure(vec![ConfigError::InvalidPort(p)]),
None => Validation::failure(vec![ConfigError::MissingPort]),
}
}
// ... similar for other validators
Key Improvements:
- 45% less code
- Each field validator is independent and testable
- Default values handled cleanly with
map - All config errors reported at once
Boilerplate Reduction Summary
| Scenario | Before (LOC) | After (LOC) | Reduction |
|---|---|---|---|
| 3-field validation | 22 | 8 | 64% |
| 5-field nested validation | 42 | 18 | 57% |
| Conditional validation | 20 | 14 | 30% |
| Error context chain | 24 | 16 | 33% |
| Dependency threading (3 deps) | 32 | 24 | 25% |
| Retry with backoff | 35 | 14 | 60% |
| Parallel with timeout | 25 | 17 | 32% |
| API request handler | 45 | 30 | 33% |
| Database transaction | 30 | 24 | 20% |
| Config validation | 40 | 22 | 45% |
Average reduction across all examples: ~40%
Complementary Usage: Stillwater + Other Crates
Stillwater + anyhow
Use anyhow for error propagation, Stillwater for validation and effects:
use stillwater::{Validation, validation::ValidateAll};
use stillwater::effect::prelude::*;
use anyhow::{Result, Context};
fn process_request(input: RequestInput) -> impl Effect<Output = Response, Error = anyhow::Error, Env = AppEnv> {
// Validation phase with Stillwater
let validated = (
validate_email(&input.email),
validate_name(&input.name),
)
.validate_all()
.into_result()
.map_err(|errors| anyhow::anyhow!("Validation failed: {:?}", errors));
from_result(validated)
.and_then(|(email, name)| {
// Effect composition with context
create_user(email, name)
.map_err(|e| anyhow::anyhow!(e))
})
}
Stillwater + validator
Use validator derive macros for struct validation, Stillwater for custom logic:
use validator::Validate;
use stillwater::{Validation, validation::ValidateAll};
#[derive(Validate)]
struct UserInput {
#[validate(email)]
email: String,
#[validate(length(min = 8))]
password: String,
}
fn validate_user(input: &UserInput) -> Validation<ValidatedUser, Vec<String>> {
// Struct-level validation via validator
let struct_valid = match input.validate() {
Ok(()) => Validation::success(()),
Err(e) => Validation::failure(vec![format!("Struct validation: {}", e)]),
};
// Custom business logic via Stillwater
let email_unique = check_email_unique(&input.email);
let password_not_common = check_password_not_common(&input.password);
(struct_valid, email_unique, password_not_common)
.validate_all()
.map(|_| ValidatedUser {
email: input.email.clone(),
password: input.password.clone(),
})
}
Stillwater + thiserror
Use thiserror for error definitions, Stillwater for composition:
use thiserror::Error;
use stillwater::effect::prelude::*;
#[derive(Error, Debug)]
enum OrderError {
#[error("Customer not found: {0}")]
CustomerNotFound(u64),
#[error("Insufficient stock for product {product_id}")]
InsufficientStock { product_id: u64 },
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
}
fn process_order(order: Order) -> impl Effect<Output = Receipt, Error = OrderError, Env = AppEnv> {
fetch_customer(order.customer_id)
.map_err(OrderError::from)
.and_then(move |customer| {
check_stock(&order.items)
.and_then(move |_| create_receipt(&customer, &order))
})
}
vs frunk
frunk focuses on type-level functional programming with HLists, Generic, and other advanced concepts.
Similarities
- Both provide Validation with error accumulation
- Both implement Semigroup
Differences
Stillwater:
- ✓ Practical focus on common patterns
- ✓ Effect composition for I/O separation
- ✓ Lower learning curve
- ✓ Better documentation for beginners
- ✓ Async support
frunk:
- ✓ More advanced type-level features (HLists, Generic)
- ✓ Powerful generic programming
- ✗ Steeper learning curve
- ✗ No effect system
- ✗ No async support
When to use frunk
- Type-level programming is important
- You need HList transformations
- You’re comfortable with advanced type theory
When to use Stillwater
- Validation and effects are your primary needs
- You want a gentler learning curve
- You need async support
vs monadic
monadic provides Reader/Writer/State monads with macro-based do-notation.
Similarities
- Both provide effect composition
- Both handle dependencies (Reader monad)
Differences
Stillwater:
- ✓ No macro DSL (more idiomatic Rust)
- ✓ Method chaining instead of do-notation
- ✓ Validation with error accumulation
- ✓ Async support
- ✓ Zero dependencies
- ✓ Writer Effect for logging/accumulation
- ✓ Reader pattern with
ask()/asks()/local()
monadic:
- ✓ State monad
- ✓ Do-notation via macros
- ✗ Macro-heavy syntax (
rdrdo!) - ✗ No validation
- ✗ No async support
When to use monadic
- You want Haskell-style do-notation
- You need State monad
- You’re porting Haskell code
When to use Stillwater
- You prefer Rust idioms over Haskell syntax
- You need validation and effects together
- You want async support
- You need Writer Effect for logging/metrics
vs anyhow / thiserror
anyhow provides ergonomic error handling. thiserror provides derive macros for error types.
Similarities
- All handle errors
- All work with Result
Differences
Stillwater:
- ✓ Error accumulation (Validation)
- ✓ Effect composition
- ✓ ContextError for trails
- ✗ Less focused on error handling alone
anyhow/thiserror:
- ✓ Excellent error handling ergonomics
- ✓ Great for error propagation
- ✗ No error accumulation
- ✗ No effect system
When to use anyhow/thiserror
- Error handling is your only need
- You want minimal boilerplate
- Short-circuiting errors are fine
When to use Stillwater
- You need error accumulation
- You want effect composition
- You’re building validation-heavy apps
Recommendation: Use both! Stillwater for business logic, anyhow for error propagation.
vs validator
validator provides derive macros for common validation rules.
Similarities
- Both validate data
- Both can accumulate errors
Differences
Stillwater:
- ✓ Functional composition
- ✓ Effect system
- ✓ Custom validation logic
- ✗ No derive macros
- ✗ More verbose for simple cases
validator:
- ✓ Derive macros for common validations
- ✓ Less boilerplate for simple cases
- ✗ No effect system
- ✗ Less flexible for complex logic
When to use validator
- Simple struct validation with standard rules
- You want derive macros
- Validation is your only need
When to use Stillwater
- Complex validation logic
- Need effect composition
- Want full control over validation
Recommendation: Use both! validator for struct-level rules, Stillwater for complex logic.
vs Standard Library (Result, Option)
Result and Option are the foundation. When should you reach for Stillwater?
Use Result when
- Short-circuiting is desired (fail fast)
- Single error is sufficient
- Simple error propagation
Use Validation when
- You want ALL errors at once
- Validating forms or API requests
- Independent validations
Use Effect when
- Separating I/O from logic
- Testing with mock environments
- Composing async operations
Rule of thumb: Start with Result. Reach for Stillwater when you need error accumulation or I/O separation.
Philosophy Comparison
| Library | Philosophy |
|---|---|
| Stillwater | Pragmatic FP: common patterns, low learning curve |
| frunk | Academic FP: type-level programming, HLists |
| monadic | Haskell-style: monad abstraction, do-notation |
| anyhow | Ergonomic errors: minimal boilerplate |
| validator | Declarative: derive macros for common cases |
Migration Guide
From Result to Stillwater
// Before: Result (short-circuits)
fn validate(data: Data) -> Result<Valid, Error> {
let email = validate_email(data.email)?;
let age = validate_age(data.age)?;
Ok(Valid { email, age })
}
// After: Validation (accumulates)
use stillwater::{Validation, validation::ValidateAll};
fn validate(data: Data) -> Validation<Valid, Vec<Error>> {
(
validate_email(data.email),
validate_age(data.age),
)
.validate_all()
.map(|(email, age)| Valid { email, age })
}
From async fn to Effect
// Before: async fn (hard to test)
async fn create_user(db: &Database, email: String) -> Result<User, Error> {
let user = User { email };
db.save(&user).await?;
Ok(user)
}
// After: Effect (testable)
use stillwater::effect::prelude::*;
fn create_user(email: String) -> impl Effect<Output = User, Error = Error, Env = AppEnv> {
let user = User { email };
from_async(move |env: &AppEnv| {
let db = env.db.clone();
let user = user.clone();
async move {
db.save(&user).await?;
Ok(user)
}
})
}
Summary
| Use Case | Best Choice |
|---|---|
| Form validation | Stillwater Validation |
| API validation | Stillwater Validation |
| Testable I/O | Stillwater Effect |
| Error propagation | anyhow + Stillwater |
| Simple validations | validator + Stillwater |
| Type-level programming | frunk |
| Haskell-style monads | monadic |
| Generic error handling | anyhow/thiserror |
Further Reading
- Stillwater User Guide
- frunk documentation
- monadic documentation
- anyhow documentation
- validator documentation
IO API Analysis - read and write
Status
Accepted and implemented.
The current IO helper API is:
IO::read(...)
IO::write(...)
IO::read_async(...)
IO::write_async(...)
The previous design discussion considered more database-oriented and generic names. This document preserves that rationale while describing the current implementation accurately.
Current Design
The IO module provides a small namespace for creating boxed effects from service operations:
use stillwater::IO;
let find_user = IO::read(|db: &Database| db.find_user(id));
let save_user = IO::write(|repo: &UserRepository| repo.save_user(user));
Both read and write receive &T, not &mut T. This follows Stillwater’s effect model: Effect::run receives &Env, and services that mutate state should expose safe interior mutation through handles such as connection pools, Arc<Mutex<T>>, channels, or client types that perform mutation behind an immutable reference.
Async operations use explicit async variants:
let find_user = IO::read_async(|db: &Database| async move {
db.find_user(id).await
});
let save_user = IO::write_async(|repo: &UserRepository| async move {
repo.save_user(user).await
});
Intent Of The Names
The names are semantic, not borrow-mode distinctions:
readmeans the operation is conceptually query-like and returns information.writemeans the operation is conceptually command-like and changes external state.read_asyncandwrite_asyncmirror the same split for futures.
This lets code communicate intent even though both closures receive immutable references:
IO::read(|cache: &Cache| cache.get(key))
IO::write(|cache: &Cache| cache.set(key, value))
Environment Extraction
IO helpers use AsRef<T> to extract a service from a larger application environment:
#[derive(Clone)]
struct AppEnv {
db: Database,
cache: Cache,
}
impl AsRef<Database> for AppEnv {
fn as_ref(&self) -> &Database {
&self.db
}
}
impl AsRef<Cache> for AppEnv {
fn as_ref(&self) -> &Cache {
&self.cache
}
}
The closure parameter tells the compiler which service type to extract:
let user_effect = IO::read(|db: &Database| db.find_user(id));
let cache_effect = IO::write(|cache: &Cache| cache.set(key, value));
This keeps the effect code decoupled from the concrete layout of AppEnv.
Mutation Pattern
Because the environment is immutable, mutable services should use interior mutability or handle types that are already designed for shared access.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct Cache {
entries: Arc<Mutex<HashMap<String, String>>>,
}
impl Cache {
fn get(&self, key: &str) -> Option<String> {
self.entries.lock().unwrap().get(key).cloned()
}
fn set(&self, key: String, value: String) {
self.entries.lock().unwrap().insert(key, value);
}
}
let read_effect = IO::read(|cache: &Cache| {
cache.get("theme")
});
let write_effect = IO::write(|cache: &Cache| {
cache.set("theme".to_string(), "dark".to_string());
});
This pattern aligns with async Rust service handles such as database pools and HTTP clients, which are commonly cloneable and internally synchronized.
Naming Alternatives Considered
read / write
IO::read(|db: &Database| db.find_user(id))
IO::write(|repo: &UserRepository| repo.save_user(user))
Pros:
- Clear across files, databases, network clients, caches, and loggers.
- Matches common I/O vocabulary.
- Short and easy to scan in effect pipelines.
- Communicates conceptual side effect without exposing mutable borrows.
Cons:
writecan sound storage-specific, even though it also covers commands such as sending email or publishing events.- Operations that both read and write require judgment.
Verdict: best default.
access / modify
IO::access(|db: &Database| db.find_user(id))
IO::modify(|repo: &UserRepository| repo.save_user(user))
Pros:
- General enough for any resource.
modifyclearly suggests state change.
Cons:
accessis vague.- Less familiar than read/write.
Verdict: reasonable but less readable.
get / set
IO::get(|cache: &Cache| cache.get(key))
IO::set(|cache: &Cache| cache.set(key, value))
Pros:
- Short and familiar.
Cons:
- Too narrow for deletes, inserts, publishes, and sends.
- Suggests property access rather than general I/O.
Verdict: too limited.
run / run_mut
IO::run(|db: &Database| db.find_user(id))
IO::run_mut(|repo: &UserRepository| repo.save_user(user))
Pros:
- Generic.
- Familiar Rust suffix pattern.
Cons:
- Loses the semantic distinction between query-like and command-like operations.
run_mutwould be misleading because the current API does not pass&mut T.
Verdict: too generic and no longer matches the implementation model.
Database-Oriented Names
Database APIs often use names equivalent to “query” for reads and “execute” for writes. That convention is familiar in database code, but Stillwater’s IO helpers are not database-specific. They also wrap file systems, caches, message buses, HTTP clients, loggers, and application services.
Verdict: too database-centric for a general effect library.
Comparison Matrix
| Option | Clarity | Generality | Matches Current Env Model | Verdict |
|---|---|---|---|---|
read / write | High | High | Yes | Accepted |
access / modify | Medium | High | Yes | Acceptable but less clear |
get / set | Medium | Low | Yes | Too limited |
run / run_mut | Low | High | No | Misleading |
| Database-oriented names | Medium | Low | Mixed | Too narrow |
Real-World Examples
Database
IO::read(|db: &Database| db.find_user(id))
IO::write(|db: &Database| db.insert_user(user))
Cache
IO::read(|cache: &Cache| cache.get(key))
IO::write(|cache: &Cache| cache.set(key, value))
Logger
IO::read(|logger: &Logger| logger.level())
IO::write(|logger: &Logger| logger.info("user registered"))
File System Abstraction
IO::read(|fs: &FileSystem| fs.read_to_string(path))
IO::write(|fs: &FileSystem| fs.write_string(path, contents))
Async HTTP Client
IO::read_async(|client: &HttpClient| async move {
client.get_json(url).await
})
IO::write_async(|client: &HttpClient| async move {
client.post_json(url, body).await
})
Decision
Use read and write as the conceptual split:
IO::read(...)
IO::write(...)
IO::read_async(...)
IO::write_async(...)
The helpers intentionally use AsRef<T> and immutable service references. This keeps environments shareable and compatible with the broader Effect model. Services that need mutation should provide internally safe mutation, not require &mut Env.
Consequences
Positive:
- The API works consistently with
Effect::run(&env). - Environments remain easy to share across async and parallel effects.
- Service extraction is explicit through
AsRef<T>. - The names are understandable outside database code.
Tradeoffs:
writeis semantic rather than enforced by the borrow type.- Users must understand that mutation requires interior mutability or shared service handles.
- A composite environment can only implement one
AsRef<T>per service type, so duplicate services of the same concrete type may need newtype wrappers.
Guidance
Prefer IO::read for:
- Lookup operations
- Configuration access
- Cache reads
- Database selects
- HTTP GET-like operations
Prefer IO::write for:
- Inserts, updates, and deletes
- Cache writes
- Logging
- Event publishing
- Email sending
- HTTP POST/PUT/PATCH-like operations
Use from_fn or from_async directly when the operation needs the whole environment or when service extraction through AsRef<T> is not the right fit.
Validation::all() - Tuples vs Alternatives
Historical design record. This predates the current concrete-combinator API and contains unimplemented or superseded proposals. It is not a 2.0 usage guide. Use the effects guide and API tiers for current behavior.
Current Design (Tuples)
Validation::all((
validate_email(&input.email),
validate_password(&input.password),
validate_age(input.age),
))
.map(|(email, password, age)| User { email, password, age })
Trade-off Analysis
Option 1: Tuples (Current Design)
How it works:
impl<T1, T2, E: Semigroup> Validation<(T1, T2), E> {
fn all(validations: (Validation<T1, E>, Validation<T2, E>)) -> Validation<(T1, T2), E>
}
// Implement for (T1, T2), (T1, T2, T3), (T1, T2, T3, T4), etc.
Pros:
- ✅ Native Rust, no dependencies
- ✅ Type inference works perfectly
- ✅ Pattern matching is clean:
|(email, age, name)| - ✅ Each validation can have different type
- ✅ Compiler catches arity mismatches
- ✅ Zero runtime overhead
Cons:
- ❌ Limited to tuple size (typically 12-16)
- ❌ Need to implement for each tuple size (macro helps)
- ❌ Larger tuples get unwieldy:
(a, b, c, d, e, f, g, h, i, j, k, l)
Real-world impact:
- Forms rarely have >12 fields that validate independently
- If you do, you’re probably doing something wrong (split the form)
- For rare cases, can nest:
Validation::all((group1, group2))
Example of edge case:
// 15 fields? Probably indicates poor UX
let personal = Validation::all((name, email, phone, address));
let payment = Validation::all((card, cvv, expiry, billing));
let shipping = Validation::all((addr, method, preference));
Validation::all((personal, payment, shipping))
.map(|((p1, p2, p3, p4), (pay1, pay2, pay3, pay4), (s1, s2, s3))| {
// Awkward, but you shouldn't be here anyway
})
Option 2: Vec/Slice (Homogeneous)
fn all_same<T, E>(validations: Vec<Validation<T, E>>) -> Validation<Vec<T>, E>
Pros:
- ✅ No size limit
- ✅ Works with dynamic number of validations
- ✅ Simple implementation
Cons:
- ❌ All validations must return SAME type
- ❌ Loses type information (can’t distinguish email from name)
- ❌ Can’t build heterogeneous structs easily
When it’s useful:
// Validating a list of records (all same type)
let validated_records = Validation::all_vec(
records.into_iter().map(validate_record).collect()
);
// Good for: bulk data validation
// Bad for: form field validation
Verdict: Useful as separate method, not replacement for tuple version.
Option 3: HList (Like Frunk)
// Heterogeneous list (compile-time linked list)
Validation::all(HCons(
validate_email(input),
HCons(
validate_password(input),
HCons(
validate_age(input),
HNil
)
)
))
Pros:
- ✅ No size limit
- ✅ Each element can be different type
- ✅ Type-safe composition
Cons:
- ❌ Horrible syntax
- ❌ Requires complex type-level programming
- ❌ Hard for users to understand
- ❌ Error messages are cryptic
- ❌ Defeats our “simplicity” goal
Example error:
error[E0271]: type mismatch resolving `<HCons<Validation<Email, Vec<Error>>,
HCons<Validation<Password, Vec<Error>>, HCons<Validation<Age, Vec<Error>>,
HNil>>> as ValidateAll>::Output == Validation<HCons<Email, HCons<Password,
HCons<Age, HNil>>>, Vec<Error>>`
Verdict: Too complex. Against our philosophy of “Rust-first, not Haskell-in-Rust.”
Option 4: Macro
validate_all![
email: validate_email(&input.email),
password: validate_password(&input.password),
age: validate_age(input.age),
]
// Returns: Validation<NamedFields, E>
// where you can access .email, .password, .age
Pros:
- ✅ Clean syntax
- ✅ No size limit
- ✅ Named fields (self-documenting)
- ✅ Could generate struct automatically
Cons:
- ❌ Macro complexity
- ❌ Magic / non-obvious
- ❌ Debugging is harder
- ❌ We said we want to avoid heavy macros
- ❌ Generated types have weird names
Verdict: Nice ergonomics, but against our “no magic” principle.
Option 5: Builder Pattern
Validation::builder()
.add(validate_email(&input.email))
.add(validate_password(&input.password))
.add(validate_age(input.age))
.build()
.map(|(email, password, age)| User { email, password, age })
Pros:
- ✅ No size limit
- ✅ Fluent API
Cons:
- ❌ More verbose than tuples
- ❌ Still limited by tuple size at the end
- ❌ Doesn’t solve the actual problem
- ❌ More API surface area
- ❌ Less clear than direct tuple
Verdict: Adds complexity without solving the core issue.
Real-World Data
Let’s look at actual forms in popular apps:
Simple forms (90% of cases):
- Login: 2 fields (email, password)
- Signup: 3-5 fields (email, password, name, age, terms)
- Contact: 4-5 fields (name, email, subject, message, consent)
- Payment: 6-8 fields (card, cvv, expiry, name, address, zip, country)
Complex forms (9% of cases):
- User profile: 10-12 fields
- Shipping info: 8-10 fields
- Advanced settings: 12-15 fields
Insanely complex (1% of cases):
- Tax forms: 50+ fields
- Medical intake: 30+ fields
- Government applications: 100+ fields
For the 1% edge case:
- You should probably split into multiple steps/pages anyway (UX best practice)
- Or validate in groups (more meaningful error grouping)
- Tuple limit isn’t the real problem
Hybrid Approach
Recommendation: Support BOTH
// 1. Tuple version (for most cases)
impl Validation<T, E> {
fn all<Tuple>(validations: Tuple) -> Validation<TupleOutput, E>
where
Tuple: ValidateAll<E>, // Implemented for tuples 1-12
{
// ...
}
}
// 2. Vec version (for homogeneous collections)
impl Validation<T, E> {
fn all_vec(validations: Vec<Validation<T, E>>) -> Validation<Vec<T>, E> {
// ...
}
}
// 3. Iterator version (for lazy evaluation)
impl Validation<T, E> {
fn all_iter<I>(validations: I) -> Validation<Vec<T>, E>
where
I: IntoIterator<Item = Validation<T, E>>,
{
// ...
}
}
Usage examples:
// Case 1: Form validation (different types)
Validation::all((
validate_email(input),
validate_password(input),
validate_age(input),
)) // Returns: Validation<(Email, Password, Age), E>
// Case 2: Bulk validation (same type)
let records: Vec<RawRecord> = ...;
Validation::all_vec(
records.into_iter().map(validate_record).collect()
) // Returns: Validation<Vec<ValidRecord>, E>
// Case 3: Lazy validation (iterator)
Validation::all_iter(
csv_lines.iter().map(|line| validate_line(line))
) // Returns: Validation<Vec<ValidLine>, E>
Comparison Matrix
| Approach | Syntax | Type Safety | Size Limit | Complexity | Verdict |
|---|---|---|---|---|---|
| Tuples | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ (12-16) | ⭐⭐⭐⭐⭐ | ✅ USE |
| Vec | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ✅ ADD |
| HList | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ | ❌ SKIP |
| Macro | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ❌ SKIP |
| Builder | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ❌ SKIP |
Recommended Implementation
// Core trait for tuple validation
pub trait ValidateAll<E: Semigroup> {
type Output;
fn validate_all(self) -> Validation<Self::Output, E>;
}
// Implement for tuples 1-12 (via macro)
macro_rules! impl_validate_all {
($($T:ident),+) => {
impl<E: Semigroup, $($T),+> ValidateAll<E> for ($(Validation<$T, E>),+) {
type Output = ($($T),+);
fn validate_all(self) -> Validation<Self::Output, E> {
// Implementation that accumulates errors
}
}
}
}
impl_validate_all!(T1);
impl_validate_all!(T1, T2);
impl_validate_all!(T1, T2, T3);
// ... up to 12
// Public API
impl<T, E: Semigroup> Validation<T, E> {
pub fn all<V: ValidateAll<E>>(validations: V) -> Validation<V::Output, E> {
validations.validate_all()
}
pub fn all_vec(validations: Vec<Validation<T, E>>) -> Validation<Vec<T>, E> {
// Fold over vec, accumulating errors
}
pub fn all_iter<I>(iter: I) -> Validation<Vec<T>, E>
where
I: IntoIterator<Item = Validation<T, E>>,
{
Self::all_vec(iter.into_iter().collect())
}
}
Decision
YES, tuples are fine!
Reasons:
- ✅ Covers 99% of use cases (forms rarely >12 independent fields)
- ✅ Zero magic (native Rust, obvious behavior)
- ✅ Type safe (different types for different fields)
- ✅ Clean syntax (readable, no boilerplate)
- ✅ Easy to implement (macro generates impls)
Edge cases handled by:
all_vec()for homogeneous collections (bulk validation)- Nesting tuples for rare >12 field cases (split into logical groups anyway)
Not worth the complexity:
- ❌ HList (too complex, cryptic errors)
- ❌ Macro (magic, debugging pain)
- ❌ Builder (verbose, doesn’t solve size limit)
Real Advantage of Alternatives?
Short answer: No.
Long answer:
The only “advantage” alternatives offer is no size limit, but:
-
Size limit isn’t a real problem in practice
- 99% of validations fit in 12 fields
- The 1% should be split anyway (UX best practice)
-
Size limit is a feature, not a bug
- Forces you to think about grouping
- Prevents monster validation functions
- Encourages better UX (multi-step forms)
-
Alternative costs outweigh benefits
- HList: Too complex, scary errors
- Macro: Magic, non-obvious
- Vec: Loses type safety
If you truly have 50 validations:
// Good: Logical grouping
let personal = Validation::all((name, email, phone, dob));
let address = Validation::all((street, city, state, zip));
let payment = Validation::all((card, cvv, expiry));
Validation::all((personal, address, payment))
.map(|(personal, address, payment)| {
CompleteForm { personal, address, payment }
})
// This is better UX AND better code!
Final Recommendation
Use tuples for Validation::all()
Also provide:
all_vec()for homogeneous collectionsall_iter()for iterator compatibility
Document:
- Tuple limit (12) and why it’s not a problem
- How to group validations for complex forms
- When to use
all_vec()vsall()
Skip:
- HList (too complex)
- Macros (too magical)
- Builder (adds no value)
Tuples are not just “fine for now” - they’re the right long-term choice.
Async Design for Stillwater
Historical design record. This predates the current concrete-combinator API and contains unimplemented or superseded proposals. It is not a 2.0 usage guide. Use the effects guide and API tiers for current behavior.
Critical Decision: Async from the Start
Since async is important for MVP, we need to design Effect with async as a first-class concern, not an afterthought.
Design Options
Option 1: Separate Sync and Async Types
// Sync version
struct Effect<T, E, Env> {
run_fn: Box<dyn FnOnce(&Env) -> Result<T, E>>,
}
// Async version
struct AsyncEffect<T, E, Env> {
run_fn: Box<dyn FnOnce(&Env) -> Pin<Box<dyn Future<Output = Result<T, E>> + Send>>>,
}
Pros:
- ✅ Simple, clear separation
- ✅ No performance overhead for sync code
- ✅ Easy to understand which is which
Cons:
- ❌ Duplicates entire API (and_then, map, etc.)
- ❌ Can’t easily mix sync and async
- ❌ Confusing for users: which one to use?
Verdict: Too much duplication, against DRY.
Option 2: Unified Type with Async Methods
struct Effect<T, E, Env> {
// Store function, not Future
run_fn: Box<dyn FnOnce(&Env) -> BoxFuture<'static, Result<T, E>> + Send>,
}
impl<T, E, Env> Effect<T, E, Env> {
// Create from sync function
pub fn from_sync<F>(f: F) -> Self
where
F: FnOnce(&Env) -> Result<T, E> + Send + 'static,
{
Effect {
run_fn: Box::new(move |env| {
let result = f(env);
Box::pin(async move { result })
}),
}
}
// Create from async function
pub fn from_async<F, Fut>(f: F) -> Self
where
F: FnOnce(&Env) -> Fut + Send + 'static,
Fut: Future<Output = Result<T, E>> + Send + 'static,
{
Effect {
run_fn: Box::new(move |env| Box::pin(f(env))),
}
}
// Run always returns Future
pub async fn run(self, env: &Env) -> Result<T, E> {
(self.run_fn)(env).await
}
}
Pros:
- ✅ Single API, works for both sync and async
- ✅ Can freely mix sync and async effects
- ✅ Clean for users: one Effect type
Cons:
- ⚠️ Always returns Future (even for sync code)
- ⚠️ Requires async runtime even for pure sync code
- ⚠️ Boxing overhead
Verdict: Good, but forces async everywhere.
Option 3: Generic Over Sync/Async (Type-State Pattern)
// Marker types
struct Sync;
struct Async;
struct Effect<T, E, Env, Runtime = Sync> {
run_fn: Box<dyn ...>, // Different based on Runtime
_phantom: PhantomData<Runtime>,
}
impl<T, E, Env> Effect<T, E, Env, Sync> {
pub fn run(self, env: &Env) -> Result<T, E> { ... }
}
impl<T, E, Env> Effect<T, E, Env, Async> {
pub async fn run(self, env: &Env) -> Result<T, E> { ... }
}
Pros:
- ✅ Type system enforces sync vs async
- ✅ No runtime overhead for sync
- ✅ Can convert between them
Cons:
- ❌ Complex type signatures
- ❌ Hard to implement correctly
- ❌ Confusing for users
- ❌ Viral type parameter
Verdict: Too complex for the benefit.
Option 4: Runtime-Agnostic with Trait-Based Execution (Recommended)
use std::future::Future;
use std::pin::Pin;
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub struct Effect<T, E, Env> {
// Always async internally for maximum flexibility
run_fn: Box<dyn FnOnce(&Env) -> BoxFuture<'_, Result<T, E>> + Send>,
}
impl<T, E, Env> Effect<T, E, Env>
where
T: Send + 'static,
E: Send + 'static,
{
/// Create from synchronous function
pub fn pure(value: T) -> Self {
Effect {
run_fn: Box::new(move |_env| {
Box::pin(async move { Ok(value) })
}),
}
}
/// Create from synchronous fallible function
pub fn from_fn<F>(f: F) -> Self
where
F: FnOnce(&Env) -> Result<T, E> + Send + 'static,
{
Effect {
run_fn: Box::new(move |env| {
let result = f(env);
Box::pin(async move { result })
}),
}
}
/// Create from async function
pub fn from_async<F, Fut>(f: F) -> Self
where
F: FnOnce(&Env) -> Fut + Send + 'static,
Fut: Future<Output = Result<T, E>> + Send + 'static,
{
Effect {
run_fn: Box::new(move |env| Box::pin(f(env))),
}
}
/// Chain effects
pub fn and_then<U, F>(self, f: F) -> Effect<U, E, Env>
where
F: FnOnce(T) -> Effect<U, E, Env> + Send + 'static,
U: Send + 'static,
{
Effect {
run_fn: Box::new(move |env| {
Box::pin(async move {
let value = (self.run_fn)(env).await?;
let next_effect = f(value);
(next_effect.run_fn)(env).await
})
}),
}
}
/// Transform success value
pub fn map<U, F>(self, f: F) -> Effect<U, E, Env>
where
F: FnOnce(T) -> U + Send + 'static,
U: Send + 'static,
{
Effect {
run_fn: Box::new(move |env| {
Box::pin(async move {
let value = (self.run_fn)(env).await?;
Ok(f(value))
})
}),
}
}
/// Run the effect (always async)
pub async fn run(self, env: &Env) -> Result<T, E> {
(self.run_fn)(env).await
}
}
Pros:
- ✅ Single, unified API
- ✅ Sync code works seamlessly (wrapped in ready Future)
- ✅ Natural async support
- ✅ Can mix sync and async effects freely
- ✅ Composable: async + sync + async chains work
Cons:
- ⚠️ Always requires async runtime (but acceptable in 2025)
- ⚠️ Some boxing overhead (minimal for I/O-bound code)
- ⚠️ Sync code has tiny wrapper cost (negligible)
Verdict: Best balance of simplicity and capability.
Recommended Design: Option 4
Rationale:
- Modern Rust is async-first for I/O
- Tokio/async-std are standard in server apps
- Wrapping sync in Future is cheap
- Single API is much cleaner
Core Effect Implementation
use std::future::Future;
use std::pin::Pin;
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
/// An effect that may perform I/O and depends on an environment
pub struct Effect<T, E = Infallible, Env = ()> {
run_fn: Box<dyn FnOnce(&Env) -> BoxFuture<'_, Result<T, E>> + Send>,
}
impl<T, E, Env> Effect<T, E, Env>
where
T: Send + 'static,
E: Send + 'static,
{
/// Create a pure value (no effects)
pub fn pure(value: T) -> Self {
Effect {
run_fn: Box::new(move |_| Box::pin(async move { Ok(value) })),
}
}
/// Create an error
pub fn fail(error: E) -> Self {
Effect {
run_fn: Box::new(move |_| Box::pin(async move { Err(error) })),
}
}
/// Create from synchronous function
pub fn from_fn<F>(f: F) -> Self
where
F: FnOnce(&Env) -> Result<T, E> + Send + 'static,
{
Effect {
run_fn: Box::new(move |env| {
let result = f(env);
Box::pin(async move { result })
}),
}
}
/// Create from async function
pub fn from_async<F, Fut>(f: F) -> Self
where
F: FnOnce(&Env) -> Fut + Send + 'static,
Fut: Future<Output = Result<T, E>> + Send + 'static,
{
Effect {
run_fn: Box::new(move |env| Box::pin(f(env))),
}
}
/// Create from Result
pub fn from_result(result: Result<T, E>) -> Self {
Effect {
run_fn: Box::new(move |_| Box::pin(async move { result })),
}
}
/// Chain dependent effects
pub fn and_then<U, F>(self, f: F) -> Effect<U, E, Env>
where
F: FnOnce(T) -> Effect<U, E, Env> + Send + 'static,
U: Send + 'static,
{
Effect {
run_fn: Box::new(move |env| {
Box::pin(async move {
let value = (self.run_fn)(env).await?;
let next = f(value);
(next.run_fn)(env).await
})
}),
}
}
/// Transform success value
pub fn map<U, F>(self, f: F) -> Effect<U, E, Env>
where
F: FnOnce(T) -> U + Send + 'static,
U: Send + 'static,
{
Effect {
run_fn: Box::new(move |env| {
Box::pin(async move {
(self.run_fn)(env).await.map(f)
})
}),
}
}
/// Transform error value
pub fn map_err<E2, F>(self, f: F) -> Effect<T, E2, Env>
where
F: FnOnce(E) -> E2 + Send + 'static,
E2: Send + 'static,
{
Effect {
run_fn: Box::new(move |env| {
Box::pin(async move {
(self.run_fn)(env).await.map_err(f)
})
}),
}
}
/// Recover from errors
pub fn or_else<F>(self, f: F) -> Self
where
F: FnOnce(E) -> Effect<T, E, Env> + Send + 'static,
{
Effect {
run_fn: Box::new(move |env| {
Box::pin(async move {
match (self.run_fn)(env).await {
Ok(value) => Ok(value),
Err(err) => {
let recovery = f(err);
(recovery.run_fn)(env).await
}
}
})
}),
}
}
/// Run the effect with the given environment
pub async fn run(self, env: &Env) -> Result<T, E> {
(self.run_fn)(env).await
}
}
Helper Methods (Idiomatic + Functional)
Based on your guidance to include helpers when appropriate:
1. Tap (Side Effect, Return Value)
impl<T, E, Env> Effect<T, E, Env>
where
T: Send + Clone + 'static,
E: Send + 'static,
{
/// Perform a side effect and return the original value
pub fn tap<F>(self, f: F) -> Self
where
F: FnOnce(&T) -> Effect<(), E, Env> + Send + 'static,
{
self.and_then(move |value| {
let value_clone = value.clone();
f(&value).map(move |_| value_clone)
})
}
}
// Usage:
user_effect
.tap(|user| IO::write(|logger| logger.info(format!("Created user: {}", user.id))))
// user is returned unchanged
2. Check (Conditional Failure)
impl<T, E, Env> Effect<T, E, Env>
where
T: Send + 'static,
E: Send + 'static,
{
/// Fail with error if predicate is false
pub fn check<P, F>(self, predicate: P, error_fn: F) -> Self
where
P: FnOnce(&T) -> bool + Send + 'static,
F: FnOnce() -> E + Send + 'static,
{
self.and_then(move |value| {
if predicate(&value) {
Effect::pure(value)
} else {
Effect::fail(error_fn())
}
})
}
}
// Usage:
user_effect.check(
|user| user.age >= 18,
|| AppError::AgeTooYoung
)
3. With (Combine Effects, Keep Both Results)
impl<T, E, Env> Effect<T, E, Env>
where
T: Send + 'static,
E: Send + 'static,
{
/// Combine with another effect, returning both values
pub fn with<U, F>(self, f: F) -> Effect<(T, U), E, Env>
where
F: FnOnce(&T) -> Effect<U, E, Env> + Send + 'static,
U: Send + 'static,
{
self.and_then(move |value| {
let effect = f(&value);
effect.map(move |other| (value, other))
})
}
}
// Usage:
user_effect.with(|user| {
IO::read(|db| db.find_orders(user.id))
})
// Returns: Effect<(User, Vec<Order>), E, Env>
4. Auto-Converting and_then
impl<T, E, Env> Effect<T, E, Env>
where
T: Send + 'static,
E: Send + 'static,
{
/// Chain effect with automatic error conversion
pub fn and_then_auto<U, E2, F>(self, f: F) -> Effect<U, E, Env>
where
F: FnOnce(T) -> Effect<U, E2, Env> + Send + 'static,
U: Send + 'static,
E2: Send + 'static,
E: From<E2>,
{
self.and_then(move |value| {
f(value).map_err(E::from)
})
}
}
// Usage (no manual map_err needed):
user_effect
.and_then_auto(|user| validate_user(user)) // Different error type, auto-converts!
.and_then_auto(|user| save_user(user)) // Another different error, auto-converts!
5. Reference-Friendly and_then
impl<T, E, Env> Effect<T, E, Env>
where
T: Send + Clone + 'static,
E: Send + 'static,
{
/// Chain effect by borrowing value, then returning it
pub fn and_then_ref<U, F>(self, f: F) -> Effect<T, E, Env>
where
F: FnOnce(&T) -> Effect<U, E, Env> + Send + 'static,
U: Send + 'static,
{
self.and_then(move |value| {
let value_clone = value.clone();
f(&value).map(move |_| value_clone)
})
}
}
// Usage:
user_effect
.and_then_ref(|user| save_audit_log(user)) // Borrows user
// user is returned (cloned once, not multiple times)
6. Parallel Execution (Future Enhancement)
impl<T, E, Env> Effect<T, E, Env>
where
T: Send + 'static,
E: Send + 'static,
{
/// Run multiple effects in parallel
pub fn all<I>(effects: I) -> Effect<Vec<T>, E, Env>
where
I: IntoIterator<Item = Effect<T, E, Env>> + Send + 'static,
{
Effect {
run_fn: Box::new(move |env| {
Box::pin(async move {
let futures: Vec<_> = effects
.into_iter()
.map(|effect| (effect.run_fn)(env))
.collect();
// Run all futures concurrently
let results: Vec<Result<T, E>> = futures::future::join_all(futures).await;
// Collect all results, fail if any failed
results.into_iter().collect()
})
}),
}
}
}
// Usage:
let user_effects = user_ids.into_iter().map(|id| fetch_user(id));
Effect::all(user_effects) // Fetches all users concurrently!
IO Module for Async
pub struct IO;
impl IO {
/// Read from environment (immutable borrow)
pub fn read<T, R, F>(f: F) -> Effect<R, Infallible, T>
where
F: FnOnce(&T) -> R + Send + 'static,
R: Send + 'static,
T: Send + Sync + 'static,
{
Effect::from_fn(move |env: &T| Ok(f(env)))
}
/// Write to environment (mutable borrow) - requires RefCell or similar
pub fn write<T, R, F>(f: F) -> Effect<R, Infallible, T>
where
F: FnOnce(&T) -> R + Send + 'static,
R: Send + 'static,
T: Send + Sync + 'static,
{
Effect::from_fn(move |env: &T| Ok(f(env)))
}
/// Async I/O operation
pub fn read_async<T, R, F, Fut>(f: F) -> Effect<R, Infallible, T>
where
F: FnOnce(&T) -> Fut + Send + 'static,
Fut: Future<Output = R> + Send + 'static,
R: Send + 'static,
T: Send + Sync + 'static,
{
Effect::from_async(move |env: &T| async move { Ok(f(env).await) })
}
}
Usage Examples
Pure Sync Code
let effect = Effect::pure(42)
.map(|x| x * 2)
.map(|x| x + 10);
let result = effect.run(&()).await; // Must use .await, but wrapping is cheap
assert_eq!(result, Ok(94));
Async I/O
async fn fetch_user_async(id: UserId) -> Effect<User, AppError, AppEnv> {
Effect::from_async(|env: &AppEnv| async move {
env.db.query("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_one()
.await
.map_err(AppError::from)
})
}
Mixed Sync and Async
fn process_user(id: UserId) -> Effect<Invoice, AppError, AppEnv> {
fetch_user_async(id) // Async I/O
.and_then(|user| {
let discount = calculate_discount(&user); // Sync pure
Effect::pure(discount)
})
.and_then(|discount| {
save_discount_async(discount) // Async I/O
})
}
// All compose seamlessly!
Performance Considerations
Boxing Overhead:
- One allocation per Effect creation
- Negligible for I/O-bound operations
- Database queries: ~1ms
- Network calls: ~10-100ms
- Boxing: ~50ns
- Ratio: 0.005% overhead
Future Wrapping:
- Sync code wrapped in ready Future
- Optimizer often eliminates this
- Zero runtime cost in practice
Conclusion: Async-first design has negligible overhead for typical use cases.
Migration Path
Users can opt-in to sync-only if needed:
// If you really need blocking for some reason:
tokio::task::block_in_place(|| {
let rt = tokio::runtime::Handle::current();
rt.block_on(effect.run(&env))
})
But modern Rust apps should embrace async.
Decision Summary
✅ Use Option 4: Async-first unified Effect
Core Design:
- Effect is always async internally
- Sync code wraps in ready Future (cheap)
- Single unified API
- Natural async support
Helper Methods:
- ✅
.tap()- side effects - ✅
.check()- conditional failures - ✅
.with()- combine effects - ✅
.and_then_auto()- auto-convert errors - ✅
.and_then_ref()- avoid cloning - ✅
Effect::all()- parallel execution
IO API:
IO::read()- immutable accessIO::write()- mutable accessIO::read_async()- async operations
Async from the start is the right choice for modern Rust I/O libraries.
Improving Our Design & Planning Process
Historical design record. This predates the current concrete-combinator API and contains unimplemented or superseded proposals. It is not a 2.0 usage guide. Use the effects guide and API tiers for current behavior.
What We’ve Done Well ✅
1. Example-Driven Design
- ✅ Wrote examples before implementation
- ✅ Tested ergonomics with realistic code
- ✅ Identified pain points early
2. Comprehensive Analysis
- ✅ Compared alternatives (tuples vs HList, read vs query)
- ✅ Documented trade-offs explicitly
- ✅ Evaluated pain points systematically
3. Clear Philosophy
- ✅ “Pure core, imperative shell” is well-defined
- ✅ Design principles documented
- ✅ Anti-patterns identified (no heavy macros, etc.)
4. Thorough Documentation
- ✅ DESIGN.md captures API
- ✅ PHILOSOPHY.md explains “why”
- ✅ Examples show real usage
Critical Gaps 🚨
Gap 1: No Validation Through Code
Problem: All our examples are fictional - they don’t compile!
Impact:
- Assumptions might be wrong
- API might not work as expected
- Hidden complexity not discovered
Solution:
// Create a minimal proof-of-concept:
// stillwater/prototypes/validation_poc.rs
// Actually implement just Validation<T, E>
// Write REAL tests that COMPILE and RUN
// Discover what breaks, what's awkward
#[test]
fn test_validation_accumulation() {
let result = Validation::all((
validate_email("test@example.com"),
validate_age(25),
));
// Does this actually work?
// Is the syntax actually ergonomic?
// What error messages does the compiler give?
}
Action Items:
- Create
prototypes/directory - Implement minimal Validation type
- Write 10 real test cases
- Document surprises/learnings
Gap 2: No User Validation
Problem: We’re designing in a vacuum - no external feedback.
Impact:
- Solving wrong problems
- Missing critical use cases
- API might not resonate with real users
Solution:
A. Define User Personas
## Persona 1: Backend Developer (Primary)
- Building REST APIs with Axum/Actix
- Uses PostgreSQL/SQLx
- Pain: Testing business logic mixed with DB
- Wants: Testable code, clear error messages
## Persona 2: CLI Tool Author (Secondary)
- Building command-line tools
- Reads configs, processes files
- Pain: Error context is lost
- Wants: Great error messages, validation
## Persona 3: Data Engineer (Tertiary)
- ETL pipelines, CSV processing
- Needs bulk validation
- Pain: Want all errors, not first one
- Wants: Performance, parallelism
B. Create User Stories
As a backend developer,
I want to validate API inputs and get all errors,
So that users can fix their entire request at once.
As a CLI tool author,
I want clear error context showing what failed,
So that users can debug issues without my help.
As a data engineer,
I want to validate thousands of records in parallel,
So that pipelines complete faster.
C. Early User Interviews
- Share design docs on r/rust
- Get feedback from 5-10 Rust developers
- Ask: “Would you use this? Why/why not?”
- Document objections and address them
Gap 3: No Performance Validation
Problem: We assume async wrapping is cheap, but haven’t measured.
Impact:
- Performance might be worse than expected
- Might not be zero-cost in practice
- Could be a deal-breaker for some users
Solution:
Benchmark Critical Paths
// benchmarks/effect_overhead.rs
#[bench]
fn hand_written_sync(b: &mut Bencher) {
b.iter(|| {
let user = fetch_user_direct(42);
let validated = validate_user_direct(user);
save_user_direct(validated)
});
}
#[bench]
fn stillwater_sync(b: &mut Bencher) {
b.iter(|| {
Effect::from_fn(|_| fetch_user(42))
.and_then(|user| validate_user(user))
.and_then(|user| save_user(user))
.run(&())
});
}
// Measure:
// - Boxing overhead
// - Future wrapping cost
// - Comparison to hand-written
// - Memory allocations
Acceptance Criteria:
- Effect overhead < 5% vs hand-written
- Memory allocations reasonable
- Document in README if overhead exists
Action Items:
- Create benchmark suite
- Run on realistic workloads
- Profile with
cargo flamegraph - Document results in PERFORMANCE.md
Gap 4: No Competitive Analysis
Problem: Haven’t deeply compared to alternatives.
Impact:
- Missing features others have
- Repeating mistakes
- Can’t articulate our advantages
Solution:
Deep Dive Comparison
## vs. anyhow/eyre (Error Handling)
| Feature | anyhow | stillwater |
|---------|--------|------------|
| Error context | ✅ Yes | ✅ Yes |
| Validation accumulation | ❌ No | ✅ Yes |
| Effect composition | ❌ No | ✅ Yes |
| Pure/effect separation | ❌ No | ✅ Yes |
**When to use anyhow:** Simple apps, don't need validation
**When to use stillwater:** Need validation, testability, effect composition
## vs. frunk (Validation)
| Feature | frunk | stillwater |
|---------|-------|------------|
| Validation | ✅ Yes | ✅ Yes |
| HList | ✅ Yes | ❌ No (not needed) |
| Effect composition | ❌ No | ✅ Yes |
| Documentation | ⚠️ Sparse | ✅ Comprehensive |
| Learning curve | ⚠️ Steep | ✅ Gentle |
**When to use frunk:** Type-level programming, Generic derives
**When to use stillwater:** Practical validation, clear APIs
## vs. Hand-rolling
| Aspect | Hand-rolled | stillwater |
|--------|-------------|------------|
| Boilerplate | ❌ High | ✅ Low |
| Consistency | ⚠️ Varies | ✅ Enforced |
| Testing | ⚠️ Manual | ✅ Patterns built-in |
| Onboarding | ⚠️ Team-specific | ✅ Documented |
**When to hand-roll:** Very simple apps, unique requirements
**When to use stillwater:** Team projects, maintainability matters
Action Items:
- Try building same feature with alternatives
- Measure LOC, compile time, ergonomics
- Document in COMPARISON.md
- Use in marketing/README
Gap 5: Missing Implementation Experiments
Problem: Designing without building reveals hidden complexity late.
Impact:
- Lifetime issues we haven’t anticipated
- Trait bounds that don’t work
- Type inference failures
Solution:
Spike/Prototype Critical Parts
// prototypes/effect_lifetimes.rs
// Experiment: Can we avoid boxing?
struct EffectNoBox<T, E, Env, F>
where
F: FnOnce(&Env) -> BoxFuture<'_, Result<T, E>>,
{
run_fn: F,
}
// Try implementing and_then without boxing
// See what breaks, what lifetime errors occur
// Document findings
// Results:
// - [ ] Boxing necessary? Why/why not?
// - [ ] Can we use impl Trait instead?
// - [ ] What's the actual cost?
Experiments to Run:
- Effect without boxing (is it possible?)
- Validation with Iterator instead of tuples
- Context without String allocation
- Try trait integration (can we make ? work?)
- Environment extraction (trait vs direct access)
Gap 6: No Migration/Adoption Story
Problem: How does someone actually start using this?
Impact:
- Adoption friction
- Unclear path from current code
- All-or-nothing approach
Solution:
Progressive Adoption Guide
## Migration Path
### Stage 1: Validation Only (Week 1)
Start with just validation in new API endpoints:
// Before fn create_user(input: UserInput) -> Result<User, Error> { if !validate_email(&input.email) { return Err(Error::InvalidEmail); } // … continue with first-error-only }
// After (just add validation)
fn create_user(input: UserInput) -> Result<User, Vec
**Benefits:** Immediate value, low risk, no refactoring needed
### Stage 2: Effect Separation (Week 2-3)
Extract pure business logic in critical paths:
// Pure functions (new) fn calculate_discount(customer: &Customer) -> Money { … } fn apply_discount(order: Order, discount: Money) -> Order { … }
// Keep existing I/O code (not refactored yet) async fn process_order(id: OrderId) -> Result<Invoice, Error> { let order = db.fetch_order(id).await?; let discount = calculate_discount(&order.customer); // Pure! let final_order = apply_discount(order, discount); // Pure! db.save_invoice(final_order).await }
**Benefits:** Better testability immediately, incremental change
### Stage 3: Full Effects (Month 2+)
Gradually wrap I/O in Effects for new features:
fn process_order_v2(id: OrderId) -> Effect<Invoice, Error, AppEnv> { // Full stillwater style }
**Benefits:** New code uses best practices, old code still works
Action Items:
- Write migration guide
- Create “starter” templates
- Document integration with popular frameworks
- Show how to use with existing codebases
Gap 7: No Clear Success Metrics
Problem: “100+ stars” is vague. How do we know we succeeded?
Impact:
- Can’t measure progress
- Don’t know when to pivot
- Unclear what “good” looks like
Solution:
Define Concrete Metrics
Technical Metrics:
- Compiles with zero warnings
- 100% documented (rustdoc)
- <5% overhead vs hand-written (benchmark)
- <2s additional compile time for simple project
- All examples compile and run
Adoption Metrics (6 months):
- 3+ production users (verified via contact)
- 10+ GitHub issues filed (engagement)
- 100+ downloads/week on crates.io
- Featured in “This Week in Rust” or similar
Quality Metrics:
- Positive HN/Reddit feedback (>70% upvote)
- 0 critical bugs reported
- <24hr response time to issues
- 5+ external contributors
Educational Metrics:
- Blog post written about it
- Conference talk accepted
- 3+ community examples/tutorials
Leading Indicators (Month 1):
- 5 people try it and give feedback
- 2 people say “I’d use this”
- 0 people say “This solves nothing”
Gap 8: Documentation Organization
Problem: Design docs scattered across many files.
Impact:
- Hard to find information
- Redundancy/conflicts
- No clear entry point
Solution:
Documentation Structure
stillwater/
├── README.md # Quick intro, examples
├── docs/
│ ├── guide/
│ │ ├── 01-getting-started.md
│ │ ├── 02-validation.md
│ │ ├── 03-effects.md
│ │ ├── 04-testing.md
│ │ └── 05-async.md
│ ├── design/
│ │ ├── philosophy.md # Why we made these choices
│ │ ├── architecture.md # How it works
│ │ ├── decisions/
│ │ │ ├── 001-tuples-for-validation.md
│ │ │ ├── 002-read-write-not-query-execute.md
│ │ │ ├── 003-async-first.md
│ │ │ └── template.md
│ │ └── alternatives.md # vs frunk, anyhow, etc.
│ ├── examples/
│ │ ├── web-api-validation.md
│ │ ├── cli-tool-errors.md
│ │ ├── data-pipeline.md
│ │ └── testing-patterns.md
│ └── contributing/
│ ├── development.md
│ ├── testing.md
│ └── releasing.md
├── examples/ # Runnable code
├── prototypes/ # Experiments
└── benchmarks/ # Performance tests
Action Items:
- Reorganize current docs into structure
- Create templates for decision records
- Add navigation/ToC to each doc
- Cross-reference related docs
Gap 9: No “Why Not” Section
Problem: Don’t address objections head-on.
Impact:
- Users have unanswered concerns
- Seems like we’re hiding weaknesses
- Can’t learn from critics
Solution:
Address Objections Explicitly
## Why NOT Use Stillwater?
### "I don't need validation accumulation"
**Then use:** anyhow/eyre for simple error handling
**Stillwater adds:** Unnecessary complexity if you don't validate forms/data
### "This adds too much abstraction"
**Valid concern:** Yes, it's more abstract than hand-written code
**Trade-off:** Abstraction buys you testability and consistency
**Decision:** If your team values simplicity > testability, skip this
### "Async-first means I can't use it in sync code"
**Clarification:** You CAN use it in sync code (wraps in ready Future)
**But:** You do need an async runtime (tokio)
**Alternative:** If you're building pure sync CLI, the overhead might not be worth it
### "I don't like the philosophy"
**That's fine:** If "pure core, imperative shell" doesn't resonate, this isn't for you
**Alternative:** Many roads to good code - this is one path
### "The API is too verbose"
**Valid in some cases:** `Effect<T, ContextError<E>, Env>` is long
**Mitigation:** Type aliases reduce this: `type AppEffect<T> = ...`
**Decision:** We chose explicit over magic
Action Items:
- List all objections we can think of
- Get feedback from critics
- Address honestly in FAQ
- Don’t be defensive - acknowledge trade-offs
Gap 10: No Failure Scenarios Considered
Problem: Only designed for success case.
Impact:
- What if compilation is slow?
- What if error messages are cryptic?
- What if adoption is zero?
Solution:
Plan for Failure
Scenario 1: Compile Times Are Terrible
- Detection: >10s for small project
- Response: Profile with
-Z self-profile, identify hot spots - Mitigation: Reduce generic instantiations, use trait objects
- Pivot: If unfixable, document clearly and target specific use cases
Scenario 2: Error Messages Are Cryptic
- Detection: User feedback: “I don’t understand this error”
- Response: Collect examples of bad errors
- Mitigation: Add trait bounds diagnostics, custom error messages
- Pivot: Simplify type system if needed
Scenario 3: No Adoption After 6 Months
- Detection: <10 downloads/week, no GitHub activity
- Response: User interviews - why didn’t it resonate?
- Pivot Options:
- Simplify to just validation (drop effects)
- Target specific niche (e.g., just data pipelines)
- Merge into existing library
- Archive project and document learnings
Scenario 4: Competing Library Emerges
- Detection: New library with similar goals gets traction
- Response: Compare features, identify gaps
- Options:
- Collaborate/merge
- Differentiate clearly
- Concede if theirs is better
Immediate Action Plan
This Week
1. Build Minimal Prototype
- Implement just Validation<T, E> (200 LOC)
- Write 10 real test cases
- Document surprises
2. Get External Feedback
- Share design docs on r/rust
- Ask 3 Rust developers to review
- Collect objections
3. Benchmark Assumptions
- Measure boxing overhead
- Compare to hand-written code
- Document results
Next Week
4. Competitive Analysis
- Build same feature with frunk
- Build same feature with anyhow
- Compare LOC, ergonomics
5. Define Success Metrics
- Technical goals (compile time, overhead)
- Adoption goals (users, downloads)
- Quality goals (bugs, response time)
6. Reorganize Documentation
- Create docs/ structure
- Move existing docs
- Add navigation
Month 1
7. Implement Core MVP
- Validation type (complete)
- Effect type (basic)
- Context errors
- IO helpers
8. Write Real Examples
- Convert fictional examples to real
- All examples compile and run
- Add to CI
9. Gather User Feedback
- 5 developers try it
- Collect feedback
- Iterate on API
Process Improvements
Add to Workflow
Before Any Design Decision:
- ✅ Write example code showing usage
- ✅ Compare 2-3 alternatives
- ✅ Document trade-offs
- ➕ Prototype if unclear (NEW)
- ➕ Benchmark if performance-sensitive (NEW)
Before Finalizing API:
- ✅ Examples compile and run
- ➕ Get feedback from 3+ external developers (NEW)
- ➕ Ensure migration path exists (NEW)
Before Calling It “Done”:
- ✅ All tests pass
- ✅ Documentation complete
- ➕ Success metrics defined and measured (NEW)
- ➕ Performance validated (NEW)
- ➕ “Why not” section written (NEW)
Key Insight
We’ve been designing in a vacuum.
Good:
- ✅ Thorough analysis
- ✅ Clear philosophy
- ✅ Example-driven
Missing:
- ❌ No real code validation
- ❌ No user feedback
- ❌ No performance data
- ❌ No competitive validation
- ❌ No clear success criteria
Fix: Build small, validate often, talk to users.
Recommended Next Steps
Priority 1: Validate Core Assumptions
- Build minimal Validation prototype
- Write real tests that compile
- Measure performance
- Get 3 people to try it
Priority 2: External Validation
- Share on r/rust
- User interviews
- Competitive analysis
- Document objections
Priority 3: Organize for Success
- Define clear metrics
- Reorganize documentation
- Create migration guide
- Plan for failure scenarios
Great design emerges from iteration with reality, not just thought experiments.