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
whereclauses are rejected; - trait
whereclauses 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.