Skip to main content
Background Image
  1. Blog/

Stillwater 1.0: Pragmatic Effect Composition and Validation for Rust

Author
Glen Baker
Building tech startups and open source tooling
Table of Contents

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:

  1. Pure Core, Imperative Shell - Separate logic from I/O for testability
  2. Fail Completely, Not Partially - Accumulate all errors, not just the first
  3. Errors Tell Stories - Context chaining preserves error trails
  4. Composition Over Complexity - Build complex from simple pieces
  5. Types Guide, Don’t Restrict - Pragmatic type system use
  6. 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 Result is 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:

LibraryPurpose
premortemConfiguration validation and multi-source loading
postmortemJSON schema validation with precise path tracking
mindsetZero-cost effect-based state machines
stilltypesDomain-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.

Related

Stillwater Validation for Rustaceans: Accumulating Errors Instead of Failing Fast
Compile-Time Resource Tracking in Rust: From Runtime Brackets to Type-Level Safety
Refactoring a God Object Detector That Was Itself a God Object
Three Patterns That Made Prodigy's Functional Migration Worth It
Stillwater - Pure Core, Imperative Shell for Rust
Premortem vs Figment: Configuration Libraries for Rust Applications
Mindset - Zero-Cost State Machines for Rust
Postmortem - Schema Validation for Rust
Stilltypes - Domain-Specific Refined Types for Rust