| Crates.io | stillwater |
| lib.rs | stillwater |
| version | 1.0.0 |
| created_at | 2025-11-22 18:34:34.488037+00 |
| updated_at | 2025-12-21 07:29:33.330831+00 |
| description | Pragmatic effect composition and validation for Rust - pure core, imperative shell |
| homepage | |
| repository | https://github.com/iepathos/stillwater |
| max_upload_size | |
| id | 1945560 |
| size | 2,654,727 |
A Rust library for pragmatic effect composition and validation, emphasizing the pure core, imperative shell pattern.
Stillwater embodies a simple idea:
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 data
use 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 config
use 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, 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
Effect::retry(|| fetch_user(id), api_policy.clone());
Effect::retry(|| save_order(order), api_policy.clone());
// Conditional retry: only retry transient failures
Effect::retry_if(
|| api_call(),
api_policy,
|err| matches!(err, ApiError::Timeout | ApiError::ServerError(_))
);
// Observability: hook into retry events for logging/metrics
Effect::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-circuitingand, or, not, all_of, any_of
len_between, contains, starts_with, all_chars, etc.between, gt, lt, positive, negative, etc.all, any, has_len, is_empty, etc.Validation via ensure() and validate()ensure family (replaces verbose and_then boilerplate)
Effect: .ensure(), .ensure_with(), .ensure_pred(), .unless(), .filter_or()Validation: .ensure(), .ensure_fn(), .ensure_with(), .ensure_fn_with(), .unless(), .filter_or()Refined<T, P> wrapper guarantees value satisfies predicate P at compile timePositive, NonNegative, Negative, NonZero, InRange<MIN, MAX>NonEmpty, Trimmed, MaxLength<N>, MinLength<N>NonEmpty, MaxSize<N>, MinSize<N> for Vec<T>And, Or, Not for composing complex predicatesNonEmptyString, PositiveI32, Port, Percentage, etc.validate(), validate_vec(), with_field() for error accumulationNonEmptyVec<T> - Type-safe non-empty collections with guaranteed head elementEffect trait - Zero-cost effect composition following the futures crate pattern
.boxed() when type erasure is neededimpl Effect for optimal performancezip(), zip_with() methods for pairwise combinationzip3() through zip8() for flat tuple resultspar2(), par3(), par4() for heterogeneous effectspar_all(), par_try_all(), race(), par_all_limit() for homogeneous collectionsrecover(), recover_with(), recover_some() for conditional error recoveryfallback(), fallback_to() for default values and alternative effectsbracket(), bracket2(), bracket3() for single and multiple resources with LIFO cleanupbracket_full() returns BracketError with explicit error handling for all failure modesacquiring() builder for fluent multi-resource management with with_flat2/3/4FileRes, DbRes, LockRes, TxRes, SocketRes (or define custom)ResourceEffect trait with Acquires/Releases associated types.acquires::<R>(), .releases::<R>(), .neutral()bracket::<R>() builder for ergonomic resource brackets (single type parameter)resource_bracket function for guaranteed resource-neutral operationsassert_resource_neutral for compile-time leak detectiontraverse() and sequence() for both validations and effectsask(), asks(), and local()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 operationsMonoid: Vec, Sum, Product, custom typesSemigroup trait - Associative combination of values
HashMap, HashSet, BTreeMap, BTreeSet, OptionFirst, Last, Intersection for alternative semanticsMonoid trait - Identity elements for powerful composition patternsMockEnv builder for composing test environmentsassert_success!, assert_failure!, assert_validation_errors!TestEffect wrapper for deterministic effect testingproptest feature for property-based testingtracing cratefutures crate pattern: concrete types, no allocation by default? operator - Integrates with Rust idiomsuse 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?;
Version 0.11.0 introduces 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():
Vec<BoxedEffect<...>>)When NOT to use .boxed():
impl Effect)vs. frunk:
vs. monadic:
rdrdo! { ... })futures crate pattern)vs. hand-rolling:
? operator via Try traitfutures)Add to your Cargo.toml:
[dependencies]
stillwater = "1.0"
# Optional: async support
stillwater = { version = "0.11", features = ["async"] }
# Optional: tracing integration
stillwater = { version = "0.11", features = ["tracing"] }
# Optional: jitter for retry policies
stillwater = { version = "0.11", features = ["jitter"] }
# Optional: property-based testing
stillwater = { version = "0.11", features = ["proptest"] }
# Multiple features
stillwater = { version = "0.11", 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: 0.11.0 - Production Ready
This library is stable and ready for use. The 0.x version indicates the API may evolve based on community feedback.
Version 0.11.0 introduces breaking changes with the zero-cost effect system. See MIGRATION.md for detailed upgrade instructions.
Key changes:
// Before (0.10.x)
Effect::pure(x)
Effect::fail(e)
Effect::from_fn(f)
// After (0.11.0)
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
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:
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. |
All libraries emphasize:
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."