Originally published on Entropic Drift
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("alice@example.com".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.
Want more content like this? Follow me on Dev.to or subscribe to Entropic Drift for posts on AI-powered development workflows, Rust tooling, and technical debt management.
Check out my open-source projects:
Top comments (0)