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

Generic Effects

Generics are useful when a capability belongs in a shared utility crate, but the application chooses the concrete type. For example, a utility crate might expose Foo<T>, while one app uses Foo<Value> everywhere.

krucyfiks supports this pattern for type parameters. The generated protocol keeps request and output generics separate where it can, so output-only generic capabilities do not force generic request enums.

Output-Only Generics

If a generic parameter appears only in method outputs, the generated request enum does not need that parameter:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub struct Value(String);

#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Foo<T = Value>: Send + Sync {
    async fn get(&self) -> T;
}
}

This generates a non-generic request enum and a generic output enum:

#![allow(unused)]
fn main() {
pub enum FooEffect {
    Get,
}

pub enum FooEffectOutput<T = Value> {
    GetDone(T),
}
}

The request is plain data. The output carries the type parameter because that is where T is actually used.

Composing Output-Only Generics

A composed effect can also keep the request enum non-generic:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[krucyfiks(output_param(T = Value))]
pub enum AppEffect {
    #[krucyfiks(ctx = T)]
    Foo(FooEffect),
}
}

output_param(T = Value) declares AppEffectOutput<T = Value>.

ctx = T tells the derive macro that this branch uses FooEffect as Effect<T>, not Effect<()>. That matters because FooEffect is not generic, but its output is selected through the Effect<Ctx> context parameter:

#![allow(unused)]
fn main() {
impl<T> krucyfiks::Effect<T> for FooEffect {
    type Output = FooEffectOutput<T>;
}
}

Without ctx = T, the composed output would try to use the child effect with the default context ().

Request Generics

If a type parameter appears in a method argument, the request enum must be generic:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Cache<T = Value>: Send + Sync {
    async fn put(&self, value: T);
}
}

This generates:

#![allow(unused)]
fn main() {
pub enum CacheEffect<T = Value> {
    Put { value: T },
}

pub enum CacheEffectOutput {
    PutDone,
}
}

When composing this effect, the app enum must also carry the request type if it stores CacheEffect<T>:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum AppEffect<T = Value> {
    #[krucyfiks(ctx = T)]
    Cache(CacheEffect<T>),
}
}

This is the shape where the core app can stay generic and a host crate, such as a TUI, locks in the concrete type.

Request And Output Generics

A capability can use generic parameters in both directions:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Store<T, U>: Send + Sync {
    async fn put(&self, value: T);
    async fn get(&self) -> U;
}
}

The request enum uses T; the output enum uses U. A composed app can carry the request generic and declare the output generic:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[krucyfiks(output_param(U))]
pub enum AppEffect<T> {
    #[krucyfiks(ctx = U)]
    Store(StoreEffect<T>),
}
}

Use a tuple context when the child effect uses more than one output context parameter:

#![allow(unused)]
fn main() {
#[krucyfiks(ctx = (T, U))]
Foo(FooEffect<T, U>)
}

Bounds And Defaults

Type parameters may have bounds and defaults:

#![allow(unused)]
fn main() {
#[krucyfiks::effect]
#[async_trait::async_trait]
pub trait Foo<T: Clone = Value>: Send + Sync {
    async fn get(&self) -> T;
}
}

The same applies to composed enums:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
pub enum AppEffect<T: Clone = Value> {
    #[krucyfiks(ctx = T)]
    Foo(FooEffect<T>),
}
}

For output-only parameters, use output_param:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq, krucyfiks::Effect)]
#[krucyfiks(output_param(T: Clone = Value))]
pub enum AppEffect {
    #[krucyfiks(ctx = T)]
    Foo(FooEffect),
}
}

Multiple output parameters can be written as separate options or grouped:

#![allow(unused)]
fn main() {
#[krucyfiks(output_param(T: Clone), output_param(U: std::fmt::Debug))]
#[krucyfiks(output_param(T: Clone, U: std::fmt::Debug))]
}

Both forms mean the same thing. Duplicate names are rejected; combine bounds for one parameter in one declaration:

#![allow(unused)]
fn main() {
#[krucyfiks(output_param(T: Clone + std::fmt::Debug))]
}

Current Limits

krucyfiks still keeps the generic surface intentionally narrow:

  • only type parameters are supported;
  • lifetimes are rejected;
  • const generics are rejected;
  • generic methods are rejected;
  • associated types and associated constants are rejected;
  • method where clauses are rejected;
  • trait where clauses must constrain type parameters;
  • composed variants must be single-field tuple variants with plain child effect paths;
  • UniFFI may reject generic protocols even when core Rust code accepts them.

These limits keep generated requests and outputs owned, inspectable, and usable across async and host boundaries.