Stillwater Documentation
Stillwater is a Rust library for pragmatic functional programming focused on validation and effect composition.
Start Here
- 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
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
All examples in this guide are runnable. You can find them in the examples/ directory:
cargo run --example validation
cargo run --example effects
cargo run --example monoid
cargo run --example form_validation
cargo run --example recover_patterns
cargo run --example retry_patterns
cargo run --example resource_tracking
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 |
| Batch processing | traverse_effect | Process collection with effects |
| 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
#![allow(unused)]
fn main() {
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
Effect::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
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:
#![allow(unused)]
fn main() {
pub trait Semigroup: Sized {
fn combine(self, other: Self) -> Self;
}
}
The key property is associativity: the order of combining doesn’t matter.
#![allow(unused)]
fn main() {
// 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:
#![allow(unused)]
fn main() {
// 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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
}
}
#[test]
fn test_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);
}
}
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:
#![allow(unused)]
fn main() {
fn validate_form(data: FormData) -> Result<ValidForm, Error> {
let email = validate_email(data.email)?; // ❌ Stops here if invalid
let password = validate_password(data.pwd)?; // Never reached
let age = validate_age(data.age)?; // Never reached
Ok(ValidForm { email, password, age })
}
}
If the email is invalid, the user doesn’t learn about password or age errors. They have to submit the form multiple times, fixing one error at a time. Frustrating!
The Solution: Validation
Stillwater’s Validation type accumulates ALL errors:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
fn validate_form(data: FormData) -> Validation<ValidForm, Vec<Error>> {
Validation::all((
validate_email(data.email),
validate_password(data.pwd),
validate_age(data.age),
))
.map(|(email, password, age)| ValidForm { email, password, age })
}
}
Now all three validations run, and the user sees all errors at once!
Core API
Creating Validations
#![allow(unused)]
fn main() {
use stillwater::Validation;
// Success
let v = Validation::success(42);
// Failure
let v = Validation::failure(vec!["error"]);
// From Result
let v = Validation::from_result(Ok(42));
let v = Validation::from_result(Err("error"));
}
Pattern Matching
#![allow(unused)]
fn main() {
use stillwater::Validation;
match validation {
Validation::Success(value) => println!("Valid: {}", value),
Validation::Failure(errors) => println!("Errors: {:?}", errors),
}
}
Checking Status
#![allow(unused)]
fn main() {
use stillwater::Validation;
let v = Validation::success(42);
assert!(v.is_success());
assert!(!v.is_failure());
let v = Validation::failure(vec!["error"]);
assert!(!v.is_success());
assert!(v.is_failure());
}
Combining Validations
#![allow(unused)]
fn main() {
use stillwater::Validation;
// Combine with tuples (up to 12 items)
let result = Validation::all((
validate_email(email),
validate_password(password),
validate_age(age),
));
// Combine Vec of same type
let result = Validation::all_vec(vec![
validate_item(item1),
validate_item(item2),
validate_item(item3),
]);
}
Transforming Validations
#![allow(unused)]
fn main() {
use stillwater::Validation;
// Transform success value
let v = Validation::success(21);
let doubled = v.map(|x| x * 2);
assert_eq!(doubled, Validation::success(42));
// Transform error value
let v = Validation::failure(vec!["oops"]);
let formatted = v.map_err(|e| format!("Error: {:?}", e));
// Chain dependent validation
let result = validate_email(email)
.and_then(|email| check_email_available(email));
}
Validation Combinators
Stillwater provides declarative validation combinators that eliminate verbose and_then boilerplate:
Using ensure() with Predicates
The ensure() method validates a success value using composable predicates from the predicate module:
#![allow(unused)]
fn main() {
use stillwater::{Validation, predicate::*};
// Single validation
let result = Validation::success(String::from("hello"))
.ensure(len_min(3), "too short");
assert_eq!(result, Validation::Success(String::from("hello")));
let result = Validation::success(String::from("hi"))
.ensure(len_min(3), "too short");
assert_eq!(result, Validation::Failure("too short"));
// Chain multiple validations
let result = Validation::success(String::from("hello"))
.ensure(len_min(3), "too short")
.ensure(len_max(10), "too long")
.ensure(is_alphabetic(), "must be alphabetic");
// Result: Success("hello")
let result = Validation::success(String::from("hello123"))
.ensure(len_min(3), "too short") // passes
.ensure(len_max(10), "too long") // passes
.ensure(is_alphabetic(), "not alpha"); // fails
// Result: Failure("not alpha")
}
Using ensure_fn() with Closures
For inline predicates, use ensure_fn():
#![allow(unused)]
fn main() {
use stillwater::Validation;
let result = Validation::success(5)
.ensure_fn(|x| *x > 0, "must be positive");
assert_eq!(result, Validation::Success(5));
let result = Validation::success(-5)
.ensure_fn(|x| *x > 0, "must be positive");
assert_eq!(result, Validation::Failure("must be positive"));
}
Using ensure_with() for Lazy Errors
When you need the value to construct the error message:
#![allow(unused)]
fn main() {
use stillwater::{Validation, predicate::*};
let result = Validation::success(String::from("hi"))
.ensure_with(len_min(3), |s| format!("'{}' is too short", s));
assert_eq!(result, Validation::Failure("'hi' is too short".to_string()));
}
Using ensure_fn_with() with Closures and Lazy Errors
Combine closure predicates with error factories:
#![allow(unused)]
fn main() {
use stillwater::Validation;
let result = Validation::success(-5)
.ensure_fn_with(
|x| *x > 0,
|x| format!("{} is not positive", x)
);
assert_eq!(result, Validation::Failure("-5 is not positive".to_string()));
}
Using unless() for Inverse Validation
The unless() method fails when the predicate is TRUE (inverse of ensure_fn):
#![allow(unused)]
fn main() {
use stillwater::Validation;
// Fail if negative
let result = Validation::success(5)
.unless(|x| *x < 0, "must not be negative");
assert_eq!(result, Validation::Success(5));
let result = Validation::success(-5)
.unless(|x| *x < 0, "must not be negative");
assert_eq!(result, Validation::Failure("must not be negative"));
}
Using filter_or() Alias
filter_or() is an alias for ensure_fn() following functional programming conventions:
#![allow(unused)]
fn main() {
use stillwater::Validation;
let result = Validation::success(5)
.filter_or(|x| *x > 0, "must be positive");
assert_eq!(result, Validation::Success(5));
}
Why Use Validation Combinators?
Before (verbose):
#![allow(unused)]
fn main() {
validate_email(email)
.and_then(|email| {
if email.len() <= 100 {
Validation::success(email)
} else {
Validation::failure(vec!["email too long"])
}
})
.and_then(|email| {
if !email.starts_with("admin") {
Validation::success(email)
} else {
Validation::failure(vec!["reserved prefix"])
}
})
}
After (declarative):
#![allow(unused)]
fn main() {
use stillwater::predicate::*;
validate_email(email)
.ensure(len_max(100), vec!["email too long"])
.ensure_fn(|e| !e.starts_with("admin"), vec!["reserved prefix"])
}
Converting to Result
#![allow(unused)]
fn main() {
use stillwater::Validation;
let v = Validation::success(42);
let r: Result<i32, Vec<String>> = v.into_result();
assert_eq!(r, Ok(42));
let v = Validation::failure(vec!["error"]);
let r: Result<i32, Vec<String>> = v.into_result();
assert_eq!(r, Err(vec!["error"]));
}
Error Accumulation with Semigroup
For Validation::all() to work, your error type must implement Semigroup:
#![allow(unused)]
fn main() {
pub trait Semigroup {
fn combine(self, other: Self) -> Self;
}
}
Common implementations:
Vec<T>: Concatenate vectorsString: Concatenate strings(A, B) where A: Semigroup, B: Semigroup: Combine components
Example:
#![allow(unused)]
fn main() {
use stillwater::Semigroup;
impl Semigroup for Vec<ValidationError> {
fn combine(mut self, mut other: Self) -> Self {
self.extend(other);
self
}
}
}
See Semigroup guide for details.
Real-World Example
#![allow(unused)]
fn main() {
use stillwater::{Validation, Semigroup};
#[derive(Debug, PartialEq)]
enum ValidationError {
InvalidEmail(String),
PasswordTooShort { min: usize, actual: usize },
AgeTooYoung { min: u8, actual: u8 },
}
fn validate_email(email: &str) -> Validation<String, Vec<ValidationError>> {
if email.contains('@') && email.contains('.') {
Validation::success(email.to_string())
} else {
Validation::failure(vec![
ValidationError::InvalidEmail(email.to_string())
])
}
}
fn validate_password(pwd: &str) -> Validation<String, Vec<ValidationError>> {
const MIN_LEN: usize = 8;
if pwd.len() >= MIN_LEN {
Validation::success(pwd.to_string())
} else {
Validation::failure(vec![
ValidationError::PasswordTooShort {
min: MIN_LEN,
actual: pwd.len(),
}
])
}
}
fn validate_age(age: u8) -> Validation<u8, Vec<ValidationError>> {
const MIN_AGE: u8 = 18;
if age >= MIN_AGE {
Validation::success(age)
} else {
Validation::failure(vec![
ValidationError::AgeTooYoung {
min: MIN_AGE,
actual: age,
}
])
}
}
#[derive(Debug)]
struct User {
email: String,
password: String,
age: u8,
}
fn validate_registration(
email: &str,
password: &str,
age: u8,
) -> Validation<User, Vec<ValidationError>> {
Validation::all((
validate_email(email),
validate_password(password),
validate_age(age),
))
.map(|(email, password, age)| User { email, password, age })
}
// Usage
match validate_registration("invalid", "short", 15) {
Validation::Success(user) => println!("✓ Registered: {:?}", user),
Validation::Failure(errors) => {
println!("✗ {} errors:", errors.len());
for err in errors {
println!(" - {:?}", err);
}
}
}
// Output:
// ✗ 3 errors:
// - InvalidEmail("invalid")
// - PasswordTooShort { min: 8, actual: 5 }
// - AgeTooYoung { min: 18, actual: 15 }
}
Batch User Registration with Traverse
Extending the above example to validate multiple user registrations:
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
#[derive(Debug)]
struct RegistrationData {
email: String,
password: String,
age: u8,
}
fn validate_batch_registrations(
registrations: Vec<RegistrationData>
) -> Validation<Vec<User>, Vec<ValidationError>> {
traverse(registrations, |data| {
validate_registration(&data.email, &data.password, data.age)
})
}
// Usage
let batch = vec![
RegistrationData {
email: "alice@example.com".to_string(),
password: "securepass123".to_string(),
age: 25,
},
RegistrationData {
email: "invalid".to_string(),
password: "short".to_string(),
age: 15,
},
RegistrationData {
email: "bob@example.com".to_string(),
password: "goodpassword".to_string(),
age: 30,
},
];
match validate_batch_registrations(batch) {
Validation::Success(users) => {
println!("✓ {} users registered successfully", users.len());
}
Validation::Failure(errors) => {
println!("✗ Registration failed with {} errors:", errors.len());
for err in errors {
println!(" - {:?}", err);
}
// Output:
// ✗ Registration failed with 3 errors:
// - InvalidEmail("invalid")
// - PasswordTooShort { min: 8, actual: 5 }
// - AgeTooYoung { min: 18, actual: 15 }
}
}
}
This demonstrates how traverse makes it easy to validate collections while accumulating all errors across all items.
When to Use Validation
Use Validation when:
- Validating user input (forms, APIs)
- You want to report ALL errors at once
- Validations are independent (order doesn’t matter)
Use Result when:
- Operations depend on previous results
- Short-circuit is desired (fail fast)
- Single error is sufficient
Patterns
Independent Field Validation
#![allow(unused)]
fn main() {
use stillwater::Validation;
// All fields validated independently
Validation::all((
validate_email(data.email),
validate_phone(data.phone),
validate_address(data.address),
))
}
Dependent Validation
#![allow(unused)]
fn main() {
use stillwater::Validation;
// First validate, then check dependencies
validate_email(email)
.and_then(|email| {
check_email_not_taken(email)
})
}
Mixed Validation
#![allow(unused)]
fn main() {
use stillwater::Validation;
// Combine independent and dependent
Validation::all((
validate_email(email),
validate_password(password),
))
.and_then(|(email, password)| {
// Now check if combination is valid
check_credentials_not_weak(email, password)
})
}
Validating Collections
When validating collections, use traverse for cleaner, more efficient code:
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
fn validate_all_items(items: Vec<Item>) -> Validation<Vec<ValidItem>, Vec<Error>> {
traverse(items, validate_item)
}
}
The traverse function applies a validation to each element and accumulates all errors:
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
fn validate_positive(x: i32) -> Validation<i32, Vec<String>> {
if x > 0 {
Validation::success(x)
} else {
Validation::failure(vec![format!("{} is not positive", x)])
}
}
// All valid
let result = traverse(vec![1, 2, 3], validate_positive);
assert_eq!(result, Validation::Success(vec![1, 2, 3]));
// Multiple errors accumulated
let result = traverse(vec![1, -2, -3], validate_positive);
match result {
Validation::Failure(errors) => {
assert_eq!(errors.len(), 2); // Both negative numbers
}
_ => panic!("Expected failure"),
}
}
For more advanced patterns, see the Traverse Patterns guide.
Alternative using all_vec (less convenient):
#![allow(unused)]
fn main() {
use stillwater::Validation;
fn validate_all_items(items: Vec<Item>) -> Validation<Vec<ValidItem>, Vec<Error>> {
let validations: Vec<_> = items
.into_iter()
.map(|item| validate_item(item))
.collect();
Validation::all_vec(validations)
}
}
Building Complex Types
#![allow(unused)]
fn main() {
use stillwater::Validation;
struct Config {
host: String,
port: u16,
timeout: u64,
}
fn validate_config(input: ConfigInput) -> Validation<Config, Vec<Error>> {
Validation::all((
validate_host(&input.host),
validate_port(input.port),
validate_timeout(input.timeout),
))
.map(|(host, port, timeout)| Config { host, port, timeout })
}
}
Testing
Validation is pure - testing is trivial:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_email() {
let result = validate_email("user@example.com");
assert!(result.is_success());
}
#[test]
fn test_invalid_email() {
let result = validate_email("invalid");
assert!(result.is_failure());
}
#[test]
fn test_accumulation() {
let result = validate_registration("bad", "short", 15);
match result {
Validation::Failure(errors) => {
assert_eq!(errors.len(), 3);
}
_ => panic!("Expected failure"),
}
}
#[test]
fn test_partial_failure() {
// Valid email, invalid password and age
let result = validate_registration("user@example.com", "short", 15);
match result {
Validation::Failure(errors) => {
assert_eq!(errors.len(), 2);
}
_ => panic!("Expected failure"),
}
}
}
}
Advanced: Custom Error Types
You can use any error type that implements Semigroup:
#![allow(unused)]
fn main() {
use stillwater::{Validation, Semigroup};
#[derive(Debug, Clone)]
struct ValidationContext {
field: String,
message: String,
}
#[derive(Debug, Clone)]
struct ValidationErrors {
errors: Vec<ValidationContext>,
}
impl Semigroup for ValidationErrors {
fn combine(mut self, other: Self) -> Self {
self.errors.extend(other.errors);
self
}
}
fn validate_with_context(
field: &str,
value: &str,
) -> Validation<String, ValidationErrors> {
if value.is_empty() {
Validation::failure(ValidationErrors {
errors: vec![ValidationContext {
field: field.to_string(),
message: "Field is required".to_string(),
}],
})
} else {
Validation::success(value.to_string())
}
}
}
Performance Considerations
Validation has minimal overhead:
- Success case: just wraps a value (zero-cost)
- Failure case: creates error collection
- Combining: uses efficient vector extension
The main cost is creating error objects, which you’d do anyway.
Common Pitfalls
Don’t use ? for accumulation
#![allow(unused)]
fn main() {
// ❌ Wrong: short-circuits on first error
fn validate(data: Data) -> Validation<Valid, Vec<Error>> {
let email = validate_email(data.email)?; // Stops here!
let age = validate_age(data.age)?;
// ...
}
// ✓ Right: accumulates errors
fn validate(data: Data) -> Validation<Valid, Vec<Error>> {
Validation::all((
validate_email(data.email),
validate_age(data.age),
))
}
}
Remember to map after all()
#![allow(unused)]
fn main() {
// ❌ Wrong: returns tuple instead of User
fn validate(email: &str, age: u8) -> Validation<(String, u8), Vec<Error>> {
Validation::all((
validate_email(email),
validate_age(age),
))
}
// ✓ Right: map tuple to User
fn validate(email: &str, age: u8) -> Validation<User, Vec<Error>> {
Validation::all((
validate_email(email),
validate_age(age),
))
.map(|(email, age)| User { email, age })
}
}
Summary
- Validation accumulates all errors instead of short-circuiting
- Use Validation::all() for independent validations
- Use and_then() for dependent validations
- Error types must implement Semigroup
- Testing is easy because validation is pure
Next Steps
- Learn about Effect composition
- See full example
- Read the API docs
Effect Composition: Pure Core, Imperative Shell
The Philosophy
Effect helps you structure applications with:
- Pure core: Business logic with no side effects (easy to test)
- Imperative shell: I/O operations at the boundaries (controlled)
This separation makes code more testable, maintainable, and composable.
Zero-Cost by Default
Stillwater’s Effect system follows the futures crate pattern: zero-cost by default, explicit boxing when needed.
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
// Zero heap allocations - compiler can inline everything
let effect = pure::<_, String, ()>(42)
.map(|x| x + 1) // Returns Map<Pure<...>, ...>
.and_then(|x| pure(x * 2)) // Returns AndThen<Map<...>, ...>
.map(|x| x.to_string()); // Returns Map<AndThen<...>, ...>
// Type: Map<AndThen<Map<Pure<i32, String, ()>, ...>, ...>, ...>
// NO heap allocation!
}
Each combinator returns a concrete type. The compiler knows the exact type at compile time and can fully optimize the effect chain.
The Problem
How do you test this code?
#![allow(unused)]
fn main() {
async fn create_user(email: String, age: u8) -> Result<User, Error> {
// Validation mixed with I/O
if !email.contains('@') {
return Err(Error::InvalidEmail);
}
// Database call (requires real/mock DB)
let existing = database.find_by_email(&email).await?;
if existing.is_some() {
return Err(Error::EmailExists);
}
// More I/O
let user = User { email, age };
database.save(&user).await?;
Ok(user)
}
}
Problems:
- Can’t test without database
- Business logic mixed with I/O
- Hard to reason about what’s pure vs effectful
The Solution: Effect
Effect separates pure logic from I/O:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
#[derive(Clone)]
struct AppEnv {
db: Database,
}
fn create_user(email: String, age: u8) -> impl Effect<Output = User, Error = AppError, Env = AppEnv> {
// Pure validation first
from_validation(validate_user(&email, age).map_err(AppError::Validation))
// Then I/O
.and_then(move |_| {
from_fn(move |env: &AppEnv| env.db.find_by_email(&email))
})
// Pure logic
.and_then(move |existing| {
if existing.is_some() {
fail(AppError::EmailExists)
} else {
pure(User { email, age })
}
})
// More I/O
.and_then(|user| {
from_fn(move |env: &AppEnv| env.db.save(&user))
.map(move |_| user)
})
}
// Run at application boundary
let env = AppEnv { db };
let user = create_user(email, age).run(&env).await?;
}
Benefits:
- Pure functions need no mocks
- I/O is explicit via
from_fn,from_async - Easy to test with mock environments
- Zero heap allocations in the effect chain
Core API
Creating Effects
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
// Pure value (no I/O)
let effect = pure::<_, String, ()>(42);
// Failed effect
let effect = fail::<i32, _, ()>("error".to_string());
// From Result
let effect = from_result::<_, String, ()>(Ok(42));
// From Validation
let validation = Validation::success(42);
let effect = from_validation(validation);
// From sync function
let effect = from_fn(|env: &Env| {
Ok::<_, String>(env.config.value)
});
// From async function
let effect = from_async(|env: &Env| async {
env.db.fetch_user(123).await
});
// From Option
let effect = from_option::<_, _, ()>(Some(42), || "value missing");
}
Transforming Effects
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
// Map success value
let effect = pure::<_, String, ()>(21).map(|x| x * 2);
let result = effect.run(&()).await; // Ok(42)
// Map error value
let effect = fail::<i32, _, ()>("oops").map_err(|e| format!("Error: {}", e));
// Chain dependent effects
let effect = pure::<_, String, ()>(5)
.and_then(|x| pure(x * 2))
.and_then(|x| pure(x + 10));
let result = effect.run(&()).await; // Ok(20)
}
Validation Combinators
Stillwater provides declarative validation combinators that eliminate verbose and_then boilerplate when validating effect outputs:
Using ensure() with Closures
The ensure() method validates an effect’s success value and fails if the predicate returns false:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
#[derive(Debug, PartialEq)]
enum Error {
Negative,
TooLarge,
}
// Before: verbose and_then pattern
let effect = pure::<_, Error, ()>(5)
.and_then(|x| {
if x > 0 {
pure(x)
} else {
fail(Error::Negative)
}
});
// After: declarative ensure
let effect = pure::<_, Error, ()>(5)
.ensure(|x| *x > 0, Error::Negative);
let result = effect.run(&()).await;
assert_eq!(result, Ok(5));
}
Using ensure_with() for Lazy Errors
When you need the value to construct the error:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
#[derive(Debug, PartialEq)]
struct RangeError {
value: i32,
min: i32,
}
let effect = pure::<_, RangeError, ()>(-5)
.ensure_with(
|x| *x >= 0,
|x| RangeError { value: *x, min: 0 }
);
let result = effect.run(&()).await;
assert_eq!(result, Err(RangeError { value: -5, min: 0 }));
}
Using ensure_pred() with Composable Predicates
For reusable validation logic, use predicates from the predicate module:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
use stillwater::predicate::*;
#[derive(Debug, PartialEq)]
enum Error {
InvalidAge,
}
let valid_age = between(18, 120);
let effect = pure::<_, Error, ()>(25)
.ensure_pred(valid_age, Error::InvalidAge);
let result = effect.run(&()).await;
assert_eq!(result, Ok(25));
// Fails for invalid ages
let effect = pure::<_, Error, ()>(15)
.ensure_pred(valid_age, Error::InvalidAge);
let result = effect.run(&()).await;
assert_eq!(result, Err(Error::InvalidAge));
}
Using unless() for Inverse Validation
The unless() method fails when the predicate is TRUE (inverse of ensure):
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
#[derive(Debug, PartialEq)]
enum Error {
UserBanned,
}
struct User {
id: u32,
is_banned: bool,
}
let effect = from_fn(|_: &()| User { id: 1, is_banned: false })
.unless(|u| u.is_banned, Error::UserBanned);
let result = effect.run(&()).await;
assert!(result.is_ok());
// Fails when user is banned
let effect = from_fn(|_: &()| User { id: 2, is_banned: true })
.unless(|u| u.is_banned, Error::UserBanned);
let result = effect.run(&()).await;
assert_eq!(result, Err(Error::UserBanned));
}
Using filter_or() Alias
filter_or() is an alias for ensure() following functional programming conventions:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
let effect = pure::<_, &str, ()>(5)
.filter_or(|x| *x > 0, "must be positive");
let result = effect.run(&()).await;
assert_eq!(result, Ok(5));
}
Chaining Multiple Validations
Combine multiple validation checks for comprehensive validation:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
use stillwater::predicate::*;
#[derive(Debug, PartialEq)]
enum Error {
TooShort,
TooLong,
NotAlpha,
}
let effect = pure::<_, Error, ()>(String::from("hello"))
.ensure_pred(len_min(3), Error::TooShort)
.ensure_pred(len_max(10), Error::TooLong)
.ensure_pred(is_alphabetic(), Error::NotAlpha);
let result = effect.run(&()).await;
assert_eq!(result, Ok(String::from("hello")));
// Fails at first violation (fail-fast)
let effect = pure::<_, Error, ()>(String::from("hi"))
.ensure_pred(len_min(3), Error::TooShort) // fails here
.ensure_pred(len_max(10), Error::TooLong)
.ensure_pred(is_alphabetic(), Error::NotAlpha);
let result = effect.run(&()).await;
assert_eq!(result, Err(Error::TooShort));
}
Real-World Example
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
#[derive(Clone)]
struct Database;
impl Database {
async fn fetch_user(&self, id: u32) -> Result<User, DbError> {
// ... database logic
}
}
#[derive(Clone)]
struct AppEnv {
db: Database,
}
#[derive(Debug)]
enum AppError {
Db(DbError),
UserBanned,
Underage,
}
struct User {
id: u32,
age: u8,
is_banned: bool,
}
fn fetch_valid_user(id: u32) -> impl Effect<Output = User, Error = AppError, Env = AppEnv> {
from_fn(move |env: &AppEnv| env.db.fetch_user(id))
.map_err(AppError::Db)
.unless(|u| u.is_banned, AppError::UserBanned)
.ensure(|u| u.age >= 18, AppError::Underage)
}
// Usage
let env = AppEnv { db: Database };
let user = fetch_valid_user(123).run(&env).await?;
}
Why Use Effect Validation Combinators?
Before (12 lines):
#![allow(unused)]
fn main() {
from_fn(|env: &AppEnv| fetch_data(env))
.and_then(|data| {
if data.value > 0 {
pure(data)
} else {
fail(Error::InvalidValue)
}
})
.and_then(|data| {
if data.count < 100 {
pure(data)
} else {
fail(Error::TooMany)
}
})
}
After (3 lines):
#![allow(unused)]
fn main() {
from_fn(|env: &AppEnv| fetch_data(env))
.ensure(|data| data.value > 0, Error::InvalidValue)
.ensure(|data| data.count < 100, Error::TooMany)
}
Running Effects
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
// With environment
let env = AppEnv { /* ... */ };
let result = effect.run(&env).await;
// With unit environment (when Env = ())
use stillwater::RunStandalone;
let result = effect.run_standalone().await;
}
When to Use .boxed()
Boxing is needed in exactly three situations:
1. Storing in Collections
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
// Different effect types can't be stored in the same Vec
// Boxing gives them a uniform type
let effects: Vec<BoxedEffect<i32, String, ()>> = vec![
pure(1).boxed(),
pure(2).map(|x| x * 2).boxed(),
pure(3).and_then(|x| pure(x * 3)).boxed(),
];
// Process them
for effect in effects {
let result = effect.run(&()).await?;
println!("Result: {}", result);
}
}
2. Recursive Effects
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
// Recursive function needs concrete return type
fn countdown(n: i32) -> BoxedEffect<i32, String, ()> {
if n <= 0 {
pure(0).boxed()
} else {
pure(n)
.and_then(move |x| countdown(x - 1).map(move |sum| x + sum))
.boxed()
}
}
let sum = countdown(5).run(&()).await?; // 15
}
3. Match Arms with Different Effect Types
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
enum DataSource {
Cache,
Database,
Remote,
}
fn fetch_data(source: DataSource) -> BoxedEffect<String, String, ()> {
match source {
DataSource::Cache => {
// Just pure value
pure("cached data".to_string()).boxed()
}
DataSource::Database => {
// Effect with map
pure("db")
.map(|s| format!("{} data", s))
.boxed()
}
DataSource::Remote => {
// Effect with and_then
pure("remote")
.and_then(|s| pure(format!("{} data", s)))
.boxed()
}
}
}
}
Reader Pattern
The Reader pattern provides functional dependency injection. Stillwater includes three helpers:
ask() - Access the Environment
Returns the entire environment:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
#[derive(Clone)]
struct Config {
api_key: String,
timeout: u64,
}
// Get the whole environment
let effect = ask::<String, Config>();
let config = Config {
api_key: "secret".into(),
timeout: 30,
};
let result = effect.run(&config).await.unwrap();
assert_eq!(result.api_key, "secret");
}
asks() - Query Environment
Extract a specific value:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
#[derive(Clone)]
struct AppEnv {
database: String,
cache: String,
}
// Query just the database field
let effect = asks(|env: &AppEnv| env.database.clone());
let env = AppEnv {
database: "postgres".into(),
cache: "redis".into(),
};
let result = effect.run(&env).await.unwrap();
assert_eq!(result, "postgres");
}
local() - Modify Environment
Run an effect with a temporarily modified environment:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
#[derive(Clone)]
struct Config {
debug: bool,
timeout: u64,
}
fn fetch_data() -> impl Effect<Output = String, Error = String, Env = Config> {
asks(|cfg: &Config| format!("fetched with timeout {}", cfg.timeout))
}
let config = Config {
debug: false,
timeout: 30,
};
// Run with modified timeout
let effect = local(
|cfg: &Config| Config { timeout: 60, ..*cfg },
fetch_data()
);
let result = effect.run(&config).await.unwrap();
assert_eq!(result, "fetched with timeout 60");
// Original config still has timeout=30
}
Parallel Effects
Heterogeneous Parallel (Zero-Cost)
For 2-4 effects of different types, use par2, par3, par4:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
let (num, text) = par2(
pure::<_, String, ()>(42),
pure::<_, String, ()>("hello".to_string()),
&(),
).await;
let num = num?;
let text = text?;
}
Homogeneous Parallel (Requires Boxing)
For collections of effects, use par_all, race, par_all_limit:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
// par_all - run all, collect all results
let effects: Vec<BoxedEffect<i32, String, ()>> = vec![
pure(1).boxed(),
pure(2).boxed(),
pure(3).boxed(),
];
let results = par_all(effects, &()).await?; // [1, 2, 3]
// race - return first completed result
let effects: Vec<BoxedEffect<String, String, ()>> = vec![
pure("first completed".to_string()).boxed(),
pure("second completed".to_string()).boxed(),
];
let result = race(effects, &()).await?; // one completed value, or RaceError::Empty
// par_all_limit - run with concurrency limit
let effects: Vec<BoxedEffect<i32, String, ()>> = /* many effects */;
let results = par_all_limit(effects, 10, &()).await?; // max 10 concurrent
}
Real-World Example: User Registration
use stillwater::prelude::*;
// Environment with dependencies
#[derive(Clone)]
struct AppEnv {
db: Database,
email_service: EmailService,
}
// Error type
#[derive(Debug)]
enum AppError {
ValidationError(Vec<String>),
EmailExists,
DatabaseError(String),
EmailError(String),
}
// Pure validation (no I/O, easy to test)
fn validate_user(email: &str, age: u8) -> Validation<(), Vec<String>> {
Validation::all((
validate_email(email),
validate_age(age),
))
.map(|_| ())
}
// Effect composition (I/O at boundaries)
fn register_user(
email: String,
age: u8,
) -> impl Effect<Output = User, Error = AppError, Env = AppEnv> {
// 1. Validate input (pure)
from_validation(
validate_user(&email, age)
.map_err(AppError::ValidationError)
)
// 2. Check if email exists (I/O)
.and_then(move |_| {
from_fn(move |env: &AppEnv| {
env.db.find_by_email(&email)
.map_err(|e| AppError::DatabaseError(e.to_string()))
})
})
// 3. Check uniqueness (pure logic)
.and_then(move |existing| {
if existing.is_some() {
fail(AppError::EmailExists)
} else {
pure(())
}
})
// 4. Create user (pure)
.map(move |_| User { email: email.clone(), age })
// 5. Save to database (I/O)
.and_then(|user| {
from_fn(move |env: &AppEnv| {
env.db.save_user(&user)
.map_err(|e| AppError::DatabaseError(e.to_string()))
})
.map(move |_| user)
})
// 6. Send welcome email (I/O)
.and_then(|user| {
from_fn(move |env: &AppEnv| {
env.email_service.send_welcome(&user.email)
.map_err(|e| AppError::EmailError(e.to_string()))
})
.map(move |_| user)
})
}
// Usage at application boundary
#[tokio::main]
async fn main() -> Result<(), AppError> {
let env = AppEnv {
db: Database::connect("postgres://...").await?,
email_service: EmailService::new(),
};
let user = register_user(
"user@example.com".to_string(),
25
).run(&env).await?;
println!("Registered: {:?}", user);
Ok(())
}
Testing Effects
The key benefit: pure functions need no mocks!
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
// Test pure validation (no mocks needed!)
#[test]
fn test_validate_user() {
let result = validate_user("user@example.com", 25);
assert!(result.is_success());
let result = validate_user("invalid", 15);
assert!(result.is_failure());
}
// Test effectful code with mock environment
#[derive(Clone)]
struct MockEnv {
users: Vec<User>,
}
impl MockEnv {
fn find_by_email(&self, email: &str) -> Result<Option<User>, String> {
Ok(self.users.iter().find(|u| u.email == email).cloned())
}
}
#[tokio::test]
async fn test_effect_with_mock_env() {
let env = MockEnv { users: vec![] };
let effect = from_fn(|env: &MockEnv| env.find_by_email("test@example.com"))
.and_then(|existing| {
if existing.is_some() {
fail("Email exists")
} else {
pure(User {
email: "test@example.com".to_string(),
age: 25,
})
}
});
let result = effect.run(&env).await;
assert!(result.is_ok());
}
}
}
Performance Considerations
The Effect trait is zero-cost by default:
- No heap allocations for effect chains
- Compiler can fully inline combinators
- Same performance as hand-written async code
Boxing happens only when you call .boxed():
- Collections of effects
- Recursive effects
- Match arms with different types
For I/O-bound work (API calls, database queries), boxing overhead is negligible compared to actual work.
Common Patterns
Pattern 1: Validate Then Execute
#![allow(unused)]
fn main() {
from_validation(validate_input(input))
.and_then(|valid| execute_with_db(valid))
}
Pattern 2: Read, Decide, Write
#![allow(unused)]
fn main() {
from_fn(|env: &Env| env.db.fetch(id))
.and_then(|data| {
let result = pure_business_logic(data);
from_fn(move |env: &Env| env.db.save(result))
})
}
Pattern 3: Error Context
#![allow(unused)]
fn main() {
create_user(email, age)
.context("Creating user account")
.and_then(|user| {
send_welcome_email(&user)
.context("Sending welcome email")
})
}
Pattern 4: Conditional Effect
#![allow(unused)]
fn main() {
fn conditional_fetch(use_cache: bool) -> BoxedEffect<String, String, AppEnv> {
if use_cache {
from_fn(|env: &AppEnv| Ok(env.cache.get("data"))).boxed()
} else {
from_async(|env: &AppEnv| async { env.db.fetch().await }).boxed()
}
}
}
When to Use Effect
Use Effect when:
- Separating I/O from business logic
- Testing effectful code
- Composing async operations
- Dependency injection needed
Use plain async fn when:
- Simple CRUD operations
- No complex composition
- Testing not critical
- Maximum simplicity needed
Summary
- Effect trait: Zero-cost effect composition following
futurespattern - Pure core: Business logic is easy to test (no mocks)
- Imperative shell: I/O at boundaries via
from_fn,from_async - Environment: Provides dependency injection
- Boxing: Use
.boxed()only when type erasure is needed - Composition: Via
map,and_then,or_else, etc. - Reader pattern:
ask(),asks(),local()for environment access
Next Steps
- Learn about Error Context
- Explore the Reader Pattern in depth
- See the Migration Guide if upgrading from 0.10.x
- Check out testing_patterns example
- Read the API docs
Error Context: Never Lose the Trail
The Problem
Standard errors lose context as they bubble up:
#![allow(unused)]
fn main() {
async fn load_user_profile(id: u64) -> Result<Profile, Error> {
let user = database.fetch_user(id).await?;
// Error: "Connection refused"
// Lost: Why were we connecting? What were we trying to do?
}
}
When an error occurs deep in your code, you lose valuable context:
- What operation was being attempted?
- What was the call path?
- Which resource failed?
The Solution: ContextError
ContextError wraps errors and accumulates context as they propagate:
#![allow(unused)]
fn main() {
use stillwater::ContextError;
let err = ContextError::new("connection refused")
.context("fetching user from database")
.context("loading user profile")
.context("rendering dashboard");
println!("{}", err);
// Output:
// Error: connection refused
// -> fetching user from database
// -> loading user profile
// -> rendering dashboard
}
Now you know exactly what failed and why!
Core API
Creating Context Errors
#![allow(unused)]
fn main() {
use stillwater::ContextError;
// Wrap an error
let err = ContextError::new("file not found");
// Add context
let err = err.context("reading config file");
}
Adding Context Layers
#![allow(unused)]
fn main() {
use stillwater::ContextError;
let err = ContextError::new("parse error")
.context("reading config.toml")
.context("initializing application")
.context("startup sequence");
// Context accumulates in order (innermost to outermost)
assert_eq!(err.context_trail(), &[
"reading config.toml",
"initializing application",
"startup sequence"
]);
}
Accessing the Error
#![allow(unused)]
fn main() {
use stillwater::ContextError;
let err = ContextError::new("base error")
.context("operation failed");
// Get reference to inner error
assert_eq!(err.inner(), &"base error");
// Consume and get inner error
let inner = err.into_inner();
assert_eq!(inner, "base error");
// Get context trail
let trail = err.context_trail();
assert_eq!(trail, &["operation failed"]);
}
Using with Effect
Context errors integrate seamlessly with Effect:
#![allow(unused)]
fn main() {
use stillwater::{Effect, ContextError};
fn load_user(id: u64) -> Effect<User, ContextError<DbError>, AppEnv> {
IO::read(|env: &AppEnv| env.db.fetch_user(id))
.map_err(|e| ContextError::new(e).context("fetching user from database"))
.and_then(|user| {
load_profile(&user)
.map_err(|e| e.context("loading user profile"))
})
.map_err(|e| e.context("rendering dashboard"))
}
}
Even better, Effect provides a context method:
#![allow(unused)]
fn main() {
use stillwater::Effect;
fn load_user(id: u64) -> Effect<User, String, AppEnv> {
IO::read(|env: &AppEnv| env.db.fetch_user(id))
.context("fetching user from database")
.and_then(|user| {
load_profile(&user)
.context("loading user profile")
})
.context("rendering dashboard")
}
}
The Effect’s context method automatically wraps errors in ContextError!
Real-World Example
#![allow(unused)]
fn main() {
use stillwater::{Effect, IO, ContextError};
struct AppEnv {
db: Database,
cache: Cache,
}
#[derive(Debug)]
enum AppError {
DatabaseError(String),
CacheError(String),
NotFound,
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
AppError::CacheError(msg) => write!(f, "Cache error: {}", msg),
AppError::NotFound => write!(f, "Resource not found"),
}
}
}
impl std::error::Error for AppError {}
fn get_user_dashboard(
user_id: u64
) -> Effect<Dashboard, ContextError<AppError>, AppEnv> {
// Try cache first
IO::read(|env: &AppEnv| {
env.cache.get_user(user_id)
.map_err(|e| AppError::CacheError(e.to_string()))
})
.map_err(|e| ContextError::new(e).context("checking user cache"))
.and_then(|cached| {
match cached {
Some(user) => Effect::pure(user),
None => {
// Cache miss - fetch from database
IO::read(|env: &AppEnv| {
env.db.fetch_user(user_id)
.map_err(|e| AppError::DatabaseError(e.to_string()))
})
.map_err(|e| ContextError::new(e).context("fetching user from database"))
}
}
})
.and_then(|user| {
// Load user's projects
IO::read(|env: &AppEnv| {
env.db.fetch_user_projects(user.id)
.map_err(|e| AppError::DatabaseError(e.to_string()))
})
.map_err(|e| ContextError::new(e).context("loading user projects"))
.map(|projects| (user, projects))
})
.and_then(|(user, projects)| {
// Load user's notifications
IO::read(|env: &AppEnv| {
env.db.fetch_notifications(user.id)
.map_err(|e| AppError::DatabaseError(e.to_string()))
})
.map_err(|e| ContextError::new(e).context("loading notifications"))
.map(|notifications| Dashboard { user, projects, notifications })
})
.map_err(|e| e.context("building user dashboard"))
}
// Usage
match get_user_dashboard(123).run(&env).await {
Ok(dashboard) => println!("Dashboard: {:?}", dashboard),
Err(err) => {
eprintln!("{}", err);
// Output might be:
// Error: Database error: connection timeout
// -> fetching user from database
// -> building user dashboard
}
}
}
Best Practices
Add Context at Boundaries
Don’t add context to every function. Add it at major operation boundaries:
#![allow(unused)]
fn main() {
// ❌ Too much context
fn validate_email(email: &str) -> Result<Email, ContextError<Error>> {
check_format(email)
.context("checking email format")?; // Too granular
check_domain(email)
.context("checking email domain")?; // Too granular
Ok(Email(email))
}
// ✓ Context at boundaries
fn register_user(input: UserInput) -> Effect<User, ContextError<Error>, Env> {
validate_user(input)
.context("validating user input") // Good
.and_then(|valid| {
save_to_database(valid)
.context("saving user to database") // Good
})
}
}
Be Specific but Concise
#![allow(unused)]
fn main() {
// ❌ Too vague
.context("error occurred")
// ❌ Too verbose
.context("An error occurred while attempting to read the user configuration file from disk")
// ✓ Just right
.context("reading user config file")
}
Include Relevant Identifiers
#![allow(unused)]
fn main() {
// ✓ Include user ID for debugging
.context(format!("loading profile for user {}", user_id))
// ✓ Include file path
.context(format!("reading config from {}", path.display()))
}
When to Use ContextError
Use ContextError when:
- Debugging production issues
- Errors cross multiple layers
- You need to understand call paths
- Building user-facing error messages
Don’t use when:
- Performance is critical (hot loops)
- Error types already have good messages
- Single-layer operations
Display Format
ContextError formats nicely for logging:
#![allow(unused)]
fn main() {
use stillwater::ContextError;
let err = ContextError::new("connection timeout")
.context("querying database")
.context("loading user profile")
.context("rendering dashboard");
println!("{}", err);
// Output:
// Error: connection timeout
// -> querying database
// -> loading user profile
// -> rendering dashboard
}
The indentation makes the error trail clear and readable.
Testing
Test error context accumulation:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_error_context() {
let env = MockEnv::with_error();
let result = load_user_dashboard(123).run(&env).await;
match result {
Err(err) => {
// Check inner error
assert_eq!(
err.inner().to_string(),
"Database error: connection failed"
);
// Check context trail
let trail = err.context_trail();
assert!(trail.contains(&"fetching user from database".to_string()));
assert!(trail.contains(&"building user dashboard".to_string()));
}
Ok(_) => panic!("Expected error"),
}
}
}
}
Integration with Other Error Crates
ContextError implements std::error::Error, so it works with libraries like anyhow:
#![allow(unused)]
fn main() {
use anyhow::Result;
use stillwater::ContextError;
fn example() -> Result<()> {
let ctx_err = ContextError::new("base error")
.context("operation failed");
Err(ctx_err)? // Converts to anyhow::Error
}
}
Performance Considerations
ContextError has minimal overhead:
- Small allocation for context Vec
- String allocations for messages
- No runtime cost if not used
The benefits for debugging usually outweigh the costs.
Summary
- ContextError accumulates context as errors propagate
- Context trail shows the call path
- Effect.context() makes adding context ergonomic
- Add context at boundaries, not everywhere
- Display format is clean and readable
Next Steps
- Learn about the IO Module
- See error_context example
- Read about Helper Combinators
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:
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
Stillwater provides helper functions for common patterns with Validation and Effect.
Validation Combinators
all() - Combine multiple validations
Already covered in Validation guide:
#![allow(unused)]
fn main() {
use stillwater::Validation;
Validation::all((
validate_email(email),
validate_age(age),
validate_password(password),
))
}
all_vec() - Combine vector of validations
For homogeneous collections:
#![allow(unused)]
fn main() {
use stillwater::Validation;
let validations: Vec<Validation<Item, Vec<Error>>> = items
.into_iter()
.map(|item| validate_item(item))
.collect();
let result: Validation<Vec<Item>, Vec<Error>> = Validation::all_vec(validations);
}
Effect Combinators
map() - Transform success value
#![allow(unused)]
fn main() {
effect.map(|user| user.email)
}
and_then() - Chain dependent effects
#![allow(unused)]
fn main() {
effect.and_then(|user| load_profile(user))
}
map_err() - Transform error value
#![allow(unused)]
fn main() {
effect.map_err(|e| format!("Failed: {}", e))
}
Building Custom Combinators
You can build your own combinators for common patterns:
#![allow(unused)]
fn main() {
use stillwater::{Effect, Validation};
// Retry combinator
fn retry<T, E, Env>(
effect: Effect<T, E, Env>,
times: usize
) -> Effect<T, E, Env>
where
T: Clone,
E: Clone,
{
// Implementation left as exercise
effect
}
// Timeout combinator
fn timeout<T, E, Env>(
effect: Effect<T, E, Env>,
duration: Duration
) -> Effect<T, TimeoutError<E>, Env> {
// Implementation left as exercise
todo!()
}
}
Next Steps
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
#![allow(unused)]
#![feature(try_trait_v2)]
fn main() {
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:
#![allow(unused)]
fn main() {
// ✓ 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):
#![allow(unused)] fn main() { a.combine(b).combine(c) == a.combine(b.combine(c)) } -
Right Identity:
#![allow(unused)] fn main() { a.combine(M::empty()) == a } -
Left Identity:
#![allow(unused)] fn main() { M::empty().combine(a) == a }
Basic Examples
Vec Monoid
Vectors form a monoid with the empty vector as identity:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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 Pattern in Stillwater
What is the Reader Pattern?
The Reader pattern is a functional programming technique for dependency injection. Instead of passing dependencies through every function parameter, you “ask” for them from an environment when needed.
In Stillwater, the Reader pattern is built into the Effect type through three key functions:
ask()- Get the entire environmentasks(f)- Extract a specific value from the environmentlocal(f, effect)- Run an effect with a modified environment
Why Use the Reader Pattern?
Problem: Dependency Threading
Without Reader, you pass dependencies everywhere:
#![allow(unused)]
fn main() {
fn process_order(order: Order, db: &Database, cache: &Cache, logger: &Logger) -> Result<()> {
validate_order(order, db)?;
save_order(order, db, cache, logger)?;
notify_customer(order, logger)?;
Ok(())
}
fn validate_order(order: Order, db: &Database) -> Result<()> {
// Only needs db, but caller must provide it
}
fn save_order(order: Order, db: &Database, cache: &Cache, logger: &Logger) -> Result<()> {
// All three dependencies needed
}
}
This gets tedious and error-prone as the application grows.
Solution: Reader Pattern
With Reader, dependencies live in an environment:
#![allow(unused)]
fn main() {
use stillwater::{Effect, IO};
struct AppEnv {
db: Database,
cache: Cache,
logger: Logger,
}
fn process_order(order: Order) -> Effect<(), Error, AppEnv> {
validate_order(order.clone())
.and_then(|_| save_order(order.clone()))
.and_then(|_| notify_customer(order))
}
fn validate_order(order: Order) -> Effect<(), Error, AppEnv> {
// Ask for just the database when needed
IO::read(|env: &AppEnv| env.db.validate(&order))
}
fn save_order(order: Order) -> Effect<(), Error, AppEnv> {
// Functions get dependencies implicitly
IO::read(|env: &AppEnv| env.db.save(&order))
.and_then(|_| IO::write(|env: &mut AppEnv| {
env.cache.invalidate(&order.id)
}))
}
}
Benefits:
- No threading dependencies through parameters
- Easy to add new dependencies without changing function signatures
- Environment is explicit at type level (
AppEnv) - Testing is easier with mock environments
Core Functions
ask() - Access the Whole Environment
Use ask() when you need the entire environment:
#![allow(unused)]
fn main() {
use stillwater::Effect;
#[derive(Clone)]
struct Config {
api_key: String,
timeout: u64,
debug: bool,
}
fn log_config() -> Effect<String, String, Config> {
Effect::ask()
.map(|cfg: Config| {
format!(
"Config: timeout={}, debug={}",
cfg.timeout,
cfg.debug
)
})
}
// Run it
tokio_test::block_on(async {
let config = Config {
api_key: "secret".into(),
timeout: 30,
debug: true,
};
let result = log_config().run(&config).await.unwrap();
assert_eq!(result, "Config: timeout=30, debug=true");
});
}
asks() - Query Specific Values
Use asks(f) when you only need part of the environment:
#![allow(unused)]
fn main() {
use stillwater::Effect;
struct AppEnv {
database_url: String,
cache_url: String,
max_connections: u32,
}
fn get_db_url() -> Effect<String, String, AppEnv> {
Effect::asks(|env: &AppEnv| env.database_url.clone())
}
fn get_max_connections() -> Effect<u32, String, AppEnv> {
Effect::asks(|env: &AppEnv| env.max_connections)
}
// Compose them
fn connect() -> Effect<String, String, AppEnv> {
Effect::asks(|env: &AppEnv| env.database_url.clone())
.and_then(|url| {
Effect::asks(|env: &AppEnv| env.max_connections)
.map(move |max| {
format!("Connecting to {} with {} connections", url, max)
})
})
}
tokio_test::block_on(async {
let env = AppEnv {
database_url: "postgres://localhost".into(),
cache_url: "redis://localhost".into(),
max_connections: 10,
};
let result = connect().run(&env).await.unwrap();
assert_eq!(result, "Connecting to postgres://localhost with 10 connections");
});
}
local() - Temporary Environment Modifications
Use local(f, effect) to run an effect with a modified environment:
#![allow(unused)]
fn main() {
use stillwater::Effect;
#[derive(Clone)]
struct Config {
timeout: u64,
retries: u32,
}
fn fetch_data() -> Effect<String, String, Config> {
Effect::asks(|cfg: &Config| {
format!("Fetching with timeout={}, retries={}", cfg.timeout, cfg.retries)
})
}
fn fetch_with_extended_timeout() -> Effect<String, String, Config> {
// Temporarily increase timeout for this operation
Effect::local(
|cfg: &Config| Config {
timeout: cfg.timeout * 2,
retries: cfg.retries,
},
fetch_data()
)
}
tokio_test::block_on(async {
let config = Config {
timeout: 30,
retries: 3,
};
// Normal fetch
let result = fetch_data().run(&config).await.unwrap();
assert_eq!(result, "Fetching with timeout=30, retries=3");
// With extended timeout
let result = fetch_with_extended_timeout().run(&config).await.unwrap();
assert_eq!(result, "Fetching with timeout=60, retries=3");
// Original config unchanged
assert_eq!(config.timeout, 30);
});
}
Composition Patterns
Pattern 1: Combining asks() with Business Logic
#![allow(unused)]
fn main() {
use stillwater::Effect;
struct PricingEnv {
tax_rate: f64,
discount_rate: f64,
}
fn calculate_price(base_price: f64) -> Effect<f64, String, PricingEnv> {
Effect::asks(|env: &PricingEnv| env.tax_rate)
.and_then(move |tax| {
Effect::asks(|env: &PricingEnv| env.discount_rate)
.map(move |discount| {
let discounted = base_price * (1.0 - discount);
discounted * (1.0 + tax)
})
})
}
tokio_test::block_on(async {
let env = PricingEnv {
tax_rate: 0.08,
discount_rate: 0.10,
};
let final_price = calculate_price(100.0).run(&env).await.unwrap();
assert_eq!(final_price, 97.2); // (100 * 0.9) * 1.08
});
}
Pattern 2: Environment-Dependent Decisions
#![allow(unused)]
fn main() {
use stillwater::{Effect, IO};
struct AppEnv {
debug_mode: bool,
log_level: String,
}
fn log_message(msg: String) -> Effect<(), String, AppEnv> {
Effect::asks(|env: &AppEnv| env.debug_mode)
.and_then(move |debug| {
if debug {
Effect::from_fn(move |env: &AppEnv| {
println!("[{}] {}", env.log_level, msg);
Ok(())
})
} else {
Effect::pure(())
}
})
}
}
Pattern 3: Nested Environments with local()
#![allow(unused)]
fn main() {
use stillwater::Effect;
#[derive(Clone)]
struct ServerConfig {
host: String,
port: u16,
timeout: u64,
}
fn make_request(path: &str) -> Effect<String, String, ServerConfig> {
Effect::asks(move |cfg: &ServerConfig| {
format!("GET {}:{}{} (timeout={})", cfg.host, cfg.port, path, cfg.timeout)
})
}
fn make_critical_request(path: &str) -> Effect<String, String, ServerConfig> {
// Critical requests get longer timeout
let path = path.to_string();
Effect::local(
|cfg: &ServerConfig| ServerConfig {
timeout: cfg.timeout * 3,
..cfg.clone()
},
Effect::asks(move |cfg: &ServerConfig| {
format!("GET {}:{}{} (timeout={})", cfg.host, cfg.port, path, cfg.timeout)
})
)
}
tokio_test::block_on(async {
let config = ServerConfig {
host: "api.example.com".into(),
port: 443,
timeout: 10,
};
let normal = make_request("/users").run(&config).await.unwrap();
assert_eq!(normal, "GET api.example.com:443/users (timeout=10)");
let critical = make_critical_request("/payment").run(&config).await.unwrap();
assert_eq!(critical, "GET api.example.com:443/payment (timeout=30)");
});
}
Real-World Example: Multi-Tier Application
#![allow(unused)]
fn main() {
use stillwater::{Effect, IO};
// Environment with multiple dependencies
struct AppEnv {
database: Database,
cache: Cache,
email: EmailService,
config: AppConfig,
}
struct AppConfig {
max_retries: u32,
cache_ttl: u64,
}
#[derive(Clone)]
struct User {
id: u64,
email: String,
}
// Business logic uses Reader pattern
fn register_user(email: String) -> Effect<User, AppError, AppEnv> {
// Validate email format (pure)
validate_email(&email)?
// Check if user exists (asks for database)
check_user_exists(email.clone())
.and_then(|exists| {
if exists {
Effect::fail(AppError::UserExists)
} else {
create_and_save_user(email)
}
})
}
fn check_user_exists(email: String) -> Effect<bool, AppError, AppEnv> {
IO::read(move |env: &AppEnv| {
env.database.find_by_email(&email)
.map(|opt| opt.is_some())
.map_err(AppError::DatabaseError)
})
}
fn create_and_save_user(email: String) -> Effect<User, AppError, AppEnv> {
let user = User {
id: generate_id(),
email: email.clone(),
};
// Save to database
save_user_to_db(user.clone())
// Update cache
.and_then(|user| cache_user(user))
// Send welcome email
.and_then(|user| {
send_welcome_email(user.clone())
.map(|_| user)
})
}
fn save_user_to_db(user: User) -> Effect<User, AppError, AppEnv> {
from_fn(move |env: &AppEnv| {
env.database.insert_user(&user)
.map(|_| user.clone())
.map_err(AppError::DatabaseError)
})
}
fn cache_user(user: User) -> Effect<User, AppError, AppEnv> {
// Get cache TTL from config
Effect::asks(|env: &AppEnv| env.config.cache_ttl)
.and_then(move |ttl| {
from_fn(move |env: &AppEnv| {
env.cache.set(&user.id, &user, ttl)
.map(|_| user.clone())
.map_err(AppError::CacheError)
})
})
}
fn send_welcome_email(user: User) -> Effect<(), AppError, AppEnv> {
from_fn(move |env: &AppEnv| {
env.email.send(&user.email, "Welcome!", "Thanks for joining!")
.map_err(AppError::EmailError)
})
}
// At application boundary
tokio_test::block_on(async {
let env = AppEnv {
database: Database::connect("postgres://...").await?,
cache: Cache::connect("redis://...").await?,
email: EmailService::new("smtp://..."),
config: AppConfig {
max_retries: 3,
cache_ttl: 3600,
},
};
match register_user("user@example.com".into()).run(&env).await {
Ok(user) => println!("User registered: {:?}", user),
Err(e) => eprintln!("Registration failed: {:?}", e),
}
});
}
Testing with Reader Pattern
The Reader pattern makes testing easier with mock environments:
#![allow(unused)]
fn main() {
use stillwater::Effect;
struct AppEnv {
database: Box<dyn UserRepository>,
}
trait UserRepository {
fn find_by_email(&self, email: &str) -> Result<Option<User>, String>;
}
fn get_user_by_email(email: String) -> Effect<Option<User>, String, AppEnv> {
IO::read(move |env: &AppEnv| {
env.database.find_by_email(&email)
})
}
#[cfg(test)]
mod tests {
use super::*;
struct MockDatabase {
users: Vec<User>,
}
impl UserRepository for MockDatabase {
fn find_by_email(&self, email: &str) -> Result<Option<User>, String> {
Ok(self.users.iter()
.find(|u| u.email == email)
.cloned())
}
}
#[tokio::test]
async fn test_get_user() {
let mock_db = MockDatabase {
users: vec![
User { id: 1, email: "test@example.com".into() },
],
};
let env = AppEnv {
database: Box::new(mock_db),
};
let result = get_user_by_email("test@example.com".into())
.run(&env)
.await
.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().id, 1);
}
}
}
When to Use Each Function
Use ask() when:
- You need the whole environment
- Passing the environment to another function
- Inspecting multiple environment fields at once
Use asks(f) when:
- You only need one or two fields
- Extracting configuration values
- Computing derived values from environment
Use local(f, effect) when:
- Temporarily overriding configuration
- Testing with modified settings
- Scoping changes to specific operations
- Implementing feature flags or A/B tests
Best Practices
1. Keep Environments Small and Focused
#![allow(unused)]
fn main() {
// ❌ Bad: Kitchen sink environment
struct AppEnv {
db: Database,
cache: Cache,
email: Email,
sms: SMS,
payment: Payment,
analytics: Analytics,
// ... 20 more dependencies
}
// ✓ Good: Focused environments
struct DataEnv {
db: Database,
cache: Cache,
}
struct NotificationEnv {
email: Email,
sms: SMS,
}
struct PaymentEnv {
payment: Payment,
analytics: Analytics,
}
}
2. Use asks() for Simple Queries
#![allow(unused)]
fn main() {
// ❌ Verbose
Effect::ask().map(|env: Config| env.timeout)
// ✓ Concise
Effect::asks(|env: &Config| env.timeout)
}
3. Compose with and_then for Dependent Operations
#![allow(unused)]
fn main() {
fn process() -> Effect<Result, Error, AppEnv> {
Effect::asks(|env: &AppEnv| env.config.max_retries)
.and_then(|retries| {
Effect::asks(|env: &AppEnv| env.database.clone())
.and_then(move |db| {
retry_with_db(db, retries)
})
})
}
}
4. Use local() Sparingly
Only use local() when you truly need to modify the environment temporarily. Most of the time, asks() is sufficient.
Common Pitfalls
Pitfall 1: Cloning Large Environments
#![allow(unused)]
fn main() {
// ❌ ask() clones the entire environment
let effect = Effect::<LargeEnv, _, LargeEnv>::ask();
// ✓ asks() only extracts what you need
let effect = Effect::asks(|env: &LargeEnv| env.small_field.clone());
}
Pitfall 2: Nested local() Calls
#![allow(unused)]
fn main() {
// ❌ Hard to follow
Effect::local(
|cfg| modify1(cfg),
Effect::local(
|cfg| modify2(cfg),
Effect::local(
|cfg| modify3(cfg),
do_work()
)
)
)
// ✓ Compose modifications
Effect::local(
|cfg| modify3(&modify2(&modify1(cfg))),
do_work()
)
}
Summary
The Reader pattern in Stillwater provides:
- Clean dependency injection without parameter threading
- Type-safe environment access
- Easy testing with mock environments
- Functional composition of environment-dependent operations
Key functions:
ask()- Get the whole environmentasks(f)- Query specific valueslocal(f, effect)- Temporary modifications
Use the Reader pattern when you want clean, testable dependency injection in your Effect compositions.
Next Steps
- Review the Effects guide for more Effect patterns
- Check out the IO Module for I/O helpers
- See testing_patterns example
- Read about Error Context
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
#[tokio::test]
async fn loads_users_in_parallel() {
let env = TestEnv::with_users(vec![
User::new(1),
User::new(2),
User::new(3),
]);
let effects: Vec<BoxedEffect<User, TestError, TestEnv>> = vec![
fetch_user(1).boxed(),
fetch_user(2).boxed(),
fetch_user(3).boxed(),
];
let users = par_all(effects, &env).await.unwrap();
assert_eq!(users.len(), 3);
}
}
For timing-sensitive tests, keep assertions loose enough to avoid flakes. Prefer testing result shape and concurrency limits over exact elapsed time.
Common Pitfalls
Do Not Parallelize Dependent Operations
#![allow(unused)]
fn main() {
// 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
#![allow(unused)]
fn main() {
// 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 individual effect builders zero-cost, and box only when placing them into a homogeneous collection:
#![allow(unused)]
fn main() {
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
The Problem
When working with collections of data that need validation or effectful processing, you often face a choice:
#![allow(unused)]
fn main() {
// Option 1: Process one at a time, manually accumulating
let mut results = Vec::new();
let mut errors = Vec::new();
for item in items {
match validate(item) {
Validation::Success(val) => results.push(val),
Validation::Failure(err) => errors.extend(err),
}
}
// Now what? How do we combine results and errors?
// Option 2: Use map and somehow convert Vec<Validation<T, E>> to Validation<Vec<T>, E>
let validations: Vec<Validation<_, _>> = items.iter().map(validate).collect();
// But how do we turn this into a single Validation?
}
Both approaches are cumbersome and error-prone. This is where traverse and sequence come in.
The Solution: Traverse and Sequence
Stillwater provides two fundamental operations for working with collections of effects:
-
sequence: Converts a collection of effects into an effect of a collectionVec<Validation<T, E>>→Validation<Vec<T>, E>Vec<Effect<T, E, Env>>→Effect<Vec<T>, E, Env>
-
traverse: Maps a function over a collection and sequences the results- Equivalent to
map(f).sequence()but more efficient
- Equivalent to
Core Concepts
Sequence
Sequence inverts the structure of nested types:
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::sequence};
// We have: Vec<Validation<T, E>>
let validations = vec![
Validation::success(1),
Validation::success(2),
Validation::success(3),
];
// We want: Validation<Vec<T>, E>
let result = sequence(validations);
assert_eq!(result, Validation::Success(vec![1, 2, 3]));
}
If any validation fails, all errors are accumulated:
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::sequence};
let validations = vec![
Validation::<i32, _>::failure(vec!["error 1"]),
Validation::success(2),
Validation::failure(vec!["error 2"]),
];
let result = sequence(validations);
match result {
Validation::Failure(errors) => {
assert_eq!(errors, vec!["error 1", "error 2"]);
}
_ => panic!("Expected failure"),
}
}
Traverse
Traverse combines mapping and sequencing in one operation:
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
fn parse_number(s: &str) -> Validation<i32, Vec<String>> {
s.parse()
.map(Validation::success)
.unwrap_or_else(|_| Validation::failure(vec![format!("Invalid: {}", s)]))
}
// Instead of: items.iter().map(parse_number).collect() then sequence
let result = traverse(vec!["1", "2", "3"], parse_number);
assert_eq!(result, Validation::Success(vec![1, 2, 3]));
}
Validation Examples
Validating User Input Collections
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
#[derive(Debug, PartialEq)]
struct Email(String);
#[derive(Debug)]
enum ValidationError {
InvalidEmail(String),
}
fn validate_email(raw: &str) -> Validation<Email, Vec<ValidationError>> {
if raw.contains('@') && raw.contains('.') {
Validation::success(Email(raw.to_string()))
} else {
Validation::failure(vec![ValidationError::InvalidEmail(raw.to_string())])
}
}
// Validate a list of email addresses
let emails = vec!["alice@example.com", "bob@example.com", "invalid"];
let result = traverse(emails, validate_email);
match result {
Validation::Success(valid_emails) => {
println!("All valid: {:?}", valid_emails);
}
Validation::Failure(errors) => {
println!("Found {} invalid emails:", errors.len());
for err in errors {
println!(" {:?}", err);
}
}
}
}
Validating Nested Data
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
#[derive(Debug)]
struct User {
name: String,
age: u8,
}
fn validate_user(name: &str, age: u8) -> Validation<User, Vec<String>> {
let name_check = if name.is_empty() {
Validation::failure(vec!["Name cannot be empty".to_string()])
} else {
Validation::success(name.to_string())
};
let age_check = if age >= 18 {
Validation::success(age)
} else {
Validation::failure(vec![format!("Age {} too young", age)])
};
Validation::all((name_check, age_check))
.map(|(name, age)| User { name, age })
}
// Validate a batch of user registrations
let registrations = vec![
("Alice", 25),
("Bob", 16),
("", 30),
];
let result = traverse(registrations, |(name, age)| validate_user(name, age));
match result {
Validation::Success(users) => {
println!("All valid: {} users registered", users.len());
}
Validation::Failure(errors) => {
println!("Validation errors:");
for err in errors {
println!(" - {}", err);
}
}
}
}
CSV Parsing with Error Accumulation
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
#[derive(Debug)]
struct Record {
id: i32,
name: String,
score: f64,
}
fn parse_record(line: &str) -> Validation<Record, Vec<String>> {
let parts: Vec<_> = line.split(',').collect();
if parts.len() != 3 {
return Validation::failure(vec![
format!("Expected 3 fields, got {}", parts.len())
]);
}
let id_check = parts[0].parse::<i32>()
.map(Validation::success)
.unwrap_or_else(|_| Validation::failure(vec![
format!("Invalid ID: {}", parts[0])
]));
let name_check = if parts[1].is_empty() {
Validation::failure(vec!["Name cannot be empty".to_string()])
} else {
Validation::success(parts[1].to_string())
};
let score_check = parts[2].parse::<f64>()
.map(Validation::success)
.unwrap_or_else(|_| Validation::failure(vec![
format!("Invalid score: {}", parts[2])
]));
Validation::all((id_check, name_check, score_check))
.map(|(id, name, score)| Record { id, name, score })
}
let csv_lines = vec![
"1,Alice,95.5",
"2,Bob,invalid",
"3,,88.0",
"bad,line",
];
let result = traverse(csv_lines, parse_record);
match result {
Validation::Success(records) => {
println!("Parsed {} records", records.len());
}
Validation::Failure(errors) => {
println!("CSV parsing errors:");
for err in errors {
println!(" - {}", err);
}
}
}
}
Effect Examples
Batch File Processing
#![allow(unused)]
fn main() {
use stillwater::{Effect, traverse::traverse_effect};
fn process_file(path: &str) -> Effect<String, String, ()> {
Effect::of(move |_env| {
Box::pin(async move {
// Simulate file reading
Ok(format!("Contents of {}", path))
})
})
}
let files = vec!["file1.txt", "file2.txt", "file3.txt"];
let effect = traverse_effect(files, |path| process_file(path));
// Run the effect
tokio_test::block_on(async {
match effect.run_standalone().await {
Ok(contents) => {
for content in contents {
println!("{}", content);
}
}
Err(e) => eprintln!("Error: {}", e),
}
});
}
Database Batch Operations
#![allow(unused)]
fn main() {
use stillwater::{Effect, traverse::traverse_effect};
struct Database {
// Database connection details
}
fn save_user(db: &Database, user: User) -> Effect<i64, String, Database> {
Effect::of(|db| {
Box::pin(async move {
// Simulate database save
Ok(42) // user ID
})
})
}
let users = vec![
User { name: "Alice".to_string(), age: 25 },
User { name: "Bob".to_string(), age: 30 },
];
let db = Database {};
let effect = traverse_effect(users, |user| save_user(&db, user));
// Run the effect
tokio_test::block_on(async {
match effect.run(&db).await {
Ok(ids) => {
println!("Saved {} users with IDs: {:?}", ids.len(), ids);
}
Err(e) => eprintln!("Database error: {}", e),
}
});
}
Parallel API Calls
#![allow(unused)]
fn main() {
use stillwater::{Effect, traverse::traverse_effect};
fn fetch_user(id: i32) -> Effect<String, String, ()> {
Effect::of(move |_env| {
Box::pin(async move {
// Simulate API call
Ok(format!("User {}", id))
})
})
}
let user_ids = vec![1, 2, 3, 4, 5];
let effect = traverse_effect(user_ids, fetch_user);
// Effects run in parallel
tokio_test::block_on(async {
match effect.run_standalone().await {
Ok(users) => {
println!("Fetched {} users", users.len());
}
Err(e) => eprintln!("API error: {}", e),
}
});
}
Sequence Examples
Sequencing Pre-computed Validations
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::sequence};
// When you already have validations (perhaps from different sources)
let validations = vec![
validate_field_1(),
validate_field_2(),
validate_field_3(),
];
let result = sequence(validations);
}
Sequencing Effects
#![allow(unused)]
fn main() {
use stillwater::{Effect, traverse::sequence_effect};
// When you have a collection of effects to run
let effects = vec![
Effect::pure(1),
Effect::pure(2),
Effect::pure(3),
];
let combined = sequence_effect(effects);
tokio_test::block_on(async {
let result = combined.run_standalone().await;
assert_eq!(result, Ok(vec![1, 2, 3]));
});
}
Practical Patterns
Pattern 1: Validate Then Process
#![allow(unused)]
fn main() {
use stillwater::{Validation, Effect, traverse::traverse};
// First validate all inputs
let validation = traverse(inputs, validate_input);
// Then convert to effect and process
let effect = Effect::from_validation(validation)
.and_then(|valid_inputs| process_batch(valid_inputs));
}
Pattern 2: Fail Fast vs Accumulate
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
// Accumulate all validation errors
fn validate_all(items: Vec<Item>) -> Validation<Vec<Valid>, Vec<Error>> {
traverse(items, validate_item)
}
// Fail on first error (use Effect instead)
fn process_all(items: Vec<Item>) -> Effect<Vec<Result>, Error, Env> {
traverse_effect(items, process_item)
}
}
Pattern 3: Filtering with Validation
#![allow(unused)]
fn main() {
use stillwater::{Validation, traverse::traverse};
fn validate_and_filter(items: Vec<String>) -> Validation<Vec<i32>, Vec<String>> {
let parsed = traverse(items, |s| {
s.parse::<i32>()
.map(Validation::success)
.unwrap_or_else(|_| Validation::failure(vec![format!("Invalid: {}", s)]))
});
parsed
}
}
Pattern 4: Transform with Environment
#![allow(unused)]
fn main() {
use stillwater::{Effect, traverse::traverse_effect};
struct Config {
api_key: String,
}
fn fetch_with_auth(id: i32) -> Effect<Data, Error, Config> {
Effect::asks(move |config: &Config| {
// Use config.api_key in request
Data { id }
})
}
let config = Config { api_key: "secret".to_string() };
let effect = traverse_effect(vec![1, 2, 3], fetch_with_auth);
// All requests share the same config
tokio_test::block_on(async {
let result = effect.run(&config).await;
});
}
When to Use What
Use traverse when:
- You have a collection and a function to apply to each element
- The function returns a Validation or Effect
- You want a single result aggregating all outcomes
Use sequence when:
- You already have a collection of Validations or Effects
- You need to invert the structure (Vec of Validations → Validation of Vec)
Use Validation::all() when:
- You have a fixed number of validations (tuple)
- The validations are different types
- You want to combine them all
Use traverse vs manual loop when:
- traverse: Pure transformation, all errors matter
- manual loop: Need early exit, complex control flow
Performance Considerations
Memory Efficiency
traverseis more efficient thanmap().sequence()because it only creates one collection- For large collections, consider streaming or chunking
Parallel vs Sequential
traverse_effectruns effects in parallel by default- For CPU-bound work, this is optimal
- For I/O-bound work with rate limits, consider sequential processing
Early Termination
- Validation accumulates ALL errors (no early exit)
- Effect stops at first error (fail-fast)
- Choose based on your error handling needs
Common Pitfalls
Pitfall 1: Not handling empty collections
#![allow(unused)]
fn main() {
// Empty collections return success with empty vec
let result = traverse(Vec::<i32>::new(), validate);
assert_eq!(result, Validation::Success(vec![]));
// Make sure this is the behavior you want!
}
Pitfall 2: Mixing traverse and for loops
#![allow(unused)]
fn main() {
// Bad: Manual loop loses error accumulation
for item in items {
validate(item)?; // Stops at first error!
}
// Good: Use traverse
traverse(items, validate)
}
Pitfall 3: Forgetting to map after traverse
#![allow(unused)]
fn main() {
// Returns Validation<Vec<(String, i32)>, E>
traverse(items, |item| {
Validation::all((validate_name(item.name), validate_age(item.age)))
})
// Better: Map to User
traverse(items, |item| {
Validation::all((validate_name(item.name), validate_age(item.age)))
.map(|(name, age)| User { name, age })
})
}
Testing
Testing traverse operations is straightforward:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use stillwater::{Validation, traverse::traverse};
#[test]
fn test_traverse_all_valid() {
let result = traverse(vec![1, 2, 3], validate_positive);
assert!(result.is_success());
}
#[test]
fn test_traverse_accumulates_errors() {
let result = traverse(vec![1, -2, -3], validate_positive);
match result {
Validation::Failure(errors) => {
assert_eq!(errors.len(), 2); // Two negative numbers
}
_ => panic!("Expected failure"),
}
}
#[test]
fn test_traverse_empty() {
let result = traverse(Vec::<i32>::new(), validate_positive);
assert_eq!(result, Validation::Success(vec![]));
}
}
}
Summary
- traverse and sequence invert collection structures
- Use traverse for Validation to accumulate ALL errors
- Use traverse_effect for parallel Effect execution
- Choose based on error handling needs: accumulate vs fail-fast
- Test thoroughly, especially edge cases like empty collections
Next Steps
- Review Validation guide for error accumulation
- See Effects guide for async processing
- Check examples/ for complete examples
- Read the API docs for full details
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:
#![allow(unused)]
fn main() {
let data = fetch_data().run(&env).await?;
}
With retry:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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 = "1.0", features = ["jitter"] }
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
pub enum TimeoutError<E> {
Timeout { duration: Duration },
Inner(E),
}
}
Real-World Patterns
HTTP Client With Retry
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
// 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
#![allow(unused)]
fn main() {
// 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
#![allow(unused)]
fn main() {
enum QueryResult {
Rows(Vec<Row>),
Count(usize),
Affected(usize),
}
// Each shard should return the same type
}
Plugin Systems: Aggregating outputs from multiple plugins
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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 - Zero-Cost Abstraction: No runtime overhead compared to manual validation
API Overview
Core Functions
validate_homogeneous
Validates that all items in a collection have the same discriminant:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
pub trait DiscriminantName {
fn discriminant_name(&self) -> &'static str;
}
}
Example implementation:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
pub struct TypeMismatchError {
pub index: usize,
pub expected: String,
pub got: String,
}
}
Use with DiscriminantName:
#![allow(unused)]
fn main() {
let result = validate_homogeneous(
items,
|a| std::mem::discriminant(a),
TypeMismatchError::new,
);
}
Examples
Example 1: Aggregation Pipeline
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
use stillwater::{Effect, IO, Validation};
use stillwater::validation::homogeneous::combine_homogeneous;
fn aggregate_with_validation(
job_id: &str,
) -> Effect<AggregateResult, Vec<String>, Env> {
IO::read(|env| env.load_results(job_id))
.and_then(|results| {
// Validation at I/O boundary
match combine_homogeneous(
results,
|r| std::mem::discriminant(r),
|idx, _, _| format!("Worker {} type mismatch", idx),
) {
Validation::Success(combined) => Effect::pure(combined),
Validation::Failure(errors) => Effect::fail(errors),
}
})
.context("Aggregating results with type validation")
}
}
Best Practices
1. Validate at Boundaries
Always validate at system boundaries, not in the middle of business logic:
✅ Good: Validate at I/O boundaries
#![allow(unused)]
fn main() {
IO::read(load_results)
.and_then(|results| validate_and_process(results))
}
❌ Bad: Validate in the middle of logic
#![allow(unused)]
fn main() {
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!():
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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 is a zero-cost abstraction:
- 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
Benchmark results show no overhead compared to manual validation.
Common Patterns
Pattern 1: MapReduce Aggregation
#![allow(unused)]
fn main() {
// Validate before reducing
let aggregated = combine_homogeneous(
worker_results,
|r| discriminant(r),
|idx, _, _| format!("Worker {} mismatch", idx),
)?;
}
Pattern 2: Config Merging
#![allow(unused)]
fn main() {
// Validate configs are same type before merging
validate_homogeneous(configs, discriminant, make_error)
.and_then(|configs| merge_configs(configs))
}
Pattern 3: Database Sharding
#![allow(unused)]
fn main() {
// Validate shard results are consistent
combine_homogeneous(
shard_results,
|r| discriminant(r),
|idx, _, _| format!("Shard {} inconsistent", idx),
)
}
Pattern 4: Plugin Composition
#![allow(unused)]
fn main() {
// 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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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)
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
The Problem
Testing code that uses validation and effects requires:
- Setting up mock environments repeatedly
- Writing verbose assertions for
Validationtypes - Creating test data and checking results manually
- Property-based testing for comprehensive coverage
Without proper utilities, test code becomes verbose and repetitive, making tests harder to write and maintain.
The Solution: Testing Utilities
Stillwater provides ergonomic testing utilities that make writing tests concise and expressive:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
#[test]
fn test_user_validation() {
let result = validate_user("user@example.com", 25);
assert_success!(result);
}
}
MockEnv Builder
The MockEnv builder creates test environments by composing dependencies:
#![allow(unused)]
fn main() {
use stillwater::testing::MockEnv;
struct Database {
users: Vec<User>,
}
struct Config {
min_age: i32,
}
#[test]
fn test_with_mock_env() {
// Build a mock environment with multiple dependencies
let env = MockEnv::new()
.with(|| Config { min_age: 18 })
.with(|| Database { users: vec![] })
.build();
let ((_, config), db) = env;
assert_eq!(config.min_age, 18);
assert!(db.users.is_empty());
}
}
Building Complex Environments
For more complex setups:
#![allow(unused)]
fn main() {
#[test]
fn test_complex_env() {
let env = MockEnv::new()
.with(|| Config { min_age: 18 })
.with(|| Database::with_test_data())
.with(|| "auth_token_123")
.build();
let (((_, config), db), token) = env;
// Use your mocked environment
let result = process_request(&config, &db, token);
assert_success!(result);
}
}
Assertion Macros
Stillwater provides three assertion macros for testing Validation types:
assert_success!
Assert that a validation succeeds:
#![allow(unused)]
fn main() {
#[test]
fn test_valid_email() {
let result = validate_email("user@example.com");
assert_success!(result);
}
}
This will panic if the validation is a Failure, showing the errors.
assert_failure!
Assert that a validation fails:
#![allow(unused)]
fn main() {
#[test]
fn test_invalid_email() {
let result = validate_email("invalid");
assert_failure!(result);
}
}
This will panic if the validation is a Success.
assert_validation_errors!
Assert that a validation fails with specific errors:
#![allow(unused)]
fn main() {
#[test]
fn test_specific_errors() {
let result = validate_email("invalid");
assert_validation_errors!(
result,
vec!["Email must contain @".to_string()]
);
}
}
This checks both that the validation failed AND that the errors match exactly.
Testing Patterns
Testing Error Accumulation
Validation accumulates all errors:
#![allow(unused)]
fn main() {
#[test]
fn test_accumulates_all_errors() {
let result = validate_user("invalid", 15);
assert_failure!(result);
match result {
Validation::Failure(errors) => {
assert_eq!(errors.len(), 2);
assert!(errors.contains(&"Invalid email".to_string()));
assert!(errors.contains(&"Must be 18+".to_string()));
}
_ => panic!("Expected failure"),
}
}
}
Testing Validation Composition
Test how validations combine:
#![allow(unused)]
fn main() {
#[test]
fn test_validation_and() {
let v1 = validate_email("user@example.com");
let v2 = validate_age(25);
let result = v1.and(v2);
assert_success!(result);
match result {
Validation::Success((email, age)) => {
assert_eq!(email, "user@example.com");
assert_eq!(age, 25);
}
_ => panic!("Expected success"),
}
}
}
Testing with Effects
Test effects using mock environments:
#![allow(unused)]
fn main() {
#[test]
fn test_effect_composition() {
let env = MockEnv::new()
.with(|| Database::with_test_data())
.build();
let (_, db) = env;
let effect = Effect::from(|_: &Database| {
Ok::<i32, String>(42)
});
let result = effect.run(&db);
assert_eq!(result, Ok(42));
}
}
Property-Based Testing
Enable property-based testing with the proptest feature:
[dev-dependencies]
proptest = "1.0"
Stillwater provides Arbitrary instances for Validation:
#![allow(unused)]
fn main() {
#[cfg(feature = "proptest")]
mod proptest_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn test_validation_map_preserves_success(value: i32) {
let val = Validation::<_, Vec<String>>::success(value);
let mapped = val.map(|x| x * 2);
assert_success!(mapped);
}
#[test]
fn test_email_validation(
local in "[a-z]{1,10}",
domain in "[a-z]{1,10}",
tld in "[a-z]{2,5}"
) {
let email = format!("{}@{}.{}", local, domain, tld);
let result = validate_email(&email);
assert_success!(result);
}
}
}
}
Property Testing Patterns
Test invariants that should always hold:
#![allow(unused)]
fn main() {
proptest! {
#[test]
fn test_success_always_is_success(value: i32) {
let val = Validation::<_, Vec<String>>::success(value);
assert!(val.is_success());
}
#[test]
fn test_failure_always_is_failure(errors: Vec<String>) {
prop_assume!(!errors.is_empty());
let val = Validation::<i32, _>::failure(errors);
assert!(val.is_failure());
}
#[test]
fn test_map_preserves_failure(errors: Vec<String>) {
prop_assume!(!errors.is_empty());
let val = Validation::<i32, _>::failure(errors);
let mapped = val.map(|x| x * 2);
assert_failure!(mapped);
}
}
}
Testing Best Practices
1. Use Descriptive Test Names
#![allow(unused)]
fn main() {
#[test]
fn test_email_validation_rejects_missing_at_symbol() {
let result = validate_email("invalid.com");
assert_failure!(result);
}
}
2. Test Both Success and Failure Cases
#![allow(unused)]
fn main() {
#[test]
fn test_age_validation_accepts_adults() {
let result = validate_age(18);
assert_success!(result);
}
#[test]
fn test_age_validation_rejects_minors() {
let result = validate_age(17);
assert_failure!(result);
}
}
3. Test Error Accumulation Explicitly
#![allow(unused)]
fn main() {
#[test]
fn test_validates_all_fields_at_once() {
let result = validate_user("invalid", 15);
match result {
Validation::Failure(errors) => {
assert!(errors.len() > 1, "Should accumulate multiple errors");
}
_ => panic!("Expected validation to fail"),
}
}
}
4. Use MockEnv for Complex Dependencies
#![allow(unused)]
fn main() {
#[test]
fn test_with_realistic_environment() {
let env = MockEnv::new()
.with(|| Config::from_env())
.with(|| Database::with_fixtures())
.with(|| Cache::empty())
.build();
// Test your code with the full environment
}
}
Integration with Test Frameworks
Using with Standard Test Framework
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use stillwater::prelude::*;
#[test]
fn test_my_function() {
let result = my_validation_function();
assert_success!(result);
}
}
}
Using with Tokio Test
For async tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod async_tests {
use super::*;
use stillwater::prelude::*;
#[tokio::test]
async fn test_async_validation() {
let result = async_validate().await;
assert_success!(result);
}
}
}
Complete Example
Here’s a complete example showing all testing utilities:
#![allow(unused)]
fn main() {
use stillwater::prelude::*;
#[derive(Debug, Clone, PartialEq)]
struct User {
email: String,
age: i32,
}
fn validate_email(email: &str) -> Validation<String, Vec<String>> {
if email.contains('@') {
Validation::success(email.to_string())
} else {
Validation::failure(vec!["Invalid email".to_string()])
}
}
fn validate_age(age: i32) -> Validation<i32, Vec<String>> {
if age >= 18 {
Validation::success(age)
} else {
Validation::failure(vec!["Must be 18+".to_string()])
}
}
fn validate_user(email: &str, age: i32) -> Validation<User, Vec<String>> {
Validation::all((validate_email(email), validate_age(age)))
.map(|(email, age)| User { email, age })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_user() {
let result = validate_user("user@example.com", 25);
assert_success!(result);
}
#[test]
fn test_invalid_email() {
let result = validate_user("invalid", 25);
assert_failure!(result);
}
#[test]
fn test_underage() {
let result = validate_user("user@example.com", 15);
assert_failure!(result);
}
#[test]
fn test_multiple_errors() {
let result = validate_user("invalid", 15);
match result {
Validation::Failure(errors) => {
assert_eq!(errors.len(), 2);
}
_ => panic!("Expected failure"),
}
}
}
}
Next Steps
- See examples/validation.rs for more examples
- Check tests/testing_utilities.rs for comprehensive test patterns
- Read Validation Guide for more on validation
- Read Effects Guide for testing effects
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.
Performance Guide
Stillwater claims zero-cost abstractions for validation, effects, and error context. This guide documents how we measure those claims and how to interpret results.
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.
Zero-cost verification
Compile-time checks live in src/effect/combinators/zero_cost_tests.rs (combinator struct sizes). Runtime benchmarks validate that those abstractions do not add unexpected overhead in hot paths.
Practices for zero-cost usage:
- Prefer concrete effect types; call
.boxed()only at collection boundaries (par_all, heterogeneous vectors). - 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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
use stillwater::effect::prelude::*;
}
Pattern 1: Read, Transform, Write
Classic pattern for processing data:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
#[test]
fn test_pure_validation() {
let result = validate_email("user@example.com");
assert!(result.is_success());
let result = validate_email("invalid");
assert!(result.is_failure());
}
}
Pattern 2: Testing Effects with Mock Environment
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
use stillwater::effect::prelude::*;
#[tokio::test]
async fn test_user_not_found() {
let env = MockEnv {
users: HashMap::new(), // Empty
};
let effect = from_fn(|env: &MockEnv| env.fetch_user(999));
let result = effect.execute(&env).await;
assert_eq!(result, Err(Error::NotFound));
}
}
Error Handling Patterns
Pattern 1: Domain-Specific Errors
#![allow(unused)]
fn main() {
#[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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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?
#![allow(unused)]
fn main() {
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: zero-cost by default. Each combinator returns a concrete type (like Map, AndThen) that the compiler can fully inline. No heap allocations occur for effect chains.
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
#![allow(unused)]
fn main() {
// 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 zero-cost 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:
#![allow(unused)]
fn main() {
Effect::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:
#![allow(unused)]
fn main() {
from_async(|env: &AppEnv| async {
env.db.query()
.await
.map_err(|err| MyError::Database(err.to_string()))
})
}
Performance
Is there overhead?
No! The Effect trait is zero-cost by default:
- Each combinator returns a concrete type (like
Map<AndThen<Pure<...>, F>, G>) - The compiler can fully inline the effect chain
- No heap allocations occur
Validation is just an enum with no overhead. Both compile to efficient code identical to hand-written async functions.
When does allocation happen?
Only when you explicitly call .boxed():
- Storing effects in collections
- Recursive effects
- Match arms with different effect types
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! The zero-cost design means you can use Effects in performance-sensitive code. Just avoid .boxed() in the hot path. For tight loops, benchmark to confirm.
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 specs in the specs/ directory for planned features. Current areas under consideration include:
- Saga-style compensation for multi-step workflows
- Serde and framework integration
- Benchmarks and performance validation
- Circuit breaker support
- Additional Result and tuple combinator ergonomics
Is this production-ready?
Yes. Stillwater 1.0 is stable with comprehensive unit, integration, and documentation tests.
Common Issues
“Cannot infer type for Effect”
Specify type parameters explicitly on constructor functions:
#![allow(unused)]
fn main() {
// 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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
// 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 0.10.x to 0.11.0
Overview
Stillwater 0.11.0 introduces a zero-cost Effect API, following the futures crate pattern. This is a breaking change that requires updating your code.
Key Changes
| 0.10.x | 0.11.0 |
|---|---|
Effect<T, E, Env> struct (boxed per combinator) | impl Effect<Output=T, Error=E, Env=Env> trait (zero-cost) |
Effect::pure(x) | pure(x) or pure::<_, E, Env>(x) |
Effect::fail(e) | fail(e) or fail::<T, _, Env>(e) |
Effect::from_fn(f) | from_fn(f) |
| N/A | from_async(f), from_result(r), from_option(o, err) |
| N/A | ask(), asks(f), local(f, effect) |
.run(&env).await | .run(&env).await or .execute(&env).await |
| Always boxed | Zero-cost by default, opt-in .boxed() |
Why the Change?
The old API boxed every combinator, allocating on the heap for each .map(), .and_then(), etc. While this was acceptable for I/O-bound work, it added unnecessary overhead for compute-bound code and prevented certain compiler optimizations.
The new API follows the pattern established by the futures crate:
- Zero-cost by default: Each combinator returns a concrete type, enabling full inlining
- Explicit boxing: Use
.boxed()only when type erasure is needed
Migration Steps
Step 1: Update Imports
#![allow(unused)]
fn main() {
// Before
use stillwater::Effect;
// After - Option A: Use prelude (recommended)
use stillwater::prelude::*;
// or
use stillwater::effect::prelude::*;
// After - Option B: Direct imports
use stillwater::{pure, fail, from_fn, Effect, EffectExt, BoxedEffect};
}
Step 2: Update Return Types
#![allow(unused)]
fn main() {
// Before
fn my_effect() -> Effect<i32, String, ()> {
Effect::pure(42)
}
// After - Option A: Zero-cost (preferred)
fn my_effect() -> impl Effect<Output = i32, Error = String, Env = ()> {
pure(42)
}
// After - Option B: Boxed (when needed)
fn my_effect_boxed() -> BoxedEffect<i32, String, ()> {
pure(42).boxed()
}
// Running effects - both work:
let result = my_effect().run(&()).await; // From Effect trait
let result = my_effect().execute(&()).await; // Convenience method
}
Step 3: Update Constructor Calls
#![allow(unused)]
fn main() {
// Before
Effect::pure(42)
Effect::fail("error")
Effect::from_fn(|env| Ok(env.value))
// After - basic constructors
pure(42)
fail("error")
from_fn(|env| Ok(env.value))
// After - additional constructors available
from_async(|env| async { Ok(value) }) // For async operations
from_result(Ok(42)) // From Result
from_option(Some(42), || "missing") // From Option with error
ask() // Get entire environment
asks(|env| env.config.clone()) // Extract from environment
local(|env| modified_env, inner_effect) // Run with modified env
}
Step 4: Add .boxed() Where Needed
If you’re storing effects in collections, using recursion, or returning different effect types from match arms, add .boxed():
#![allow(unused)]
fn main() {
use stillwater::{pure, BoxedEffect, EffectExt};
// Collections - need same type
let effects: Vec<BoxedEffect<i32, String, ()>> = vec![
pure(1).boxed(),
pure(2).boxed(),
];
// Recursion - need to break infinite type
fn recursive(n: i32) -> BoxedEffect<i32, String, ()> {
if n <= 0 {
pure(0).boxed()
} else {
pure(n)
.and_then(move |_| recursive(n - 1))
.boxed()
}
}
// Match arms - need same type
fn conditional(flag: bool) -> BoxedEffect<i32, String, ()> {
if flag {
pure(1).boxed()
} else {
pure(2).map(|x| x * 2).boxed()
}
}
}
Using the Compatibility Module
For gradual migration, use the compatibility module:
#![allow(unused)]
fn main() {
#[allow(deprecated)]
use stillwater::LegacyEffect; // Type alias for BoxedEffect
// Old-style code (with deprecation warnings)
fn my_effect() -> LegacyEffect<i32, String, ()> {
stillwater::pure(42).boxed()
}
}
The LegacyEffect type alias and LegacyConstructors trait are deprecated. Migrate to the new API as soon as possible.
Common Issues
“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
“cannot infer type”
Add type annotations to constructor functions:
#![allow(unused)]
fn main() {
pure::<_, String, ()>(42) // Specify error and env types
}
“the trait bound is not satisfied”
Make sure your closures are Send:
#![allow(unused)]
fn main() {
// Before (might not be Send)
.map(|x| x + some_local_ref)
// After (capture by value)
let value = *some_local_ref;
.map(move |x| x + value)
}
“recursive type has infinite size”
You need to use .boxed() for recursive effects:
#![allow(unused)]
fn main() {
fn countdown(n: i32) -> BoxedEffect<i32, String, ()> {
if n <= 0 {
pure(0).boxed()
} else {
pure(n)
.and_then(move |x| countdown(x - 1).map(move |sum| x + sum))
.boxed()
}
}
}
Before/After Examples
Simple Effect Chain
#![allow(unused)]
fn main() {
// Before
fn calculate() -> Effect<i32, String, AppEnv> {
Effect::pure(42)
.map(|x| x * 2)
.and_then(|x| Effect::pure(x + 10))
}
// After
fn calculate() -> impl Effect<Output = i32, Error = String, Env = AppEnv> {
pure(42)
.map(|x| x * 2)
.and_then(|x| pure(x + 10))
}
}
Effect with Environment
#![allow(unused)]
fn main() {
// Before
fn fetch_config() -> Effect<String, AppError, AppEnv> {
Effect::from_fn(|env: &AppEnv| {
Ok(env.config.api_key.clone())
})
}
// After
fn fetch_config() -> impl Effect<Output = String, Error = AppError, Env = AppEnv> {
asks(|env: &AppEnv| env.config.api_key.clone())
}
}
Async Effect
#![allow(unused)]
fn main() {
// Before
fn fetch_user(id: u64) -> Effect<User, DbError, AppEnv> {
Effect::from_async(|env: &AppEnv| {
let db = env.db.clone();
async move {
db.find_user(id).await
}
})
}
// After
fn fetch_user(id: u64) -> impl Effect<Output = User, Error = DbError, Env = AppEnv> {
from_async(move |env: &AppEnv| {
let db = env.db.clone();
async move {
db.find_user(id).await
}
})
}
}
Parallel Effects
#![allow(unused)]
fn main() {
use stillwater::effect::prelude::*;
// Heterogeneous parallel (zero-cost) - par2, par3, par4
let effect = par2(
pure::<_, String, ()>(1),
pure::<_, String, ()>("hello".to_string()),
);
let (num, text) = effect.run(&()).await?;
// Homogeneous parallel (requires boxing) - par_all, race
let effects: Vec<BoxedEffect<i32, String, ()>> = vec![
pure(1).boxed(),
pure(2).boxed(),
pure(3).boxed(),
];
let results = par_all(effects, &()).await?;
}
Performance Implications
The new zero-cost API eliminates heap allocations for effect chains:
| Scenario | 0.10.x | 0.11.0 |
|---|---|---|
| 10-combinator chain | 10 Box allocations | 0 allocations |
| Effect stored in collection | 1 Box per effect | 1 Box per effect (same) |
| Recursive effect | Multiple boxes | 1 Box per recursion level (same) |
For I/O-bound applications, this difference is negligible. For compute-bound code or code running in tight loops, the new API can provide meaningful performance improvements.
Getting Help
- Check the examples/ directory for working code
- Read the User Guide for comprehensive tutorials
- See FAQ.md for common questions
- Open an issue on GitHub
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
#[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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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 Mock Dependencies
Before (Traditional Rust - complex mocking):
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use mockall::{automock, predicate::*};
#[automock]
trait DatabaseTrait {
async fn fetch_user(&self, id: u64) -> Result<User, Error>;
}
#[tokio::test]
async fn test_process_user() {
let mut mock_db = MockDatabaseTrait::new();
mock_db.expect_fetch_user()
.with(eq(123))
.times(1)
.returning(|_| Ok(User { id: 123, name: "Test".into() }));
let result = process_user(&mock_db, 123).await;
assert!(result.is_ok());
}
}
}
After (With Stillwater - 12 lines, simpler):
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use stillwater::effect::prelude::*;
use std::sync::Arc;
#[tokio::test]
async fn test_process_user() {
let test_env = AppEnv {
db: Arc::new(InMemoryDb::with_user(User { id: 123, name: "Test".into() })),
cache: Arc::new(NoOpCache),
email: Arc::new(RecordingEmailService::new()),
};
let result = process_user(123).run(&test_env).await;
assert!(result.is_ok());
}
}
}
Key Improvements:
- No mocking framework required
- Test environment is just data
- Easy to reuse test fixtures across tests
- Behavior verification via recording implementations
Before/After Examples: Async Composition
Retry with Exponential Backoff
Problem: Retry a flaky network call with exponential backoff.
Before (Traditional Rust - 35 lines):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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):
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
// 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
#![allow(unused)]
fn main() {
// 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
Validation::all() - Tuples vs Alternatives
Current Design (Tuples)
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
// 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)
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
// 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)
#![allow(unused)]
fn main() {
// 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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
// 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:
#![allow(unused)]
fn main() {
// 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
#![allow(unused)]
fn main() {
// 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:
#![allow(unused)]
fn main() {
// 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
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
#![allow(unused)]
fn main() {
// 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
#![allow(unused)]
fn main() {
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)
#![allow(unused)]
fn main() {
// 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)
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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)
#![allow(unused)]
fn main() {
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)
#![allow(unused)]
fn main() {
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)
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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)
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
// 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.
IO API Analysis - read and write
Status
Accepted and implemented.
The current IO helper API is:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
#[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:
#![allow(unused)]
fn main() {
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.
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
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
#![allow(unused)]
fn main() {
IO::read(|db: &Database| db.find_user(id))
IO::write(|db: &Database| db.insert_user(user))
}
Cache
#![allow(unused)]
fn main() {
IO::read(|cache: &Cache| cache.get(key))
IO::write(|cache: &Cache| cache.set(key, value))
}
Logger
#![allow(unused)]
fn main() {
IO::read(|logger: &Logger| logger.level())
IO::write(|logger: &Logger| logger.info("user registered"))
}
File System Abstraction
#![allow(unused)]
fn main() {
IO::read(|fs: &FileSystem| fs.read_to_string(path))
IO::write(|fs: &FileSystem| fs.write_string(path, contents))
}
Async HTTP Client
#![allow(unused)]
fn main() {
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:
#![allow(unused)]
fn main() {
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.
Improving Our Design & Planning Process
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:
#![allow(unused)]
fn main() {
// 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
#![allow(unused)]
fn main() {
// 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
#![allow(unused)]
fn main() {
// 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:
```rust
// 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<ValidationError>> {
Validation::all((
validate_email(&input.email),
validate_age(input.age),
))
.into_result()
}
Benefits: Immediate value, low risk, no refactoring needed
Stage 2: Effect Separation (Week 2-3)
Extract pure business logic in critical paths:
#![allow(unused)]
fn main() {
// 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:
#![allow(unused)]
fn main() {
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
```markdown
## 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.