corophage

Algebraic effect handlers for stable Rust.
Separate what your program does from how it gets done.

cargo add corophage

Testable

Swap in mock handlers for testing without touching the real world. Your business logic stays pure and easy to verify.

Composable

Attach handlers incrementally with the Program API. Partially-handled programs are first-class values you can pass around and extend.

Stable Rust

No nightly required. Built on async coroutines via fauxgen and hlists/coproducts via frunk.

Fast

~10 ns per yield. Zero-cost dispatch, the compiler monomorphizes and inlines effect dispatch into flat branches.

Define effects, write logic, attach handlers

Your program describes what to do by yielding effects.

Define your effects

Each effect is a struct annotated with #[effect(ResumeType)]. The resume type defines what the handler sends back.

use corophage::prelude::*;

#[effect(())]
struct Log<'a>(&'a str);

#[effect(String)]
struct Read(String);

#[effect(Never)]
struct Cancel;

type Effs = Effects![Cancel, Log<'static>, Read];

Describe what to do

Use #[effectful] to write effectful functions with yield_!(). Your program doesn't know or care how effects are handled.

#[effectful(Cancel, Log<'static>, Read)]
fn program() -> usize {
    yield_!(Log("Starting..."));
    let data = yield_!(Read("config.toml".into()));
    data.len()
}

Now decide how to handle each effect.

Run with plain closures as handlers.

let result = program()
    .handle(|_: Cancel| Control::cancel())
    .handle(|Log(msg)| {
        println!("{msg}");
        Control::resume(())
    })
    .handle(|Read(path)| {
        Control::resume(std::fs::read_to_string(path).unwrap())
    })
    .run_sync();

assert_eq!(result, Ok(42));

Use async closures and .await real I/O.

let result = program()
    .handle(async |_: Cancel| Control::cancel())
    .handle(async |Log(msg)| {
        println!("{msg}");
        Control::resume(())
    })
    .handle(async |Read(path)| {
        let data = tokio::fs::read_to_string(path).await.unwrap();
        Control::resume(data)
    })
    .run().await;

assert_eq!(result, Ok(42));

Swap in mock handlers, test without side effects.

let result = program()
    .handle(|_: Cancel| Control::cancel())
    .handle(|Log(_)| Control::resume(())) // silent
    .handle(|Read(_)| {
        // Fake data instead of reading from disk
        Control::resume("mock content!".into())
    })
    .run_sync();

// No filesystem access, no stdout output
assert_eq!(result, Ok(13));

The effects and logic stay the same, only the handlers change.

More features

Invoke sub-programs from within a program.
Effects are forwarded automatically, the sub-program's effects just need to be a subset of the outer program's.

use corophage::prelude::*;

#[effect(&'static str)]
struct Ask(&'static str);

#[effect(())]
struct Print(String);

#[effect(())]
struct Log(&'static str);

#[effectful(Ask, Print)]
fn greet() {
    let name: &str = yield_!(Ask("name?"));
    yield_!(Print(format!("Hello, {name}!")));
}

#[effectful(Ask, Print, Log)]
fn main_program() {
    yield_!(Log("Starting..."));
    invoke!(greet());
    yield_!(Log("Done!"));
}

let result = main_program()
    .handle(|_: Ask| Control::resume("world"))
    .handle(|Print(msg)| { println!("{msg}"); Control::resume(()) })
    .handle(|_: Log| Control::resume(()))
    .run_sync();

assert_eq!(result, Ok(()));

Handlers can share mutable state. The state is passed as an argument to every handler.

use corophage::prelude::*;

#[effect(u64)]
struct Counter;

#[effectful(Counter)]
fn count_up() -> u64 {
    let a = yield_!(Counter);
    let b = yield_!(Counter);
    a + b
}

let mut count: u64 = 0;

let result = count_up()
    .handle(|s: &mut u64, _: Counter| {
        *s += 1;
        Control::resume(*s)
    })
    .run_sync_stateful(&mut count);

assert_eq!(result, Ok(3));  // 1 + 2
assert_eq!(count, 2);       // handler was called twice

Handlers can resume computations with borrowed data, no cloning needed.
Because Effect::Resume<'r> is a GAT, handlers can return references instead of owned values.

use corophage::prelude::*;
use std::collections::HashMap;

#[effect(&'r str)]
struct Lookup<'a> {
    map: &'a HashMap<String, String>,
    key: &'a str,
}

// Pass borrowed data as function parameters.
// For inline use or fine-grained capture control,
// use Program::new directly instead.
#[effectful(Lookup<'a>)]
fn lookup<'a>(map: &'a HashMap<String, String>) -> String {
    let host: &str = yield_!(Lookup { map, key: "host" });
    let port: &str = yield_!(Lookup { map, key: "port" });
    format!("{host}:{port}")
}

let map = HashMap::from([
    ("host".into(), "localhost".into()),
    ("port".into(), "5432".into()),
]);

let result = lookup(&map)
    .handle(|Lookup { map, key }| {
        let value = map.get(key).unwrap();
        Control::resume(value.as_str())
    })
    .run_sync();

assert_eq!(result, Ok("localhost:5432".to_string()));

Effects can borrow data from the local scope by using a non-'static lifetime.

use corophage::prelude::*;

#[effect(())]
struct Log<'a>(pub &'a str);

// Pass borrowed data as function parameters.
// For inline use or fine-grained capture control,
// use Program::new directly instead.
#[effectful(Log<'a>)]
fn greet<'a>(msg: &'a str) {
    yield_!(Log(msg));
}

let msg = String::from("hello from a local string");

let result = greet(&msg)
    .handle(|Log(m)| { println!("{m}"); Control::resume(()) })
    .run_sync();

assert_eq!(result, Ok(()));

Ready to get started?