Thanks to visit codestin.com
Credit goes to github.com

Skip to content

A Rust library for pragmatic effect composition and validation, emphasizing the pure core, imperative shell pattern.

License

Notifications You must be signed in to change notification settings

iepathos/stillwater

Repository files navigation

Stillwater

Crates.io Downloads CI License

A Rust library for pragmatic effect composition and validation, emphasizing the pure core, imperative shell pattern.

Philosophy

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.

What Problems Does It Solve?

1. "I want ALL validation errors, not just the first one"

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])

2. "How do I validate that all items have the same type before combining?"

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"]
    }
}

3. "How do I test code with database calls?"

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);
}

4. "I need to fetch multiple independent resources"

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);

5. "My errors lose context as they bubble up"

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

6. "I need clean dependency injection without passing parameters everywhere"

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

7. "I need guaranteed cleanup even when errors occur"

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"),
}

8. "Retry logic is scattered and hard to test"

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
    )
);

9. "I need to accumulate logs/metrics without threading state everywhere"

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);

10. "I want the type system to prevent resource leaks"

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!

Core Features

  • 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 Validation via ensure() and validate()
  • Validation combinators - Declarative validation with 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()
    • 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> for Vec<T>
    • Combinators: And, Or, Not for 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 element
  • Effect trait - Zero-cost effect composition following the futures crate pattern
    • Zero heap allocations by default
    • Explicit .boxed() when type erasure is needed
    • Returns impl Effect for optimal performance
  • Zip combinators - Combine independent effects into tuples
    • zip(), zip_with() methods for pairwise combination
    • zip3() through zip8() 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
  • 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 recovery
    • fallback(), 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 cleanup
    • bracket_full() returns BracketError with explicit error handling for all failure modes
    • acquiring() builder for fluent multi-resource management with with_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)
    • ResourceEffect trait with Acquires/Releases associated types
    • Extension methods: .acquires::<R>(), .releases::<R>(), .neutral()
    • bracket::<R>() builder for ergonomic resource brackets (single type parameter)
    • resource_bracket function for guaranteed resource-neutral operations
    • assert_resource_neutral for compile-time leak detection
  • Traverse and sequence - Transform collections with traverse() and sequence() for both validations and effects
  • Reader pattern helpers - Clean dependency injection with ask(), asks(), and local()
  • Writer Effect - Accumulate logs, metrics, or audit trails alongside computation
    • tell(), tell_one() for emitting values to accumulator
    • tap_tell() for logging derived values after success
    • censor() for filtering/transforming accumulated writes
    • listen(), pass() for introspecting and controlling writes
    • traverse_writer(), fold_writer() for collection operations
    • Works with any Monoid: Vec, Sum, Product, custom types
  • Semigroup trait - Associative combination of values
    • Extended implementations for HashMap, HashSet, BTreeMap, BTreeSet, Option
    • Wrapper types: First, Last, Intersection for alternative semantics
  • Monoid trait - Identity elements for powerful composition patterns
  • Testing utilities - Ergonomic test helpers
    • MockEnv builder for composing test environments
    • Assertion macros: assert_success!, assert_failure!, assert_validation_errors!
    • TestEffect wrapper for deterministic effect testing
    • Optional proptest feature for property-based testing
  • Context chaining - Never lose error context
  • Tracing integration - Instrument effects with semantic spans using the standard tracing crate
  • Zero-cost abstractions - Follows futures crate pattern: concrete types, no allocation by default
  • Works with ? operator - Integrates with Rust idioms
  • No heavy macros - Clear types, obvious behavior

Quick Start

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?;

Zero-Cost Effect System

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():

  • 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

Why Stillwater?

Compared to existing solutions:

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 futures crate pattern)
  • Idiomatic Rust, not Haskell port

vs. hand-rolling:

  • Validation accumulation built-in
  • Error context handling
  • Testability patterns established
  • Composable, reusable

What makes it "Rust-first":

  • No attempt at full monad abstraction (impossible without HKTs)
  • Works with ? operator via Try trait
  • Zero-cost via concrete types and monomorphization (like futures)
  • Integrates with async/await
  • Borrows checker friendly
  • Clear error messages

Installation

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"] }

Examples

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.

Production Readiness

Status: 0.11.0 - Production Ready

  • 355 unit tests passing
  • 113 documentation tests passing
  • 21 runnable examples
  • Zero clippy warnings
  • Full async support
  • CI/CD pipeline with security audits

This library is stable and ready for use. The 0.x version indicates the API may evolve based on community feedback.

Migration from 0.10.x

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

Documentation

Migrating from Result

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.

Contributing

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.

Ecosystem

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:

  • Error accumulation over short-circuiting
  • Pure core, effects at the boundaries
  • Zero-cost abstractions
  • Testability through dependency injection

License

MIT © Glen Baker [email protected]


"Like a still pond with water flowing through it, stillwater keeps your pure business logic calm and testable while effects flow at the boundaries."

About

A Rust library for pragmatic effect composition and validation, emphasizing the pure core, imperative shell pattern.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

No packages published