The Three Type Parameters
Every Effect carries three type parameters: Effect<A, E, R>. These aren't arbitrary — they answer the three fundamental questions every computation must address:
- A — What do I produce when I succeed?
- E — What do I produce when I fail?
- R — What do I need in order to run?
Let's examine each one.
A: The Answer
The A parameter is the success type — what you get back when everything goes right.
#![allow(unused)] fn main() { use id_effect::{Effect, succeed}; // This effect produces an i32 on success let answer: Effect<i32, String, ()> = succeed(42); // This effect produces a User on success let user_effect: Effect<User, DbError, ()> = succeed(User::new("Alice")); }
If you're familiar with Result<T, E>, think of A as the T. It's what you're hoping to get.
When you transform an effect with .map(), you're changing the A:
#![allow(unused)] fn main() { let numbers: Effect<i32, String, ()> = succeed(21); let doubled: Effect<i32, String, ()> = numbers.map(|n| n * 2); let stringified: Effect<String, String, ()> = doubled.map(|n| n.to_string()); }
Each .map() transforms the success value while preserving the error type and requirements.
E: The Error
The E parameter is the failure type — what you get back when something goes wrong.
#![allow(unused)] fn main() { use id_effect::{Effect, fail}; // This effect always fails with a String error let failure: Effect<i32, String, ()> = fail("something went wrong".to_string()); // This effect can fail with a DbError let user: Effect<User, DbError, ()> = fetch_user_from_db(42); }
Again, if you know Result<T, E>, think of E as the E. It's what you're worried might happen.
You can transform error types with .map_error():
#![allow(unused)] fn main() { let db_effect: Effect<User, DbError, ()> = fetch_user(42); // Convert DbError to a more general AppError let app_effect: Effect<User, AppError, ()> = db_effect.map_error(|e| AppError::Database(e)); }
Unlike traditional error handling where you sprinkle .map_err() everywhere, with effects you typically handle error transformation at specific boundaries — when composing larger effects from smaller ones, or when exposing an API.
R: The Requirements
Here is where effects get interesting. The R parameter represents the environment — the dependencies this effect needs in order to run.
When an effect needs services, express R with caps! and capability keys (Chapter 5 names keys fully; use Database, not a bare Database type):
#![allow(unused)] fn main() { use id_effect::{Effect, caps, effect, provide, require, run_with, succeed}; // Self-contained — R is () let standalone: Effect<i32, String, ()> = succeed(42); // Needs Database at the edge fn get_user(id: u64) -> Effect<User, DbError, caps!(Database)> { effect!(|r| { let db = ~Database; Ok(db.fetch_user(id)) }) } // Needs two keys fn get_user_logged(id: u64) -> Effect<User, DbError, caps!(Database, EffectLogger)> { effect!(|r| { let db = ~Database; let log = ~EffectLogger; let user = db.fetch_user(id)?; log.info(&format!("fetched {}", user.id)); Ok(user) }) } }
You cannot run an effect until its capabilities exist. Satisfy them at the program edge with run_with, not inside library code:
#![allow(unused)] fn main() { run_with([provide!(DatabaseLive)], get_user(42))?; }
There is no .provide() on effects. run_with builds an Env and executes the program.
Why R matters
The R parameter is why id_effect offers compile-time dependency injection.
#![allow(unused)] fn main() { fn process_order(order: Order) -> Effect< Receipt, OrderError, caps!(Database, PaymentGateway, EmailService, EffectLogger), > }
Just from the type you know success, error, and which capability services must be wired before run_with.
R flows through composition
#![allow(unused)] fn main() { fn get_user(id: u64) -> Effect<User, DbError, caps!(Database)> { ... } fn send_email(to: &str, body: &str) -> Effect<(), EmailError, caps!(EmailService)> { ... } fn notify_user(id: u64) -> Effect<(), AppError, caps!(Database, EmailService)> { effect!(|r| { let user = ~ get_user(id).map_error(AppError::Db); ~ send_email(&user.email, "Hello!").map_error(AppError::Email); () }) } }
The unit environment: ()
When R = (), the effect is self-contained:
#![allow(unused)] fn main() { let standalone: Effect<i32, String, ()> = succeed(42); let result = run_blocking(standalone, ()); }
Effects with dependencies keep caps!(…) on the type until the edge:
fn main() -> Result<(), AppError> { run_with( [ provide!(DatabaseLive), provide!(CacheLive), provide!(LoggerLive), provide!(ConfigLive), ], business_logic(), ) }
Reading Effect Signatures
Let's practice reading some signatures:
#![allow(unused)] fn main() { // Produces String, never fails, needs nothing Effect<String, Never, ()> // Produces i32, can fail with ParseError, needs nothing Effect<i32, ParseError, ()> // Produces User, can fail with DbError, needs Database Effect<User, DbError, caps!(Database)> // Produces (), can fail with AppError, needs four capability services Effect<(), AppError, caps!(Database, Cache, EffectLogger)> }
With practice, you'll read these as fluently as you read Result<T, E>. The extra R parameter becomes second nature.
What's Next
We've seen that effects are descriptions, not actions. We've seen that Effect<A, E, R> encodes success type, error type, and requirements.
But we haven't answered the obvious question: why does this matter? Why is it better to describe computations than to just do them?
The answer is laziness. And laziness, it turns out, is a superpower.