This is a research/educational project and is NOT ready for production use.
- ๐ง Unstable API: Everything may change without notice
- ๐ Learning Purpose: Built primarily for exploring algebraic effects in Rust
โ ๏ธ Use at Your Own Risk: Not suitable for any production systems- ๐งช Experimental: Relies on unstable Rust nightly features
Algae is a Rust library that brings the power of algebraic effects to systems programming. It provides a clean, type-safe way to handle side effects in your programs while maintaining composability, testability, and performance.
Algae implements one-shot (linear) algebraic effects, where each effect operation receives exactly one response and continuations are not captured for reuse. This design choice prioritizes simplicity, performance, and ease of understanding while covering the vast majority of real-world use cases.
Algebraic effects are a programming paradigm that allows you to separate the description of side effects from their implementation. Think of them as a more powerful and composable alternative to traditional approaches like dependency injection or the strategy pattern.
- ๐ Composable: Effects can be combined and nested naturally
- ๐งช Testable: Easy to mock and test effectful code
- ๐ญ Polymorphic: Same code can run with different implementations
- ๐ Type-safe: All effects are statically checked at compile time
- โก Low-cost: Minimal runtime overhead using efficient Rust coroutines
- ๐ Linear: One-shot effects ensure predictable, easy-to-reason-about control flow
Add algae to your Cargo.toml
:
โ ๏ธ Note: Algae is not yet published to Crates.io. For now, you'll need to use it as a Git dependency:
[dependencies]
algae = { git = "https://github.com/your-username/algae.git" }
Or clone the repository and use it as a local dependency:
[dependencies]
algae = { path = "../algae" }
Enable the required nightly features in your src/main.rs
or lib.rs
:
#![feature(coroutines, coroutine_trait, yield_expr)]
Here's a step-by-step example showing both the explicit and convenient approaches:
#![feature(coroutines, coroutine_trait, yield_expr)]
use algae::prelude::*;
// 1. Define your effects
effect! {
Console::Print (String) -> ();
Console::ReadLine -> String;
}
// 2a. Write effectful functions (explicit approach)
fn greet_user_explicit() -> Effectful<String, Op> {
Effectful::new(#[coroutine] move |mut _reply: Option<Reply>| {
// Print prompt
{
let effect = Effect::new(Console::Print("What's your name?".to_string()).into());
let reply_opt = yield effect;
let _: () = reply_opt.unwrap().take::<()>();
}
// Read input
let name: String = {
let effect = Effect::new(Console::ReadLine.into());
let reply_opt = yield effect;
reply_opt.unwrap().take::<String>()
};
format!("Hello, {}!", name)
})
}
// 2b. Write effectful functions (convenient approach - same behavior!)
#[effectful]
fn greet_user() -> String {
let _: () = perform!(Console::Print("What's your name?".to_string()));
let name: String = perform!(Console::ReadLine);
format!("Hello, {}!", name)
}
// 3. Implement handlers (same for both approaches)
struct RealConsoleHandler;
impl Handler<Op> for RealConsoleHandler {
fn handle(&mut self, op: &Op) -> Box<dyn std::any::Any + Send> {
match op {
Op::Console(Console::Print(msg)) => {
println!("{}", msg);
Box::new(())
}
Op::Console(Console::ReadLine) => {
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
Box::new(input.trim().to_string())
}
}
}
}
// 4. Run with handlers (both functions work identically)
fn main() {
// Both approaches produce the same result
let result1 = greet_user_explicit()
.handle(RealConsoleHandler)
.run();
let result2 = greet_user()
.handle(RealConsoleHandler)
.run();
println!("Explicit result: {}", result1);
println!("Convenient result: {}", result2);
// Both print the same thing!
}
Key insight: The #[effectful]
macro is pure convenience - it generates exactly the same Effectful<R, Op>
type and runtime behavior as the explicit approach, but with much cleaner syntax.
๐ Working Examples:
examples/readme.rs
- Complete version of this code with real and mock handlersexamples/explicit_vs_convenient.rs
- Side-by-side comparison proving both approaches are identicalexamples/test_send_across_threads.rs
- Demonstrates thread-safe effectful computations
Understanding algae requires familiarity with several key types and concepts. This section provides a comprehensive guide to all the core library components and how they work together.
Effects in algae are descriptions of what you want to do, not how to do it. They're defined using the effect!
macro, which generates Rust enums representing your operations:
effect! {
// Each line defines an operation: Family::Operation (Parameters) -> ReturnType
FileSystem::Read (String) -> Result<String, std::io::Error>;
FileSystem::Write ((String, String)) -> Result<(), std::io::Error>;
Database::Query (String) -> Vec<Row>;
Database::Execute (String) -> Result<u64, DbError>;
Logger::Info (String) -> ();
Logger::Error (String) -> ();
}
The effect!
macro generates several types for you:
// Generated effect family enums
pub enum FileSystem {
Read(String),
Write((String, String)),
}
pub enum Database {
Query(String),
Execute(String),
}
pub enum Logger {
Info(String),
Error(String),
}
// Generated unified operation type
pub enum Op {
FileSystem(FileSystem),
Database(Database),
Logger(Logger),
}
// Generated conversion traits
impl From<FileSystem> for Op { ... }
impl From<Database> for Op { ... }
impl From<Logger> for Op { ... }
The Effectful<R, Op>
struct is the heart of algae. It represents a computation that:
- May perform effects of type
Op
during execution - Eventually produces a result of type
R
- Can be run with different handlers for different behaviors
// Type signature breakdown:
// Effectful<R, Op>
// โ โโโ The type of effects this computation can perform
// โโโโโโโ The type of result this computation produces
// Example: A computation that performs Console and Math effects and returns an i32
type MyComputation = Effectful<i32, Op>;
Let's first see how to create effectful computations explicitly to understand what's happening under the hood:
use algae::prelude::*;
// Explicit function that returns Effectful<R, Op>
fn calculate_with_logging_explicit(x: i32, y: i32) -> Effectful<i32, Op> {
Effectful::new(#[coroutine] move |mut _reply: Option<Reply>| {
// Manually perform Logger::Info effect
{
let effect = Effect::new(Logger::Info(format!("Calculating {} + {}", x, y)).into());
let reply_opt = yield effect;
let _: () = reply_opt.unwrap().take::<()>();
}
// Manually perform Math::Add effect
let result: i32 = {
let effect = Effect::new(Math::Add((x, y)).into());
let reply_opt = yield effect;
reply_opt.unwrap().take::<i32>()
};
// Manually perform another Logger::Info effect
{
let effect = Effect::new(Logger::Info(format!("Result: {}", result)).into());
let reply_opt = yield effect;
let _: () = reply_opt.unwrap().take::<()>();
}
result
})
}
This explicit approach shows exactly what's happening:
- Return type is explicit:
Effectful<i32, Op>
- no magic - Coroutine creation: We manually create the coroutine with
Effectful::new()
- Effect operations: Each effect is manually created, yielded, and the reply extracted
- Type safety: We explicitly specify the expected return types
Writing coroutines manually is verbose and error-prone. The #[effectful]
attribute and perform!
macro automate this boilerplate:
#[effectful]
fn calculate_with_logging(x: i32, y: i32) -> i32 {
let _: () = perform!(Logger::Info(format!("Calculating {} + {}", x, y)));
let result: i32 = perform!(Math::Add((x, y)));
let _: () = perform!(Logger::Info(format!("Result: {}", result)));
result
}
// Actually returns: Effectful<i32, Op> (macro transforms the return type)
What the #[effectful]
macro does:
- Transforms return type:
i32
โEffectful<i32, Op>
- Wraps function body: Creates the coroutine automatically
- Enables
perform!
: Lets you use the convenient effect syntax
What the perform!
macro does:
- Creates the effect:
Effect::new(operation.into())
- Yields to handler:
yield effect
- Extracts the reply:
reply.unwrap().take::<ExpectedType>()
The explicit approach is educational but impractical for real code:
Explicit Approach | #[effectful] Approach |
---|---|
โ Verbose: 7 lines per effect | โ Concise: 1 line per effect |
โ Error-prone: Manual type annotations | โ Safe: Automatic type inference |
โ Repetitive: Same pattern every time | โ DRY: Macro handles boilerplate |
โ Hard to read: Focus on mechanics | โ Clear intent: Focus on business logic |
โ Educational: Shows what's happening | โ Productive: Gets work done |
Equivalence guarantee: Both approaches produce identical Effectful<R, Op>
values and have the same runtime behavior.
Effectful<R, Op>
provides methods for execution:
let computation = calculate_with_logging(5, 3);
// Method 1: Direct execution with handler
let result: i32 = computation.run_with(MyHandler::new());
// Method 2: Fluent API (recommended)
let result: i32 = computation
.handle(MyHandler::new()) // Returns Handled<i32, Op, MyHandler>
.run(); // Returns i32
The Handler<Op>
trait defines how effects are actually executed. Handlers are the "interpreters" that give meaning to your effect descriptions:
pub trait Handler<Op> {
fn handle(&mut self, op: &Op) -> Box<dyn std::any::Any + Send>;
}
Although the return type is type-erased (Box<dyn Any + Send>
), algae ensures type safety through the effect system:
struct MyHandler {
log_count: usize,
}
impl Handler<Op> for MyHandler {
fn handle(&mut self, op: &Op) -> Box<dyn std::any::Any + Send> {
match op {
// Each branch must return the type specified in the effect! declaration
Op::Logger(Logger::Info(msg)) => {
println!("INFO: {}", msg);
self.log_count += 1;
Box::new(()) // Must return () as declared
}
Op::Math(Math::Add((a, b))) => {
Box::new(a + b) // Must return i32 as declared
}
Op::FileSystem(FileSystem::Read(path)) => {
Box::new(std::fs::read_to_string(path)) // Must return Result<String, std::io::Error>
}
}
}
}
Production Handler:
struct ProductionHandler {
db_pool: ConnectionPool,
logger: Logger,
}
impl Handler<Op> for ProductionHandler {
fn handle(&mut self, op: &Op) -> Box<dyn std::any::Any + Send> {
match op {
Op::Database(Database::Query(sql)) => {
let rows = self.db_pool.execute(sql).unwrap();
Box::new(rows)
}
Op::Logger(Logger::Info(msg)) => {
self.logger.info(msg);
Box::new(())
}
}
}
}
Test Handler:
struct MockHandler {
db_responses: HashMap<String, Vec<Row>>,
logged_messages: Vec<String>,
}
impl Handler<Op> for MockHandler {
fn handle(&mut self, op: &Op) -> Box<dyn std::any::Any + Send> {
match op {
Op::Database(Database::Query(sql)) => {
let rows = self.db_responses.get(sql).cloned().unwrap_or_default();
Box::new(rows)
}
Op::Logger(Logger::Info(msg)) => {
self.logged_messages.push(msg.clone());
Box::new(())
}
}
}
}
These are the low-level types that power the effect system. You typically don't use them directly, but understanding them helps you understand how algae works internally.
An Effect<Op>
represents a single effect operation that has been requested but not yet handled:
pub struct Effect<Op> {
pub op: Op, // The operation being requested
reply: Option<Box<dyn Any + Send>>, // Storage for the handler's response
}
// Created automatically by perform!() macro
let effect = Effect::new(Logger::Info("Hello".to_string()));
// Handler fills the effect with a response
effect.fill_boxed(Box::new(()));
// Extract the response
let reply = effect.get_reply();
Reply
- Typed Response Extraction
A Reply
wraps the handler's response and provides type-safe extraction:
pub struct Reply {
value: Box<dyn Any + Send>, // Type-erased response from handler
}
impl Reply {
pub fn take<R: Any + Send>(self) -> R {
// Runtime type checking + extraction
// Panics if types don't match
}
}
// Created when extracting from Effect
let reply: Reply = effect.get_reply();
// Type-safe extraction (must match effect declaration)
let response: () = reply.take::<()>(); // For Logger::Info -> ()
let result: i32 = reply.take::<i32>(); // For Math::Add -> i32
Understanding how algae executes effectful computations helps you write better code and debug issues:
// What you write with the convenient syntax:
#[effectful]
fn my_function() -> String {
let value: i32 = perform!(Math::Add((2, 3)));
format!("Result: {}", value)
}
// What the macros generate (equivalent to explicit approach):
fn my_function() -> Effectful<String, Op> {
Effectful::new(#[coroutine] move |mut _reply: Option<Reply>| {
// perform!(Math::Add((2, 3))) expands to:
let value: i32 = {
let __eff = Effect::new(Math::Add((2, 3)).into());
let __reply_opt = yield __eff;
__reply_opt.unwrap().take::<i32>()
};
format!("Result: {}", value)
})
}
// This is identical to what you'd write explicitly:
fn my_function_explicit() -> Effectful<String, Op> {
Effectful::new(#[coroutine] move |mut _reply: Option<Reply>| {
let value: i32 = {
let effect = Effect::new(Math::Add((2, 3)).into());
let reply_opt = yield effect;
reply_opt.unwrap().take::<i32>()
};
format!("Result: {}", value)
})
}
let computation = my_function();
let result = computation.handle(MyHandler::new()).run();
Step-by-step execution:
- Start coroutine with
None
(no previous reply) - Hit
perform!
- createsEffect::new(Math::Add((2, 3)))
- Yield effect to handler and suspend coroutine
- Handler processes
Math::Add((2, 3))
and returnsBox::new(5i32)
- Fill effect with handler's response
- Resume coroutine with
Some(Reply { value: Box::new(5i32) })
- Extract result using
reply.take::<i32>()
โ5i32
- Continue execution with the extracted value
- Return final result
"Result: 5"
// Effect declaration says Math::Add returns i32
effect! {
Math::Add ((i32, i32)) -> i32;
}
// Handler must return i32 (but as Box<dyn Any + Send>)
impl Handler<Op> for MyHandler {
fn handle(&mut self, op: &Op) -> Box<dyn std::any::Any + Send> {
match op {
Op::Math(Math::Add((a, b))) => Box::new(a + b), // โ
Returns i32
// Op::Math(Math::Add((a, b))) => Box::new("hello"), // โ Would panic at runtime
}
}
}
// perform! expects i32 (enforced at runtime)
let value: i32 = perform!(Math::Add((2, 3))); // โ
Type matches
// let value: String = perform!(Math::Add((2, 3))); // โ Would panic at runtime
Here's how all the types work together:
// 1. Effect declaration generates operation types
effect! {
Console::Print (String) -> ();
Math::Add ((i32, i32)) -> i32;
}
// Generates: Console, Math, Op enums + From impls
// 2. Effectful functions return Effectful<R, Op>
#[effectful]
fn interactive_calculator() -> i32 { // Returns Effectful<i32, Op>
let _: () = perform!(Console::Print("Enter numbers...".to_string()));
let result: i32 = perform!(Math::Add((5, 3)));
result
}
// 3. Handlers implement behavior for Op
struct MyHandler;
impl Handler<Op> for MyHandler { ... }
// 4. Execution ties everything together
let computation: Effectful<i32, Op> = interactive_calculator();
let handled: Handled<i32, Op, MyHandler> = computation.handle(MyHandler);
let result: i32 = handled.run();
The type system makes testing effectful code straightforward:
#[effectful]
fn user_workflow() -> String {
let _: () = perform!(Logger::Info("Starting workflow".to_string()));
let name: String = perform!(Console::ReadLine);
let _: () = perform!(Logger::Info(format!("Hello, {}", name)));
name
}
#[test]
fn test_user_workflow() {
struct TestHandler {
input: String,
logs: Vec<String>,
}
impl Handler<Op> for TestHandler {
fn handle(&mut self, op: &Op) -> Box<dyn std::any::Any + Send> {
match op {
Op::Console(Console::ReadLine) => Box::new(self.input.clone()),
Op::Logger(Logger::Info(msg)) => {
self.logs.push(msg.clone());
Box::new(())
}
}
}
}
let mut handler = TestHandler {
input: "Alice".to_string(),
logs: Vec::new(),
};
let result = user_workflow().handle(handler).run();
assert_eq!(result, "Alice");
// handler.logs contains the logged messages
}
This comprehensive type system ensures that:
- Effects are declared once and used consistently
- Handlers provide correct return types (checked at runtime)
- Effectful functions get properly typed results from effects
- Testing is straightforward with mock handlers
- Composition is natural through the trait system
Algae is based on the mathematical theory of algebraic effects and handlers, developed by researchers like Gordon Plotkin and Matija Pretnar.
Algae implements one-shot (linear) algebraic effects. Understanding this design choice helps explain what algae can and cannot do:
- Single Response: Each effect operation receives exactly one response
- No Continuation Capture: Computation state is not saved for later reuse
- Linear Control Flow: Effects execute once and continue forward
- Simpler Implementation: Easier to understand, debug, and optimize
- Better Performance: No overhead from capturing and managing continuations
// โ
Supported: Traditional side effects
perform!(File::Read("config.txt")) // Read once, get result once
perform!(Database::Query("SELECT...")) // Query once, get rows once
perform!(Logger::Info("Starting...")) // Log once, acknowledge once
- Multiple Responses: Effect operations can be resumed multiple times
- Continuation Capture: Computation state is captured and reusable
- Non-Linear Control Flow: Effects can branch, backtrack, or iterate
- Complex Implementation: Requires sophisticated continuation management
- Higher Overhead: Performance cost of capturing and managing state
// โ Not supported: Non-deterministic, generator-style effects
perform!(Choice::Select(vec![1,2,3])) // Cannot try all options
perform!(Generator::Yield(value)) // Cannot yield multiple values
perform!(Search::Backtrack) // Cannot rewind and try alternatives
- Covers 90% of Use Cases: File I/O, networking, databases, logging, state management
- Easier to Learn: Simpler mental model for developers new to algebraic effects
- Better Performance: No continuation overhead means faster execution
- Reliable: Fewer edge cases and potential for subtle bugs
- Rust-Friendly: Aligns well with Rust's ownership model and zero-cost abstractions
For advanced use cases requiring multi-shot effects (like probabilistic programming, non-deterministic search, or complex generators), consider specialized libraries or implementing custom continuation-passing patterns.
Theory | Algae Implementation | Purpose |
---|---|---|
Effect Signature | effect! macro |
Declares operations and their types |
Effect Operation | perform!(Operation) |
Invokes an effect operation |
Handler | Handler<Op> trait |
Provides interpretation for operations |
Computation | Effectful<R, Op> |
Computation that may perform effects |
Effectful Function | fn f() -> T with #[effectful] โ fn f() -> Effectful<T, Op> |
Function that returns a computation |
Handler Installation | .handle(h).run() |
Applies handler to computation |
The distinction between effectful functions and computations is important:
- Effectful function:
greet_user()
- A pure function that returns a computation - Computation:
Effectful<String, Op>
- The value returned by the function, representing effects to be performed - Execution:
greet_user().handle(h).run()
- Running the computation with a handler
๐ Theory in Practice: See
examples/theory.rs
for a complete demonstration of how these theoretical concepts map to working code.
Algae respects the fundamental algebraic laws of effects:
- Associativity:
(a >> b) >> c โก a >> (b >> c)
- Identity: Handler for no-op effects acts as identity
- Homomorphism: Handlers preserve the algebraic structure
๐ Laws in Action: See
tests/algebraic_laws.rs
for comprehensive tests and educational explanations of all 12 algebraic laws, including beginner-friendly introductions to the mathematical concepts.
Approach | Composability | Type Safety | Performance | Testability |
---|---|---|---|---|
Algebraic Effects | โ Excellent | โ Full | โ Low-cost | โ Excellent |
Async/Await | โ Good | โ Good | ||
Dependency Injection | โ Good | |||
Global State | โ Poor | โ None | โ Fast | โ Poor |
algae/
โโโ algae/ # Core library
โ โโโ src/lib.rs # Effect, Effectful, Handler types
โ โโโ examples/ # Example programs
โโโ algae-macros/ # Procedural macros
โ โโโ src/lib.rs # effect!, #[effectful], perform! macros
โโโ README.md
The effect!
macro generates:
// From this:
effect! {
Console::Print (String) -> ();
Console::ReadLine -> String;
}
// Generates this:
#[derive(Debug, Clone)]
pub enum Console {
Print(String),
ReadLine,
}
#[derive(Debug, Clone)]
pub enum Op {
Console(Console),
}
impl From<Console> for Op {
fn from(c: Console) -> Op { Op::Console(c) }
}
- Effectful Function Call: Returns
Effectful<R, Op>
(zero-cost wrapper) - Handler Installation: Creates
Handled<R, Op, H>
(zero-cost wrapper) - Execution: Drives coroutine, yielding effects to handler
- Effect Processing: Handler processes operation, returns typed result
- Resume: Coroutine resumes with handler's reply
The library includes several examples demonstrating different patterns:
cargo run --example overview
Comprehensive roadmap showing where to find all examples, tests, and documentation.
cargo run --example test_send_across_threads
Demonstrates how effectful computations can be safely sent across threads for concurrent processing.
cargo run --example readme
Complete, runnable version of the README's introductory example with both real and mock handlers.
cargo run --example explicit_vs_convenient
Side-by-side demonstration showing that #[effectful]
and perform!
are pure convenience macros that generate identical code to the explicit approach.
cargo run --example multiple_effects_demo
Comprehensive guide to organizing multiple effects: single declaration vs module separation, with trade-offs and best practices.
cargo run --example custom_root_effects
Demonstrates the new custom root enum functionality: avoiding conflicts, combining roots, and managing multiple effect declarations in one module.
cargo run --example advanced
Complex multi-effect application with file I/O, database operations, logging, error handling, and comprehensive testing patterns.
cargo run --example theory
Demonstrates the mapping between algebraic effects theory and algae implementation, including algebraic laws.
cargo run --example pure
Shows pure functional state management using algebraic effects.
cargo run --example console
Demonstrates interactive I/O with both real and mock implementations, plus random number generation.
cargo run --example partial_handlers
Shows how to use partial handlers for safe, modular effect composition without panics.
cargo run --example effect_test
Basic test of the effect system with simple operations.
cargo run --example minimal
Minimal example showing the underlying coroutine mechanics (educational).
cargo run --example no_macros --no-default-features
Complete example showing how to use algae without any macros - pure explicit syntax.
# Run core examples demonstrating main features
for example in readme explicit_vs_convenient multiple_effects_demo test_send_across_threads advanced theory pure console partial_handlers variable_handler_chain chained_handlers effect_test minimal; do
echo "=== Running $example ==="
cargo run --example $example
echo
done
# Run test examples demonstrating bug fixes and edge cases
for example in test_non_default_payload test_custom_root_effectful test_effectful_scoping_fix test_error_messages; do
echo "=== Running $example ==="
cargo run --example $example
echo
done
# Run no-macros example separately (requires different feature flags)
echo "=== Running no_macros ==="
cargo run --example no_macros --no-default-features
- Rust Nightly: Required for coroutine features
- Git: For cloning the repository
# Clone the repository
git clone https://github.com/your-username/algae.git
cd algae
# Ensure you're using nightly Rust
rustup default nightly
# Or set up a toolchain file (already included)
cat rust-toolchain.toml
# Build the library
cargo build
# Build with optimizations
cargo build --release
# Build documentation
cargo doc --open
# Run all tests
cargo test
# Run only unit tests
cargo test --lib
# Run only integration tests
cargo test --test '*'
# Run only documentation tests
cargo test --doc
# Run with verbose output
cargo test -- --nocapture
# Check for issues
cargo clippy --all-targets -- -D warnings
# Format code
cargo fmt
# Check formatting
cargo fmt -- --check
# Run core examples
cargo run --example pure
cargo run --example console
cargo run --example debug
cargo run --example effect_test
cargo run --example test_send_across_threads
# Run feature demonstrations
cargo run --example test_non_default_payload
cargo run --example test_custom_root_effectful
cargo run --example test_error_messages
# Run specific example with release optimizations
cargo run --release --example console
# Run benchmarks (if implemented)
cargo bench
# Profile memory usage
cargo run --example pure --features profiling
Algae's macros (effect!
, #[effectful]
, perform!
) are optional. You can disable them if you prefer explicit syntax or have restrictions on proc-macros.
[dependencies]
algae = "0.1.0" # macros feature enabled by default
[dependencies]
algae = { version = "0.1.0", default-features = false }
When macros are disabled, you define everything manually:
#![feature(coroutines, coroutine_trait, yield_expr)]
use algae::prelude::*; // Only exports core types, no macros
use std::any::Any;
// 1. Manually define effect enums (instead of effect! macro)
#[derive(Debug)]
pub enum Console {
Print(String),
ReadLine,
}
#[derive(Debug)]
pub enum Op {
Console(Console),
}
impl From<Console> for Op {
fn from(c: Console) -> Self {
Op::Console(c)
}
}
// 2. Manually create effectful functions (instead of #[effectful])
fn greet_user() -> Effectful<String, Op> {
Effectful::new(#[coroutine] |mut _reply: Option<Reply>| {
// Manual effect operations (instead of perform!)
{
let effect = Effect::new(Console::Print("What's your name?".to_string()).into());
let reply_opt = yield effect;
let _: () = reply_opt.unwrap().take::<()>();
}
let name: String = {
let effect = Effect::new(Console::ReadLine.into());
let reply_opt = yield effect;
reply_opt.unwrap().take::<String>()
};
format!("Hello, {}!", name)
})
}
// 3. Handlers work exactly the same
struct ConsoleHandler;
impl Handler<Op> for ConsoleHandler {
fn handle(&mut self, op: &Op) -> Box<dyn Any + Send> {
match op {
Op::Console(Console::Print(msg)) => {
println!("{}", msg);
Box::new(())
}
Op::Console(Console::ReadLine) => {
// In real code, read from stdin
Box::new("Alice".to_string())
}
}
}
}
// 4. Execution is identical
fn main() {
let result = greet_user()
.handle(ConsoleHandler)
.run();
println!("Result: {}", result);
}
๐ Working Example: See
examples/no_macros.rs
for a complete working example without macros.
Use the manual approach when:
- Proc-macro restrictions: Your environment doesn't allow procedural macros
- Full control: You need custom implementations of the generated types
- Library development: Minimizing dependencies for a library crate
- Learning: Understanding exactly how the effects system works
- Custom syntax: Building your own effect DSL on top of algae
Use macros (default) when:
- Productivity: You want clean, readable application code
- Rapid development: Prototyping or building applications quickly
- Standard use cases: The generated code meets your needs
- Team development: Consistent, familiar syntax for all developers
Both approaches provide identical capabilities:
- โ One-shot algebraic effects - Same runtime model
- โ Type-safe effect handlers - Same type system
- โ Composable effect systems - Same composition patterns
- โ Zero-cost abstractions - Same performance characteristics
- โ Full coroutine support - Same underlying implementation
The only difference is syntax for defining and using effects.
You can define multiple effect families in a single declaration:
effect! {
// File operations
File::Read (String) -> Result<String, std::io::Error>;
File::Write ((String, String)) -> Result<(), std::io::Error>;
// Network operations
Http::Get (String) -> Result<String, reqwest::Error>;
Http::Post ((String, String)) -> Result<String, reqwest::Error>;
// Database operations
Db::Query (String) -> Vec<Row>;
Db::Execute (String) -> Result<u64, DbError>;
// Logging operations
Logger::Info (String) -> ();
Logger::Error (String) -> ();
}
This generates a single Op
enum that contains all your effect families:
// Generated by the macro
pub enum Op {
File(File),
Http(Http),
Db(Db),
Logger(Logger),
}
When building larger applications, you may need to define effects in different modules or avoid naming conflicts between different effect families. Algae provides custom root enum names to solve this problem elegantly.
By default, the effect!
macro generates a root enum called Op
. However, when you need multiple effect declarations in the same scope, you can specify a custom root enum name:
// Instead of the default Op enum, use ConsoleOp
effect! {
root ConsoleOp;
Console::Print (String) -> ();
Console::ReadLine -> String;
}
// This generates:
// - enum Console { Print(String), ReadLine }
// - enum ConsoleOp { Console(Console) } // Custom root instead of Op
// - impl From<Console> for ConsoleOp { ... }
This feature is essential when:
- Building modular effect systems
- Avoiding naming conflicts in large codebases
- Creating reusable effect libraries
- Separating concerns between different domains
The #[effectful]
attribute macro seamlessly adapts to your custom root types:
effect! {
root FileSystemOp;
FS::Read (String) -> Result<String, std::io::Error>;
FS::Write ((String, String)) -> Result<(), std::io::Error>;
}
// The #[effectful] macro automatically uses FileSystemOp
#[effectful(root = FileSystemOp)]
fn process_config(path: String) -> Result<String, std::io::Error> {
let content: Result<String, std::io::Error> = perform!(FS::Read(path.clone()));
let content = content?;
let processed = content.to_uppercase();
let _: Result<(), std::io::Error> = perform!(FS::Write((
format!("{}.processed", path),
processed.clone()
)))?;
Ok(processed)
}
// Returns: Effectful<Result<String, std::io::Error>, FileSystemOp>
Key points about #[effectful(root = CustomOp)]
:
- Automatic type inference: The macro determines the correct root type
- Type safety: Compile-time verification that effects match the root
- Seamless integration: Works identically to the default
Op
case - Handler compatibility: Handlers implement
Handler<CustomOp>
instead ofHandler<Op>
Custom root enums enable sophisticated architectural patterns for large applications:
// Domain-specific effect families
effect! {
root AuthOp;
Auth::Login ((String, String)) -> Result<User, AuthError>;
Auth::Logout -> ();
Auth::CheckPermission (Permission) -> bool;
}
effect! {
root DataOp;
Db::Query (String) -> Vec<Row>;
Db::Execute (String) -> Result<u64, DbError>;
Cache::Get (String) -> Option<String>;
Cache::Set ((String, String)) -> ();
}
effect! {
root BusinessOp;
Order::Create (OrderRequest) -> Result<Order, BusinessError>;
Order::Process (OrderId) -> Result<(), BusinessError>;
Inventory::Check (ProductId) -> u32;
Inventory::Reserve ((ProductId, u32)) -> Result<(), BusinessError>;
}
// Combine all effects for the application
algae::combine_roots!(pub AppOp = AuthOp, DataOp, BusinessOp);
// Now you can write handlers that compose different domains
struct AppHandler {
auth: AuthHandler,
data: DataHandler,
business: BusinessHandler,
}
impl Handler<AppOp> for AppHandler {
fn handle(&mut self, op: &AppOp) -> Box<dyn std::any::Any + Send> {
match op {
AppOp::AuthOp(auth_op) => self.auth.handle(auth_op),
AppOp::DataOp(data_op) => self.data.handle(data_op),
AppOp::BusinessOp(business_op) => self.business.handle(business_op),
}
}
}
// Functions can use any combination of effects
#[effectful(root = AppOp)]
fn place_order(user_id: UserId, request: OrderRequest) -> Result<Order, String> {
// Check authentication
let has_permission: bool = perform!(Auth::CheckPermission(Permission::CreateOrder).into());
if !has_permission {
return Err("Insufficient permissions".to_string());
}
// Check inventory
let available: u32 = perform!(Inventory::Check(request.product_id).into());
if available < request.quantity {
return Err("Insufficient inventory".to_string());
}
// Reserve inventory
let _: Result<(), BusinessError> = perform!(Inventory::Reserve((
request.product_id,
request.quantity
)).into()).map_err(|e| e.to_string())?;
// Create order
let order: Result<Order, BusinessError> = perform!(Order::Create(request).into());
order.map_err(|e| e.to_string())
}
Benefits of this approach:
- Clear separation: Each domain has its own effect family
- Type safety: Effects are grouped logically
- Modular testing: Test each domain independently
- Team scalability: Different teams can work on different effect families
- Incremental adoption: Add new effect families without touching existing code
You can use multiple effect!
declarations in the same module by specifying custom root enum names:
// โ
Works with custom root names
effect! {
root ConsoleOp;
Console::Print (String) -> ();
Console::ReadLine -> String;
}
effect! {
root FileOp;
File::Read (String) -> Result<String, String>;
File::Write ((String, String)) -> Result<(), String>;
}
effect! {
root NetworkOp;
Http::Get (String) -> Result<String, String>;
Http::Post ((String, String)) -> Result<String, String>;
}
Each generates its own root enum:
ConsoleOp
containingConsole
variantsFileOp
containingFile
variantsNetworkOp
containingHttp
variants
You can then combine them using the combine_roots!
macro:
// Combine multiple root enums into one
algae::combine_roots!(pub Op = ConsoleOp, FileOp, NetworkOp);
// Now you can write unified handlers
impl Handler<Op> for UnifiedHandler {
fn handle(&mut self, op: &Op) -> Box<dyn std::any::Any + Send> {
match op {
Op::ConsoleOp(console_op) => self.console_handler.handle(console_op),
Op::FileOp(file_op) => self.file_handler.handle(file_op),
Op::NetworkOp(network_op) => self.network_handler.handle(network_op),
}
}
}
Attempting to use duplicate root names (including the default Op
) in the same scope will produce clear error messages:
// โ ERROR: Conflicting Op enum definitions
effect! {
Console::Print (String) -> ();
}
effect! {
Math::Add ((i32, i32)) -> i32;
}
// Error: duplicate definition of `Op`
Each effect!
macro generates its own Op
enum, so multiple declarations in the same scope create conflicting type definitions.
For large codebases, you can separate effects into modules:
mod console_effects {
use algae::prelude::*;
effect! {
Console::Print (String) -> ();
Console::ReadLine -> String;
}
#[effectful]
pub fn interactive_session() -> String {
let _: () = perform!(Console::Print("Hello!".to_string()));
let name: String = perform!(Console::ReadLine);
name
}
}
mod math_effects {
use algae::prelude::*;
effect! {
Math::Add ((i32, i32)) -> i32;
Math::Multiply ((i32, i32)) -> i32;
}
#[effectful]
pub fn calculation(x: i32, y: i32) -> i32 {
let sum: i32 = perform!(Math::Add((x, y)));
perform!(Math::Multiply((sum, 2)))
}
}
Trade-offs of module separation:
- โ Good for: Large teams, feature boundaries, independent testing
- โ Limitation: Can't easily compose effects across modules
- โ Complexity: Each module needs its own handler
Single effect! |
Module Separation |
---|---|
โ Small to medium projects | โ Large codebases with teams |
โ Effects that interact | โ Independent feature areas |
โ Single unified handler | โ Separate testing strategies |
โ Easy composition | โ Complex cross-module composition |
๐ Working Example: See
examples/multiple_effects_demo.rs
for complete demonstrations of both patterns.
Handlers can be composed to handle different effect families:
struct CompositeHandler {
file_handler: FileHandler,
http_handler: HttpHandler,
db_handler: DbHandler,
}
impl Handler<Op> for CompositeHandler {
fn handle(&mut self, op: &Op) -> Box<dyn std::any::Any + Send> {
match op {
Op::File(_) => self.file_handler.handle(op),
Op::Http(_) => self.http_handler.handle(op),
Op::Db(_) => self.db_handler.handle(op),
}
}
}
Effects naturally support Result
types for error handling:
#[effectful]
fn safe_file_operation(path: String) -> Result<String, AppError> {
let content: Result<String, std::io::Error> = perform!(File::Read(path.clone()));
let content = content.map_err(AppError::IoError)?;
let result: Result<(), std::io::Error> = perform!(File::Write((
format!("{}.backup", path),
content.clone()
)));
result.map_err(AppError::IoError)?;
Ok(content)
}
Effectful functions support all Rust control flow:
#[effectful]
fn batch_process(items: Vec<String>) -> Vec<Result<String, String>> {
let mut results = Vec::new();
for (i, item) in items.iter().enumerate() {
let _: () = perform!(Logger::Info(format!("Processing item {}: {}", i, item)));
let result = match item.as_str() {
"skip" => {
let _: () = perform!(Logger::Info("Skipping item".to_string()));
continue;
}
"break" => {
let _: () = perform!(Logger::Info("Breaking early".to_string()));
break;
}
_ => {
let processed: Result<String, String> = perform!(Processor::Handle(item.clone()));
processed
}
};
results.push(result);
}
results
}
Algae supports partial handlers that can selectively handle operations, enabling safe effect composition without panics.
The library now supports chaining an arbitrary number of handlers together:
// Using handle_all to attach multiple handlers at once
let result = computation()
.handle_all(vec![
Box::new(ConsoleHandler),
Box::new(FileHandler),
Box::new(LoggerHandler),
])
.run_checked()?;
// Chaining handlers one by one
let result = computation()
.begin_chain() // Start with empty VecHandler
.handle(ConsoleHandler)
.handle(FileHandler)
.handle(LoggerHandler)
.run_checked()?;
// Starting with one handler and adding more
let result = computation()
.handle_all([ConsoleHandler]) // Start with one
.handle(FileHandler) // Add another
.handle(LoggerHandler) // And another
.run_checked()?;
// Building handler chain dynamically
let mut handled = computation().begin_chain().handle(ConsoleHandler);
if need_file_ops {
handled = handled.handle(FileHandler);
}
if need_logging {
handled = handled.handle(LoggerHandler);
}
let result = handled.run_checked()?;
// Or build a VecHandler manually for more control
let mut handlers = VecHandler::new();
handlers.push(ConsoleHandler);
handlers.push(FileHandler);
handlers.push(LoggerHandler);
let result = computation().run_checked(handlers)?;
// Define partial handlers that only handle specific operations
struct MathHandler;
impl PartialHandler<Op> for MathHandler {
fn maybe_handle(&mut self, op: &Op) -> Option<Box<dyn std::any::Any + Send>> {
match op {
Op::Math(Math::Add((a, b))) => Some(Box::new(a + b)),
Op::Math(Math::Multiply((a, b))) => Some(Box::new(a * b)),
_ => None, // Decline other operations
}
}
}
struct LoggerHandler;
impl PartialHandler<Op> for LoggerHandler {
fn maybe_handle(&mut self, op: &Op) -> Option<Box<dyn std::any::Any + Send>> {
match op {
Op::Logger(Logger::Info(msg)) => {
println!("[INFO] {}", msg);
Some(Box::new(()))
}
_ => None,
}
}
}
// Compose handlers and get Result-based error handling
#[effectful]
fn program() -> i32 {
let _: () = perform!(Logger::Info("Starting calculation".to_string()));
let sum: i32 = perform!(Math::Add((2, 3)));
let _: () = perform!(Logger::Info(format!("Result: {}", sum)));
sum
}
// Method 1: Manual VecHandler
let mut handlers = VecHandler::new();
handlers.push(MathHandler);
handlers.push(LoggerHandler);
match program().run_checked(handlers) {
Ok(result) => println!("Success: {}", result),
Err(UnhandledOp(op)) => eprintln!("Unhandled operation: {:?}", op),
}
// Method 2: Using handle_all
let result = program()
.handle_all(vec![
Box::new(MathHandler) as Box<dyn PartialHandler<Op> + Send>,
Box::new(LoggerHandler),
])
.run_checked()?;
- ๐ No Panics:
run_checked
returnsResult<T, UnhandledOp<Op>>
instead of panicking - ๐ Composable: Combine multiple handlers that each handle a subset of operations
- ๐ฆ Modular: Handlers can be developed and tested independently
- ๐ฏ Clear Errors: Know exactly which operation wasn't handled
- โก Same Performance: No additional overhead compared to total handlers
// Total handler (existing) - must handle all operations
impl Handler<Op> for TotalHandler {
fn handle(&mut self, op: &Op) -> Box<dyn Any + Send> {
match op {
// Must handle ALL operations or panic
}
}
}
// Partial handler (new) - can decline operations
impl PartialHandler<Op> for SelectiveHandler {
fn maybe_handle(&mut self, op: &Op) -> Option<Box<dyn Any + Send>> {
match op {
// Return Some for handled operations
// Return None to decline
}
}
}
// Total handlers can still be used with run_checked_with
let result = computation.run_checked_with(TotalHandler)?;
๐ Working Examples:
examples/partial_handlers.rs
- Comprehensive demonstrations of partial handlersexamples/variable_handler_chain.rs
- Variable-length handler chains with zero-panic executionexamples/chained_handlers.rs
- Demonstrates the.handle().handle().handle()
chaining syntaxexamples/clean_chaining.rs
- Simplest handler chaining with.begin_chain()
Algae is designed for minimal runtime overhead:
- Effect Declaration: Compile-time only, no runtime cost
- Effectful Functions: Single heap allocation for coroutine state machine
- Handler Calls: Static dispatch with dynamic typing for return values
- Type Safety: Compile-time checked effects, runtime type verification for replies
- Performance Cost: Comparable to
async/await
but with more flexibility
- Single Allocation per Computation: One heap allocation for the coroutine state
- Stack-Safe: Uses coroutines instead of recursion for deep effect chains
- No GC Pressure: All allocations are explicit and bounded
- Dynamic Typing Overhead:
Box<dyn Any + Send>
for handler return values - Thread Safety: Send trait enables zero-cost transfer between threads
Costs:
- One heap allocation per effectful computation (for coroutine state)
- Dynamic type checking when extracting handler replies (
Reply::take()
) - Coroutine suspend/resume overhead (similar to async/await)
- Pattern matching on effect operations
Optimizations:
- Minimize effect frequency: Batch operations when possible
- Use concrete handler types: Avoid trait objects where possible
- Profile critical paths: Effects add overhead to hot loops
- Consider alternatives: For tight loops, direct function calls may be faster
We welcome contributions! Please see our contributing guidelines:
- Fork the repository
- Create a feature branch:
git checkout -b my-feature
- Make your changes
- Add tests for new functionality
- Ensure all tests pass:
cargo test
- Run clippy:
cargo clippy --all-targets -- -D warnings
- Format your code:
cargo fmt
- Submit a pull request
The project includes a comprehensive Makefile for development:
# Quick development workflow
make dev # Format, check, and test
make ci-local # Run full CI pipeline locally
make examples # Check all examples compile
make test-error-detection # Verify error cases work correctly
# Individual tasks
make test # Run all tests
make clippy # Run linting (matches CI)
make fmt # Format code
make doc # Build documentation
- Documentation: Improve examples and guides
- Performance: Benchmarks and optimizations
- Testing: Additional test cases and property tests
- Examples: Real-world usage examples
- Integrations: Async/await compatibility, tokio integration
This project is licensed under the MIT License - see the LICENSE file for details.
- Gordon Plotkin and Matija Pretnar for the theoretical foundations of algebraic effects
- The Rust Community for excellent tools and ecosystem
- OCaml's Effects for inspiration on practical algebraic effects
- Koka Language for demonstrating effect types in systems programming
- Eff Language for the original algebraic effects implementation
- Algebraic Effects and Handlers - Tutorial introduction
- An Introduction to Algebraic Effects and Handlers - Comprehensive overview
- Handling Asynchronous Exceptions with Algebraic Effects - Advanced applications
- Eff Language - The original algebraic effects language
- Koka - Microsoft's research language with effect types
- OCaml 5.0 Effects - Effects in OCaml
- Unison - Functional language with algebraic effects
- Algebraic Effects for the Rest of Us - Accessible introduction
- Effects in Rust - Rust-specific discussions
- What are Algebraic Effects? - Practical explanation
Built with โค๏ธ and Rust ๐ฆ