Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Migration Guide: Stillwater 0.10.x to 0.11.0

Overview

Stillwater 0.11.0 introduces a zero-cost Effect API, following the futures crate pattern. This is a breaking change that requires updating your code.

Key Changes

0.10.x0.11.0
Effect<T, E, Env> struct (boxed per combinator)impl Effect<Output=T, Error=E, Env=Env> trait (zero-cost)
Effect::pure(x)pure(x) or pure::<_, E, Env>(x)
Effect::fail(e)fail(e) or fail::<T, _, Env>(e)
Effect::from_fn(f)from_fn(f)
N/Afrom_async(f), from_result(r), from_option(o, err)
N/Aask(), asks(f), local(f, effect)
.run(&env).await.run(&env).await or .execute(&env).await
Always boxedZero-cost by default, opt-in .boxed()

Why the Change?

The old API boxed every combinator, allocating on the heap for each .map(), .and_then(), etc. While this was acceptable for I/O-bound work, it added unnecessary overhead for compute-bound code and prevented certain compiler optimizations.

The new API follows the pattern established by the futures crate:

  • Zero-cost by default: Each combinator returns a concrete type, enabling full inlining
  • Explicit boxing: Use .boxed() only when type erasure is needed

Migration Steps

Step 1: Update Imports

#![allow(unused)]
fn main() {
// Before
use stillwater::Effect;

// After - Option A: Use prelude (recommended)
use stillwater::prelude::*;
// or
use stillwater::effect::prelude::*;

// After - Option B: Direct imports
use stillwater::{pure, fail, from_fn, Effect, EffectExt, BoxedEffect};
}

Step 2: Update Return Types

#![allow(unused)]
fn main() {
// Before
fn my_effect() -> Effect<i32, String, ()> {
    Effect::pure(42)
}

// After - Option A: Zero-cost (preferred)
fn my_effect() -> impl Effect<Output = i32, Error = String, Env = ()> {
    pure(42)
}

// After - Option B: Boxed (when needed)
fn my_effect_boxed() -> BoxedEffect<i32, String, ()> {
    pure(42).boxed()
}

// Running effects - both work:
let result = my_effect().run(&()).await;      // From Effect trait
let result = my_effect().execute(&()).await;  // Convenience method
}

Step 3: Update Constructor Calls

#![allow(unused)]
fn main() {
// Before
Effect::pure(42)
Effect::fail("error")
Effect::from_fn(|env| Ok(env.value))

// After - basic constructors
pure(42)
fail("error")
from_fn(|env| Ok(env.value))

// After - additional constructors available
from_async(|env| async { Ok(value) })  // For async operations
from_result(Ok(42))                     // From Result
from_option(Some(42), || "missing")     // From Option with error
ask()                                   // Get entire environment
asks(|env| env.config.clone())          // Extract from environment
local(|env| modified_env, inner_effect) // Run with modified env
}

Step 4: Add .boxed() Where Needed

If you’re storing effects in collections, using recursion, or returning different effect types from match arms, add .boxed():

#![allow(unused)]
fn main() {
use stillwater::{pure, BoxedEffect, EffectExt};

// Collections - need same type
let effects: Vec<BoxedEffect<i32, String, ()>> = vec![
    pure(1).boxed(),
    pure(2).boxed(),
];

// Recursion - need to break infinite type
fn recursive(n: i32) -> BoxedEffect<i32, String, ()> {
    if n <= 0 {
        pure(0).boxed()
    } else {
        pure(n)
            .and_then(move |_| recursive(n - 1))
            .boxed()
    }
}

// Match arms - need same type
fn conditional(flag: bool) -> BoxedEffect<i32, String, ()> {
    if flag {
        pure(1).boxed()
    } else {
        pure(2).map(|x| x * 2).boxed()
    }
}
}

Using the Compatibility Module

For gradual migration, use the compatibility module:

#![allow(unused)]
fn main() {
#[allow(deprecated)]
use stillwater::LegacyEffect; // Type alias for BoxedEffect

// Old-style code (with deprecation warnings)
fn my_effect() -> LegacyEffect<i32, String, ()> {
    stillwater::pure(42).boxed()
}
}

The LegacyEffect type alias and LegacyConstructors trait are deprecated. Migrate to the new API as soon as possible.

Common Issues

“expected struct, found opaque type”

You’re returning impl Effect but the caller expects a concrete type. Either:

  1. Use .boxed() to get BoxedEffect
  2. Update the caller to accept impl Effect

“cannot infer type”

Add type annotations to constructor functions:

#![allow(unused)]
fn main() {
pure::<_, String, ()>(42)  // Specify error and env types
}

“the trait bound is not satisfied”

Make sure your closures are Send:

#![allow(unused)]
fn main() {
// Before (might not be Send)
.map(|x| x + some_local_ref)

// After (capture by value)
let value = *some_local_ref;
.map(move |x| x + value)
}

“recursive type has infinite size”

You need to use .boxed() for recursive effects:

#![allow(unused)]
fn main() {
fn countdown(n: i32) -> BoxedEffect<i32, String, ()> {
    if n <= 0 {
        pure(0).boxed()
    } else {
        pure(n)
            .and_then(move |x| countdown(x - 1).map(move |sum| x + sum))
            .boxed()
    }
}
}

Before/After Examples

Simple Effect Chain

#![allow(unused)]
fn main() {
// Before
fn calculate() -> Effect<i32, String, AppEnv> {
    Effect::pure(42)
        .map(|x| x * 2)
        .and_then(|x| Effect::pure(x + 10))
}

// After
fn calculate() -> impl Effect<Output = i32, Error = String, Env = AppEnv> {
    pure(42)
        .map(|x| x * 2)
        .and_then(|x| pure(x + 10))
}
}

Effect with Environment

#![allow(unused)]
fn main() {
// Before
fn fetch_config() -> Effect<String, AppError, AppEnv> {
    Effect::from_fn(|env: &AppEnv| {
        Ok(env.config.api_key.clone())
    })
}

// After
fn fetch_config() -> impl Effect<Output = String, Error = AppError, Env = AppEnv> {
    asks(|env: &AppEnv| env.config.api_key.clone())
}
}

Async Effect

#![allow(unused)]
fn main() {
// Before
fn fetch_user(id: u64) -> Effect<User, DbError, AppEnv> {
    Effect::from_async(|env: &AppEnv| {
        let db = env.db.clone();
        async move {
            db.find_user(id).await
        }
    })
}

// After
fn fetch_user(id: u64) -> impl Effect<Output = User, Error = DbError, Env = AppEnv> {
    from_async(move |env: &AppEnv| {
        let db = env.db.clone();
        async move {
            db.find_user(id).await
        }
    })
}
}

Parallel Effects

#![allow(unused)]
fn main() {
use stillwater::effect::prelude::*;

// Heterogeneous parallel (zero-cost) - par2, par3, par4
let effect = par2(
    pure::<_, String, ()>(1),
    pure::<_, String, ()>("hello".to_string()),
);
let (num, text) = effect.run(&()).await?;

// Homogeneous parallel (requires boxing) - par_all, race
let effects: Vec<BoxedEffect<i32, String, ()>> = vec![
    pure(1).boxed(),
    pure(2).boxed(),
    pure(3).boxed(),
];
let results = par_all(effects, &()).await?;
}

Performance Implications

The new zero-cost API eliminates heap allocations for effect chains:

Scenario0.10.x0.11.0
10-combinator chain10 Box allocations0 allocations
Effect stored in collection1 Box per effect1 Box per effect (same)
Recursive effectMultiple boxes1 Box per recursion level (same)

For I/O-bound applications, this difference is negligible. For compute-bound code or code running in tight loops, the new API can provide meaningful performance improvements.

Getting Help