Programs

A Program combines a computation with its effect handlers. This is the primary API for using corophage.

Creating a program with #[effectful]

The simplest way to create a program is with the #[effectful] attribute macro:

use corophage::prelude::*;

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

#[effect(u64)]
struct Counter;

#[effectful(Log, Counter)]
fn my_program() -> u64 {
    yield_!(Log("hello".into()));
    let n = yield_!(Counter);
    n * 2
}

The #[effectful(Eff1, Eff2, ...)] macro:

  • Transforms the return type to Effectful<'_, Effects![Eff1, Eff2, ...], T>
  • Wraps the body in Program::new
  • Enables yield_!(effect) syntax to perform effects

Lifetime handling

Borrowed and generic arguments are captured for the lifetime of the returned program automatically. Elided references, &self receivers, generic parameters, and argument-position impl Trait do not need artificial lifetime annotations or outlives bounds.

#[effectful]
fn length(value: &str) -> usize {
    value.len()
}

Generic parameters, argument-position impl Trait, and borrowed receivers work the same way:

#[effectful]
fn stringify<T: ToString>(value: T) -> String {
    value.to_string()
}

#[effectful]
fn stringify_impl(value: impl ToString) -> String {
    value.to_string()
}

struct Label(String);

impl Label {
    #[effectful]
    fn length(&self) -> usize {
        self.0.len()
    }
}

If your effects borrow data, the macro infers the lifetime automatically when the function has exactly one lifetime parameter:

#[effectful(Log<'a>)]
fn log_msg<'a>(msg: &'a str) -> () {
    yield_!(Log(msg));
}

With multiple lifetime parameters, specify the effect lifetime explicitly as the first argument:

#[effectful('a, Log<'a>)]
fn log_msg<'a, 'b>(msg: &'a str, _other: &'b str) -> () {
    yield_!(Log(msg));
}

An explicit lifetime can also be the only attribute argument. A named lifetime needs no trailing comma, 'static remains a concrete lifetime, and '_ requests the same inference used when the lifetime is omitted:

#[effectful('a)]
fn named<'a>(value: &'a str) -> usize {
    value.len()
}

#[effectful('static)]
fn static_value() -> &'static str {
    "static"
}

#[effectful('_)]
fn inferred(value: &str) -> usize {
    value.len()
}

Send-able programs

Add send to the attribute to create a Send-able program (for use with tokio::spawn):

#[effectful(Counter, send)]
fn my_send_program() -> u64 {
    yield_!(Counter)
}

Spreading an effects type alias

If you define a reusable set of effects with type MyEffs = Effects![...], you can spread it into the attribute with ...Alias (the same syntax as frunk's Coprod!(...Tail)):

type MyEffs = Effects![Log, Counter];

#[effectful(...MyEffs)]
fn using_alias() -> u64 {
    yield_!(Log("hello".into()));
    yield_!(Counter)
}

You can also prepend extra inline effects before the spread:

#[effect(&'r str)]
struct GetConfig;

#[effectful(GetConfig, ...MyEffs)]
fn with_extra() -> String {
    let cfg = yield_!(GetConfig);
    yield_!(Log(cfg.to_string()));
    format!("{cfg}: {}", yield_!(Counter))
}

The spread must be the last effect argument (before send if present). This combines naturally with lifetimes and send:

type LogEffs<'a> = Effects![GetConfig, Log<'a>];

#[effectful('a, Counter, ...LogEffs<'a>, send)]
fn complex<'a>(msg: &'a str) -> u64 {
    yield_!(Log(msg));
    yield_!(Counter)
}

Creating a program with Program::new

You can also create programs manually with Program::new, which takes an async closure that receives a Yielder:

use corophage::prelude::*;

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

#[effect(u64)]
struct Counter;

type Effs = Effects![Log, Counter];

let program = Program::new(|mut y: Yielder<'_, Effs>| async move {
    y.yield_(Log("hello".into())).await;
    let n = y.yield_(Counter).await;
    n * 2
});

When you await the result of y.yield_(some_effect), the computation pauses, the effect is handled, and the await resolves to the value provided by the handler.

A Yielder supports one effect operation at a time. Its yield_ and invoke methods require &mut self, so the borrow checker rejects overlapping operations such as passing two yield_ futures to join!. Await each operation before starting another.

Computation bodies may also await ordinary futures. This works inside #[effectful] functions, manual Program::new closures, and invoked sub-programs when executed with .run().await or a low-level asynk runner:

#[effectful(Counter)]
fn delayed_count() -> u64 {
    tokio::task::yield_now().await;
    yield_!(Counter)
}

let result = delayed_count()
    .handle(async |_: Counter| Control::resume(42))
    .run()
    .await;

Synchronous runners handle effect suspension but cannot wait for unrelated futures. If an ordinary future returns Pending, use .run().await instead.

Attaching handlers

Handlers are attached one at a time with .handle(). Handlers can be attached in any order — the type system tracks which effects remain unhandled.

let result = my_program()
    .handle(|Log(msg)| {
        println!("{msg}");
        Control::resume(())
    })
    .handle(|_: Counter| Control::resume(42u64))
    .run_sync();

assert_eq!(result, Ok(84));

You can also attach multiple handlers at once with .handle_all():

#[effectful(Counter, Log)]
fn my_program() -> u64 {
    yield_!(Log("start".into()));
    yield_!(Counter)
}

let result = my_program()
    .handle_all(hlist![
        |_: Counter| Control::resume(42u64),
        |Log(msg)| { println!("{msg}"); Control::resume(()) },
    ])
    .run_sync();

Running programs

Once all effects are handled, you can run the program:

  • .run_sync() — execute synchronously, returns Result<R, Cancelled>; the computation body must not suspend on non-effect futures
  • .run().await — execute asynchronously, including ordinary futures awaited by the computation body
  • .run_sync_stateful(&mut state) — synchronous with shared mutable state
  • .run_stateful(&mut state).await — async with shared mutable state

Partially-handled programs

A partially-handled program is a first-class value you can pass around, store, or extend later:

fn add_logging<Effs>(program: Program</* ... */>) -> Program</* ... */> {
    program.handle(|Log(msg)| {
        println!("{msg}");
        Control::resume(())
    })
}

The compiler enforces at the type level that you can only call .run_sync() when all effects have been handled — attempting to run a partially-handled program is a compile error.

Program composition

Programs can invoke other programs via invoke!() (or y.invoke() with the manual API). The sub-program's effects must be a subset of the outer program's effects — each yielded effect is forwarded to the outer handler automatically.

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

With the manual Program::new API, use y.invoke(program).await:

let result = Program::new(|mut y: Yielder<'_, Effects![Ask, Print, Log]>| async move {
    y.yield_(Log("Starting...")).await;
    y.invoke(greet()).await;
    y.yield_(Log("Done!")).await;
})
.handle(|_: Ask| Control::resume("world"))
.handle(|Print(msg)| { println!("{msg}"); Control::resume(()) })
.handle(|_: Log| Control::resume(()))
.run_sync();

Sub-programs can be nested — a sub-program can itself invoke other sub-programs. The only requirement is that the inner program's effects are a subset of the outer program's effects.

Send-able programs

For use with multi-threaded runtimes like tokio, use #[effectful(..., send)] or Program::new_send:

#[effectful(Counter, send)]
fn my_program() -> u64 {
    yield_!(Counter)
}

// Can be spawned on tokio
tokio::spawn(async move {
    let result = my_program()
        .handle(async |_: Counter| Control::resume(42u64))
        .run()
        .await;
});

Or with the manual API:

let program = Program::new_send(|mut y: Yielder<'_, Effs>| async move {
    y.yield_(Counter).await
});

tokio::spawn(async move {
    let result = program
        .handle(async |_: Counter| Control::resume(42u64))
        .run()
        .await;
});

The result and every async handler future must be Send. Handlers that capture state across an await should clone it into a non-lending future, for example move |effect| { let state = Arc::clone(&state); async move { ... } }. Local programs continue to accept lending async |effect| { ... } handlers.