A Rust library for pragmatic effect composition and validation, emphasizing the pure core, imperative shell pattern.
Stillwater embodies a simple idea:
- Pure functions (unchanging, referentially transparent)
- Effects (flowing, performing I/O)
Keep your business logic pure and calm like still water. Let effects flow at the boundaries.
use stillwater::Validation;
// Standard Result: stops at first error
let email = validate_email(input)?; // Stops here
let age = validate_age(input)?; // Never reached if email fails
// Stillwater: accumulates all errors
let user = Validation::all((
validate_email(input),
validate_age(input),
validate_name(input),
))?;
// Returns: Err(vec![EmailError, AgeError, NameError])use stillwater::validation::homogeneous::validate_homogeneous;
use std::mem::discriminant;
#[derive(Clone, Debug, PartialEq)]
enum Aggregate {
Sum(f64), // Can combine Sum + Sum
Count(usize), // Can combine Count + Count
// But Sum + Count is a type error!
}
// Without validation: runtime panic
let mixed = vec![Aggregate::Count(5), Aggregate::Sum(10.0)];
// items.into_iter().reduce(|a, b| a.combine(b)) // PANIC!
// With validation: type-safe error accumulation
let result = validate_homogeneous(
mixed,
|a| discriminant(a),
|idx, _, _| format!("Type mismatch at index {}", idx),
);
match result {
Validation::Success(items) => {
// Safe to combine - all same type!
let total = items.into_iter().reduce(|a, b| a.combine(b));
}
Validation::Failure(errors) => {
// All mismatches reported: ["Type mismatch at index 1"]
}
}use stillwater::prelude::*;
// Pure business logic (no DB, easy to test)
fn calculate_discount(customer: &Customer, total: Money) -> Money {
match customer.tier {
Tier::Gold => total * 0.15,
_ => total * 0.05,
}
}
// Effects at boundaries (mockable) - zero-cost by default
fn process_order(id: OrderId) -> impl Effect<Output = Invoice, Error = AppError, Env = AppEnv> {
from_fn(move |env: &AppEnv| env.db.fetch_order(id)) // I/O
.and_then(|order| {
let total = calculate_total(&order); // Pure!
from_fn(move |env: &AppEnv| env.db.fetch_customer(order.customer_id))
.map(move |customer| (order, customer, total))
})
.map(|(order, customer, total)| {
let discount = calculate_discount(&customer, total); // Pure!
create_invoice(order.id, total - discount) // Pure!
})
.and_then(|invoice| from_fn(move |env: &AppEnv| env.db.save(invoice))) // I/O
}
// Test with mock environment
#[tokio::test]
async fn test_with_mock_db() {
let env = MockEnv::new();
let result = process_order(id).run(&env).await?;
assert_eq!(result.total, expected);
}use stillwater::prelude::*;
// Combine independent effects - neither depends on the other
fn load_user_profile(id: UserId) -> impl Effect<Output = UserProfile, Error = AppError, Env = AppEnv> {
fetch_user(id)
.zip(fetch_settings(id))
.zip(fetch_preferences(id))
.map(|((user, settings), prefs)| UserProfile { user, settings, prefs })
}
// Or use zip3 for cleaner flat tuples
fn load_user_profile_v2(id: UserId) -> impl Effect<Output = UserProfile, Error = AppError, Env = AppEnv> {
zip3(
fetch_user(id),
fetch_settings(id),
fetch_preferences(id),
)
.map(|(user, settings, prefs)| UserProfile { user, settings, prefs })
}
// Combine results with a function directly using zip_with
let effect = fetch_price(item_id)
.zip_with(fetch_quantity(item_id), |price, qty| price * qty);use stillwater::prelude::*;
fetch_user(id)
.context("Loading user profile")
.and_then(|user| process_data(user))
.context("Processing user data")
.run(&env).await?;
// Error output:
// Error: UserNotFound(12345)
// -> Loading user profile
// -> Processing user datause stillwater::prelude::*;
#[derive(Clone)]
struct Config {
timeout: u64,
retries: u32,
}
// Functions don't need explicit config parameters
fn fetch_data() -> impl Effect<Output = String, Error = String, Env = Config> {
// Ask for config when needed
asks(|cfg: &Config| format!("Fetching with timeout={}", cfg.timeout))
}
fn fetch_with_extended_timeout() -> impl Effect<Output = String, Error = String, Env = Config> {
// Temporarily modify environment for specific operations
local(
|cfg: &Config| Config { timeout: cfg.timeout * 2, ..*cfg },
fetch_data()
)
}
let config = Config { timeout: 30, retries: 3 };
let result = fetch_with_extended_timeout().run(&config).await?;
// Uses timeout=60 without changing the original configuse stillwater::effect::bracket::{bracket, bracket2, acquiring, BracketError};
use stillwater::prelude::*;
// Single resource with guaranteed cleanup
let result = bracket(
open_connection(), // Acquire
|conn| async move { conn.close().await }, // Release (always runs)
|conn| fetch_user(conn, user_id), // Use
).run(&env).await;
// Multiple resources with LIFO cleanup order
let result = bracket2(
open_database(),
open_file(path),
|db| async move { db.close().await }, // Released second
|file| async move { file.close().await }, // Released first (LIFO)
|db, file| process(db, file),
).run(&env).await;
// Fluent builder for ergonomic multi-resource management
let result = acquiring(open_conn(), |c| async move { c.close().await })
.and(open_file(), |f| async move { f.close().await })
.and(acquire_lock(), |l| async move { l.release().await })
.with_flat3(|conn, file, lock| do_work(conn, file, lock))
.run(&env)
.await;
// Explicit error handling with BracketError
let result = bracket_full(acquire, release, use_fn).run(&env).await;
match result {
Ok(value) => println!("Success"),
Err(BracketError::AcquireError(e)) => println!("Acquire failed"),
Err(BracketError::UseError(e)) => println!("Use failed, cleanup succeeded"),
Err(BracketError::CleanupError(e)) => println!("Use succeeded, cleanup failed"),
Err(BracketError::Both { use_error, cleanup_error }) => println!("Both failed"),
}use stillwater::effect::retry::{retry, retry_if, retry_with_hooks};
use stillwater::RetryPolicy;
use std::time::Duration;
// Stillwater: Policy as Data
// Define retry policies as pure, testable values
let api_policy = RetryPolicy::exponential(Duration::from_millis(100))
.with_max_retries(5)
.with_max_delay(Duration::from_secs(2))
.with_jitter(0.25);
// Test the policy without any I/O
assert_eq!(api_policy.delay_for_attempt(0), Some(Duration::from_millis(100)));
assert_eq!(api_policy.delay_for_attempt(1), Some(Duration::from_millis(200)));
assert_eq!(api_policy.delay_for_attempt(2), Some(Duration::from_millis(400)));
// Reuse the same policy across different effects
retry(|| fetch_user(id), api_policy.clone());
retry(|| save_order(order), api_policy.clone());
// Conditional retry: only retry transient failures
retry_if(
|| api_call(),
api_policy,
|err| matches!(err, ApiError::Timeout | ApiError::ServerError(_))
);
// Observability: hook into retry events for logging/metrics
retry_with_hooks(
|| api_call(),
policy,
|event| log::warn!(
"Attempt {} failed: {}, retrying in {:?}",
event.attempt, event.error, event.next_delay
)
);use stillwater::effect::writer::prelude::*;
use stillwater::effect::prelude::*;
// Without Writer: manually threading state
fn process(x: i32, logs: &mut Vec<String>) -> Result<i32, Error> {
logs.push("Starting".into());
let y = step1(x, logs)?;
logs.push(format!("Step 1: {}", y));
Ok(y)
}
// With Writer Effect: automatic accumulation
fn process_with_writer(x: i32) -> impl WriterEffect<
Output = i32, Error = String, Env = (), Writes = Vec<String>
> {
tell_one::<_, String, ()>("Starting".to_string())
.and_then(move |_| into_writer::<_, _, Vec<String>>(pure::<_, String, ()>(x * 2)))
.tap_tell(|y| vec![format!("Step 1: {}", y)])
}
// Run and get both result and accumulated logs
let (result, logs) = process_with_writer(21).run_writer(&()).await;
assert_eq!(result, Ok(42));
assert_eq!(logs, vec!["Starting", "Step 1: 42"]);
// Use any Monoid for accumulation - not just Vec!
use stillwater::monoid::Sum;
// Count operations
let effect = tell::<Sum<u32>, String, ()>(Sum(1))
.and_then(|_| tell(Sum(1)))
.and_then(|_| tell(Sum(1)));
let (_, Sum(count)) = effect.run_writer(&()).await;
assert_eq!(count, 3);use stillwater::effect::resource::*;
// Mark effects with resource acquisition at the TYPE level
fn open_file(path: &str) -> impl ResourceEffect<Acquires = Has<FileRes>> {
pure(FileHandle::new(path)).acquires::<FileRes>()
}
fn close_file(handle: FileHandle) -> impl ResourceEffect<Releases = Has<FileRes>> {
pure(()).releases::<FileRes>()
}
// The bracket pattern guarantees resource neutrality
// Use the builder for ergonomic syntax (single type parameter)
fn read_file_safe(path: &str) -> impl ResourceEffect<Acquires = Empty, Releases = Empty> {
bracket::<FileRes>()
.acquire(open_file(path))
.release(|h| async move { close_file(h).run(&()).await })
.use_fn(|h| read_contents(h))
}
// Transaction protocols enforced at compile time
fn begin_tx() -> impl ResourceEffect<Acquires = Has<TxRes>> { /* ... */ }
fn commit(tx: Tx) -> impl ResourceEffect<Releases = Has<TxRes>> { /* ... */ }
// This function MUST be resource-neutral or it won't compile
fn transfer_funds() -> impl ResourceEffect<Acquires = Empty, Releases = Empty> {
bracket::<TxRes>()
.acquire(begin_tx())
.release(|tx| async move { commit(tx).run(&()).await })
.use_fn(|tx| execute_queries(tx))
}
// Zero runtime overhead - all tracking is compile-time only!Validation<T, E>- Accumulate all errors instead of short-circuiting- Predicate combinators - Composable validation logic with
and,or,not,all_of,any_of- String predicates:
len_between,contains,starts_with,all_chars, etc. - Number predicates:
between,gt,lt,positive,negative, etc. - Collection predicates:
all,any,has_len,is_empty, etc. - Seamless integration with
Validationviaensure()andvalidate()
- String predicates:
- Validation combinators - Declarative validation with
ensurefamily (replaces verboseand_thenboilerplate)Effect:.ensure(),.ensure_with(),.ensure_pred(),.unless(),.filter_or()Validation:.ensure(),.ensure_fn(),.ensure_with(),.ensure_fn_with(),.unless(),.filter_or()- Zero-cost: compiles to concrete types with no heap allocation
- Reduces 12-line validation blocks to single-line predicates
- Refined types - "Parse, don't validate" pattern for type-level invariants
Refined<T, P>wrapper guarantees value satisfies predicate P at compile time- Numeric predicates:
Positive,NonNegative,Negative,NonZero,InRange<MIN, MAX> - String predicates:
NonEmpty,Trimmed,MaxLength<N>,MinLength<N> - Collection predicates:
NonEmpty,MaxSize<N>,MinSize<N>forVec<T> - Combinators:
And,Or,Notfor composing complex predicates - Type aliases:
NonEmptyString,PositiveI32,Port,Percentage, etc. - Validation integration:
validate(),validate_vec(),with_field()for error accumulation - Zero-cost: same memory layout as inner type, predicate is compile-time only
NonEmptyVec<T>- Type-safe non-empty collections with guaranteed head elementEffecttrait - Zero-cost effect composition following thefuturescrate pattern- Zero heap allocations by default
- Explicit
.boxed()when type erasure is needed - Returns
impl Effectfor optimal performance
- Zip combinators - Combine independent effects into tuples
zip(),zip_with()methods for pairwise combinationzip3()throughzip8()for flat tuple results- Zero-cost: all combinators return concrete types
- Parallel effect execution - Run independent effects concurrently
- Zero-cost:
par2(),par3(),par4()for heterogeneous effects - Boxed:
par_all(),par_try_all(),race(),par_all_limit()for homogeneous collections
- Zero-cost:
- Retry and resilience - Policy-as-data approach with exponential, linear, constant, and Fibonacci backoff. Includes jitter, conditional retry, retry hooks, and timeout support
- Error recovery - Selective error handling with predicate-based recovery
recover(),recover_with(),recover_some()for conditional error recoveryfallback(),fallback_to()for default values and alternative effects- Predicate composition for sophisticated recovery strategies
- Real-world patterns: multi-tier caching, graceful degradation, API fallback
- Resource management - Comprehensive bracket pattern for safe acquire/use/release
bracket(),bracket2(),bracket3()for single and multiple resources with LIFO cleanupbracket_full()returnsBracketErrorwith explicit error handling for all failure modesacquiring()builder for fluent multi-resource management withwith_flat2/3/4- Guaranteed cleanup even on errors, partial acquisition rollback
- Compile-time resource tracking - Type-level resource safety with zero runtime overhead
- Resource markers:
FileRes,DbRes,LockRes,TxRes,SocketRes(or define custom) ResourceEffecttrait withAcquires/Releasesassociated types- Extension methods:
.acquires::<R>(),.releases::<R>(),.neutral() bracket::<R>()builder for ergonomic resource brackets (single type parameter)resource_bracketfunction for guaranteed resource-neutral operationsassert_resource_neutralfor compile-time leak detection
- Resource markers:
- Traverse and sequence - Transform collections with
traverse()andsequence()for both validations and effects - Reader pattern helpers - Clean dependency injection with
ask(),asks(), andlocal() - Writer Effect - Accumulate logs, metrics, or audit trails alongside computation
tell(),tell_one()for emitting values to accumulatortap_tell()for logging derived values after successcensor()for filtering/transforming accumulated writeslisten(),pass()for introspecting and controlling writestraverse_writer(),fold_writer()for collection operations- Works with any
Monoid:Vec,Sum,Product, custom types
Semigrouptrait - Associative combination of values- Extended implementations for
HashMap,HashSet,BTreeMap,BTreeSet,Option - Wrapper types:
First,Last,Intersectionfor alternative semantics
- Extended implementations for
Monoidtrait - Identity elements for powerful composition patterns- Testing utilities - Ergonomic test helpers
MockEnvbuilder for composing test environments- Assertion macros:
assert_success!,assert_failure!,assert_validation_errors! TestEffectwrapper for deterministic effect testing- Optional
proptestfeature for property-based testing
- Context chaining - Never lose error context
- Tracing integration - Instrument effects with semantic spans using the standard
tracingcrate - Zero-cost abstractions - Follows
futurescrate pattern: concrete types, no allocation by default - Works with
?operator - Integrates with Rust idioms - No heavy macros - Clear types, obvious behavior
use stillwater::prelude::*;
// 1. Validation with error accumulation
fn validate_user(input: UserInput) -> Validation<User, Vec<Error>> {
Validation::all((
validate_email(&input.email),
validate_age(input.age),
validate_name(&input.name),
))
.map(|(email, age, name)| User { email, age, name })
}
// 2. Effect composition (zero-cost by default)
fn create_user(input: UserInput) -> impl Effect<Output = User, Error = AppError, Env = AppEnv> {
// Validate (pure, accumulates errors)
from_validation(validate_user(input).map_err(AppError::Validation))
// Check if exists (I/O)
.and_then(|user| {
from_fn(move |env: &AppEnv| env.db.find_by_email(&user.email))
.and_then(move |existing| {
if existing.is_some() {
fail(AppError::EmailExists)
} else {
pure(user)
}
})
})
// Save user (I/O)
.and_then(|user| {
from_fn(move |env: &AppEnv| env.db.insert_user(&user))
.map(move |_| user)
})
.context("Creating new user")
}
// 3. Run at application boundary
let env = AppEnv { db, cache, logger };
let result = create_user(input).run(&env).await?;Stillwater's current effect API uses a zero-cost effect system following the futures crate pattern:
// Free-standing constructors (not methods)
let effect = pure(42); // Not Effect::pure(42)
let effect = fail("error"); // Not Effect::fail("error")
let effect = from_fn(|env| Ok(env.value)); // Not Effect::from_fn(...)
// Chain combinators - each returns a concrete type, zero allocations
let result = pure(1)
.map(|x| x + 1)
.and_then(|x| pure(x * 2))
.map(|x| x.to_string());
// Use .boxed() when you need type erasure
fn dynamic_effect(flag: bool) -> BoxedEffect<i32, String, ()> {
if flag {
pure(1).boxed()
} else {
pure(2).boxed()
}
}
// Collections of effects require boxing
let effects: Vec<BoxedEffect<i32, String, Env>> = vec![
effect1.boxed(),
effect2.boxed(),
];
let results = par_all(effects, &env).await;When to use .boxed():
- Storing effects in collections (
Vec<BoxedEffect<...>>) - Returning different effect types from branches
- Recursive effect definitions
- Dynamic dispatch scenarios
When NOT to use .boxed():
- Simple linear chains (use
impl Effect) - Fixed combinator sequences
- Performance-critical paths
vs. frunk:
- Focused on practical use cases, not type-level programming
- Better documentation and examples
- Effect composition, not just validation
vs. monadic:
- No awkward macro syntax (
rdrdo! { ... }) - Zero-cost by default (follows
futurescrate pattern) - Idiomatic Rust, not Haskell port
vs. hand-rolling:
- Validation accumulation built-in
- Error context handling
- Testability patterns established
- Composable, reusable
- No attempt at full monad abstraction (impossible without HKTs)
- Works with
?operator viaTrytrait - Zero-cost via concrete types and monomorphization (like
futures) - Integrates with async/await
- Borrows checker friendly
- Clear error messages
Add to your Cargo.toml:
[dependencies]
stillwater = "1.0"
# Optional: async support
stillwater = { version = "1.0", features = ["async"] }
# Optional: tracing integration
stillwater = { version = "1.0", features = ["tracing"] }
# Optional: jitter for retry policies
stillwater = { version = "1.0", features = ["jitter"] }
# Optional: property-based testing
stillwater = { version = "1.0", features = ["proptest"] }
# Multiple features
stillwater = { version = "1.0", features = ["async", "tracing", "jitter"] }Run any example with cargo run --example <name>:
| Example | Demonstrates |
|---|---|
| predicates | Composable predicate combinators for validation logic |
| form_validation | Validation error accumulation |
| homogeneous_validation | Type-safe validation for discriminated unions before combining |
| nonempty | NonEmptyVec type for guaranteed non-empty collections |
| user_registration | Effect composition and I/O separation |
| error_context | Error trails for debugging |
| data_pipeline | Real-world ETL pipeline |
| testing_patterns | Testing pure vs effectful code |
| reader_pattern | Reader pattern with ask(), asks(), and local() |
| writer_logging | Writer Effect for accumulating logs, metrics, and audit trails |
| validation | Validation type and error accumulation patterns |
| effects | Effect type and composition patterns |
| parallel_effects | Parallel execution with par_all, race, and par_all_limit |
| recover_patterns | Error recovery with recover, recover_with, recover_some, fallback patterns |
| retry_patterns | Retry policies, backoff strategies, timeouts, and resilience patterns |
| io_patterns | IO module helpers for reading/writing |
| pipeline | Data transformation pipelines |
| traverse | Traverse and sequence for collections of validations and effects |
| monoid | Monoid and Semigroup traits for composition |
| extended_semigroup | Semigroup for HashMap, HashSet, Option, and wrapper types |
| tracing_demo | Tracing integration with semantic spans and context |
| boxing_decisions | When to use .boxed() vs zero-cost effects |
| resource_scopes | Bracket pattern for safe resource management with guaranteed cleanup |
| resource_tracking | Compile-time resource tracking with type-level safety |
| refined | Refined types for "parse, don't validate" pattern with type-level invariants |
See examples/ directory for full code.
Status: 1.0.1 - Production Ready
- Comprehensive unit and documentation test coverage
- 27 runnable examples
- Zero clippy warnings
- Full async support
- CI/CD pipeline with security audits
This library is stable and ready for use.
The zero-cost effect API was introduced in 0.11.0 and remains the current API in 1.x. See MIGRATION.md for detailed upgrade instructions from the older boxed effect API.
Key changes:
// Before (0.10.x)
Effect::pure(x)
Effect::fail(e)
Effect::from_fn(f)
// After (current API)
pure(x)
fail(e)
from_fn(f)
// Return types changed
fn old() -> Effect<T, E, Env> { ... } // Boxed by default
fn new() -> impl Effect<...> { ... } // Zero-cost by default
fn boxed() -> BoxedEffect<T, E, Env> { ... } // Explicit boxing- Documentation Site - Published guide and reference
- User Guide - Comprehensive tutorials
- API Docs - Full API reference
- FAQ - Common questions
- Design - Architecture and decisions
- Philosophy - Core principles
- Patterns - Common patterns and recipes
- Comparison - vs other libraries
- Migration Guide - Upgrading from the pre-0.11 boxed effect API
Already using Result everywhere? No problem! Stillwater integrates seamlessly:
// Your existing code works as-is
fn validate_email(email: &str) -> Result<Email, Error> {
// ...
}
// Upgrade to accumulation when you need it
fn validate_form(input: FormInput) -> Validation<Form, Vec<Error>> {
Validation::all((
Validation::from_result(validate_email(&input.email)),
Validation::from_result(validate_age(input.age)),
))
}
// Convert back to Result when needed
let result: Result<Form, Vec<Error>> = validation.into_result();Start small, adopt progressively. Use Validation only where you need error accumulation.
Contributions welcome! This is a young library with room to grow:
- Bug reports and feature requests via issues
- Documentation improvements
- More examples and use cases
- API feedback and design discussions
Before submitting PRs, please open an issue to discuss the change.
Stillwater is part of a family of libraries that share the same functional programming philosophy:
| Library | Description |
|---|---|
| premortem | Configuration validation that finds all errors before your app runs. Multi-source loading with error accumulation and value origin tracing. |
| postmortem | Validation library that accumulates all errors with precise JSON path tracking. Composable schemas, cross-field validation, and effect integration. |
| mindset | Zero-cost, effect-based state machines. Pure guards for validation, explicit actions for side effects, environment pattern for testability. |
| stilltypes | Domain-specific refined types built on stillwater. Pre-built predicates for emails, URLs, usernames, currencies, and more. |
All libraries emphasize:
- Error accumulation over short-circuiting
- Pure core, effects at the boundaries
- Zero-cost abstractions
- Testability through dependency injection
MIT © Glen Baker iepathos@gmail.com
"Like a still pond with water flowing through it, stillwater keeps your pure business logic calm and testable while effects flow at the boundaries."