I’m proud to announce the release of stillwater 1.0, a production-ready Rust library for pragmatic effect composition and validation.
What is Stillwater?#
Stillwater implements the pure core, imperative shell pattern for Rust. The name is a mental model: like a still pond with streams flowing through it, your business logic stays calm and predictable at the center while effects (I/O, side effects) flow at the boundaries.
Still Waters
╱ ╲
Pure Logic Effects
↓ ↓
Unchanging Flowing
Predictable Performing I/O
Testable At boundaries
This isn’t Haskell-in-Rust. It’s an attempt at better Rust, leveraging pragmatic functional patterns where they make code more testable and maintainable, while respecting Rust’s ownership model and idioms.
What Are Effects?#
If you’re coming from OOP, “effects” might be unfamiliar. The concept is simple:
Typical Rust functions execute immediately:
fn add(a: i32, b: i32) -> i32 {
a + b // eager evaluation, happens NOW, returns result
}
An effect is a description of what to do, executed later:
fn fetch_user(id: UserId) -> impl Effect<Output = User, Error = DbError, Env = AppEnv> {
asks(|env| env.db.get_user(id)) // describes the operation, deferred evaluation
}
// Nothing happens yet - we just have a description
let effect = fetch_user(42);
// NOW it executes
let user = effect.run(&env).await;
This separation lets you:
- Compose descriptions before running them
- Test logic without executing I/O
- Control when and where side effects actually happen
If you’ve used async Rust, you already know this pattern - async fn returns a Future (a description), which runs when you .await it. Effects are the same idea, extended with environment access, typed errors, and composition tools.
The Core Problem#
Most code mixes business logic with I/O:
fn process_user(id: UserId, db: &Database) -> Result<User, Error> {
let user = db.fetch_user(id)?; // I/O
if user.age < 18 { // Logic
return Err(Error::TooYoung);
}
let discount = if user.premium { 0.15 } else { 0.05 }; // Logic
db.save_user(&user)?; // I/O
Ok(user)
}
Testing this requires mocking the database. Reasoning about it requires mentally separating what transforms data from what performs I/O. Reusing the discount logic means extracting it anyway.
Stillwater separates these concerns by design, using multiple features working together:
use stillwater::prelude::*;
use stillwater::refined::{Refined, NonEmpty, InRange};
use stillwater::predicate::*;
// Refined types - validity proven at compile time
type Username = Refined<String, And<NonEmpty, MaxLength<20>>>;
type Age = Refined<u8, InRange<18, 120>>;
type Email = Refined<String, NonEmpty>; // simplified
// See https://github.com/iepathos/stilltypes for full Email type implementation
// Pure validation - accumulates ALL errors at once
fn validate_registration(input: RawInput) -> Validation<ValidUser, Vec<String>> {
Validation::all((
Username::new(input.username)
.map_err(|_| vec!["Username must be 1-20 characters".into()]),
Age::new(input.age)
.map_err(|_| vec!["Age must be 18-120".into()]),
Email::new(input.email)
.map_err(|_| vec!["Email required".into()]),
))
.map(|(username, age, email)| ValidUser { username, age, email })
}
// Pure business logic - no I/O, easily testable
fn calculate_discount(user: &ValidUser) -> Discount {
if user.age.get() >= 65 { Discount(0.20) } // Senior discount
else { Discount(0.05) }
}
// Effect composition - describes I/O, doesn't execute it
fn register_user(input: RawInput) -> impl Effect<Output = User, Error = AppError, Env = AppEnv> {
// Validate (pure) -> transform to effect
from_validation(validate_registration(input))
.map_err(|errs| AppError::Validation(errs))
// Apply pure business logic
.map(|valid| {
let discount = calculate_discount(&valid);
(valid, discount)
})
// Describe the I/O - asks() doesn't execute, it builds a description
.and_then(|(valid, discount)| {
asks(move |env: &AppEnv| env.db.create_user(&valid, discount))
})
.context("registering new user")
}
// I/O executes at the application boundary
async fn handle_registration(input: RawInput, env: &AppEnv) -> Result<User, AppError> {
register_user(input).run(env).await // <-- I/O happens here
}
// In tests - pure functions need no mocks
#[test]
fn senior_gets_20_percent_discount() {
let user = ValidUser {
username: Username::new("alice".into()).unwrap(),
age: Age::new(70).unwrap(), // 70 years old
email: Email::new("[email protected]".into()).unwrap(),
};
assert_eq!(calculate_discount(&user), Discount(0.20));
}
The key insight: register_user returns an effect description, not a result. No I/O executes until .run(env) is called. This keeps your business logic pure and testable - the register_user function itself does no I/O, it just describes what I/O should happen.
This example showcases several stillwater features:
- Refined types (
Username,Age,Email) encode invariants in the type system - once constructed, they’re guaranteed valid - Validation accumulates all errors - users see every problem at once, not one at a time
- Pure functions (
calculate_discount) are trivially testable with no mocks - Deferred I/O -
asks()describes database operations; actual I/O happens only at.run() - Context chaining preserves error trails for debugging
Key Features in 1.0#
Zero-Cost Effect System#
Following the futures crate pattern, effects are zero-cost by default with opt-in boxing:
use stillwater::prelude::*;
// No heap allocation - each combinator returns a concrete type
let effect = pure::<_, String, ()>(42)
.map(|x| x + 1)
.and_then(|x| pure(x * 2));
// Type: AndThen<Map<Pure<i32, ...>, ...>, ...>
// Compiler inlines everything - zero allocation!
Use .boxed() only when you need type erasure - like match arms with different effect types:
// Different branches produce different concrete types
fn fetch_user(id: UserId, use_cache: bool) -> BoxedEffect<User, Error, Env> {
if use_cache {
asks(|env: &Env| env.cache.get(id)) // type A
.boxed()
} else {
asks(|env: &Env| env.db.fetch(id)) // type B
.and_then(|user| cache_user(user))
.boxed()
}
}
If you’ve worked with async Rust, you already understand the pattern.
Validation with Error Accumulation#
Rust’s Result short-circuits on the first error. Stillwater’s Validation accumulates all of them:
use stillwater::Validation;
fn validate_registration(input: RawInput) -> Validation<User, Vec<String>> {
Validation::all((
validate_email(input.email),
validate_age(input.age),
validate_username(input.username),
))
.map(|(email, age, username)| User { email, age, username })
}
// Returns ALL errors at once:
// Failure(["Invalid email format", "Age must be 18+", "Username too short"])
No more frustrating round-trips where users fix one error only to discover another.
Predicate Combinators#
Composable, reusable predicates for declarative validation:
use stillwater::predicate::*;
let valid_username = len_between(3, 20)
.and(all_chars(|c| c.is_alphanumeric() || c == '_'));
let valid_port = between(1024, 65535);
// Combine with validation
Validation::success(username)
.ensure(valid_username, "Invalid username format")
Refined Types#
The “parse, don’t validate” pattern - type-level invariants that guarantee validity:
use stillwater::refined::{Refined, NonEmpty, Positive, InRange};
type NonEmptyString = Refined<String, NonEmpty>;
type PositiveI32 = Refined<i32, Positive>;
type Port = Refined<u16, InRange<1024, 65535>>;
// Validate at boundaries
let name = NonEmptyString::new("Alice".to_string())?;
let port = Port::new(8080)?;
// Use freely inside - validity guaranteed by construction
fn process_user(name: NonEmptyString, port: Port) {
// No need to re-check: types prove validity
}
Zero runtime overhead - same memory layout as the inner type.
Bracket Pattern for Resource Management#
Guaranteed acquire/use/release semantics:
use stillwater::effect::bracket::*;
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;
// Fluent builder for multiple resources
let result = acquiring(open_db(), |db| async move { db.close().await })
.and(open_file(), |f| async move { f.close().await })
.with_flat2(|db, file| process(db, file))
.run(&env)
.await;
Compile-Time Resource Tracking#
Type-level resource tracking prevents leaks at compile time:
use stillwater::effect::resource::*;
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>()
}
// Bracket guarantees resource neutrality - won't compile if unbalanced!
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))
}
Zero runtime overhead - all tracking happens at compile time.
Retry Policies as Data#
Policies are composable, testable values - not scattered implementation:
use stillwater::{Effect, RetryPolicy};
use std::time::Duration;
// Define policy as pure data
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)));
// Reuse across effects
Effect::retry(|| fetch_user(id), api_policy.clone());
Effect::retry(|| save_order(order), api_policy.clone());
// Conditional retry
Effect::retry_if(
|| api_call(),
api_policy,
|err| matches!(err, ApiError::Timeout | ApiError::ServerError(_))
);
Writer Effect for Audit Trails#
Accumulate logs without threading state through every function:
use stillwater::effect::writer::prelude::*;
fn process_order(order: Order) -> impl WriterEffect<
Output = Receipt, Error = String, Env = (), Writes = Vec<String>
> {
tell_one("Processing order".to_string())
.and_then(move |_| validate_order(order))
.tap_tell(|_| vec!["Validation passed".to_string()])
.and_then(|order| charge_card(order))
.tap_tell(|receipt| vec![format!("Charged: ${}", receipt.amount)])
}
let (result, logs) = process_order(order).run_writer(&()).await;
// logs: ["Processing order", "Validation passed", "Charged: $42.00"]
Parallel Effect Execution#
Run independent effects concurrently:
use stillwater::prelude::*;
// Combine independent 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 })
}
// Homogeneous collection
let results = par_all(effects, &env).await;
// Race - first success wins
let result = race(fetch_from_primary(), fetch_from_backup(), &env).await;
Production Ready#
1.0 represents a stable API ready for production use:
- 355+ unit tests passing
- 113+ documentation tests
- 21 comprehensive runnable examples
- Zero clippy warnings
- Full async/await support
- CI/CD with security audits
Philosophy#
Stillwater is built on six core beliefs:
- Pure Core, Imperative Shell - Separate logic from I/O for testability
- Fail Completely, Not Partially - Accumulate all errors, not just the first
- Errors Tell Stories - Context chaining preserves error trails
- Composition Over Complexity - Build complex from simple pieces
- Types Guide, Don’t Restrict - Pragmatic type system use
- Pragmatism Over Purity - Better Rust, not academic FP
We don’t fight the borrow checker. We don’t replace the standard library. We work with ?, integrate with async/await, and follow Rust idioms.
When to Use Stillwater#
Good fit:
- Complex form/config validation (error accumulation shines)
- Business logic that needs extensive testing (pure core)
- Deep call stacks needing error context
- Long-lived codebases prioritizing maintainability
- Effects with dependency injection (Reader pattern)
- Resource management with guaranteed cleanup
Less suitable:
- Simple CRUD applications (standard
Resultis fine) - Performance-critical hot paths (profile first)
- Teams not aligned on functional patterns
The Ecosystem#
Stillwater is part of a family of libraries sharing the same philosophy:
| Library | Purpose |
|---|---|
| premortem | Configuration validation and multi-source loading |
| postmortem | JSON schema validation with precise path tracking |
| mindset | Zero-cost effect-based state machines |
| stilltypes | Domain-specific refined types |
Getting Started#
Add to your Cargo.toml:
[dependencies]
stillwater = "1.0"
Start with validation - it’s the most immediately useful part:
use stillwater::Validation;
fn validate(input: &Input) -> Validation<Valid, Vec<String>> {
Validation::all((
validate_field1(&input.field1),
validate_field2(&input.field2),
validate_field3(&input.field3),
))
.map(|(f1, f2, f3)| Valid { f1, f2, f3 })
}
Then explore effects when you need testable I/O composition:
use stillwater::prelude::*;
fn fetch_and_process(id: Id) -> impl Effect<Output = Result, Error = AppError, Env = AppEnv> {
asks(|env: &AppEnv| env.api.fetch(id))
.and_then(|data| pure(transform(data)))
.context("fetching and processing data")
}
// In tests - no real API needed
let result = fetch_and_process(id).run(&mock_env).await;
What’s Next#
With 1.0 stable, future work focuses on:
- More refined type predicates stilltypes
- Additional effect combinators
- Performance optimizations
- Ecosystem integration examples
Check out stillwater on GitHub or crates.io.
