-
Notifications
You must be signed in to change notification settings - Fork 5
Description
Problem
When defining message handling in Spawned, each request returns a typed response that is an Enum. Callers must match the complete enum even when only a subset of variants are possible for a given request:
enum Request { GetName, GetAge }
enum Reply { Name(String), Age(u32), NotFound }
// GetName can only return Name or NotFound, but Rust requires matching Age too
let reply = actor.request(Request::GetName).await?;
match reply {
Reply::Name(n) => println!("{}", n),
Reply::NotFound => println!("not found"),
Reply::Age(_) => unreachable!(), // 😫 Required but ugly
}The type system doesn't encode the relationship between a specific request variant and its expected reply variants.
Related: API Verbosity
Spawned currently requires 4 associated types even when some are unused:
impl Actor for MyActor {
type Request = Request;
type Message = Unused; // Required even if unused
type Reply = Reply;
type Error = std::fmt::Error;
}Compare to Actix which only requires type Context = Context<Self>.
Options
Option 1: Actix-style - One Type Per Message
Each message is its own struct with its own response type:
struct GetName(String);
struct GetAge(String);
trait TypedRequest: Send {
type Response: Send;
}
impl TypedRequest for GetName {
type Response = Option<String>; // Specific to GetName
}
// Caller gets exact type - no unreachable!
let name: Option<String> = actor.request(GetName("joe".into())).await?;Pros: Compile-time safety, clean caller code
Cons: More types to define, need impl Handler<Msg> per message type
Option 2: Typed Wrapper Methods (Works Today)
Hide enum matching inside typed API methods:
impl NameServer {
pub async fn find(handle: &mut ActorRef<Self>, key: String) -> Option<String> {
match handle.request(Request::Find { key }).await.ok()? {
Reply::Found { value } => Some(value),
Reply::NotFound => None,
_ => unreachable!(), // Hidden from user
}
}
}
// Caller - clean!
let name = NameServer::find(&mut handle, "joe".into()).await;Pros: Works today, no framework changes
Cons: Boilerplate in each actor, unreachable! still exists (just hidden)
Option 3: Derive Macro to Generate Typed Methods
A proc macro generates typed wrapper methods automatically:
#[derive(ActorMessages)]
enum Request {
#[reply(Option<String>)]
GetName { key: String },
#[reply(Option<u32>)]
GetAge { key: String },
}
// Generates:
// - fn get_name(&mut ActorRef, key: String) -> Result<Option<String>, Error>
// - fn get_age(&mut ActorRef, key: String) -> Result<Option<u32>, Error>Pros: Best of both worlds - enum definition, typed API
Cons: Adds proc macro dependency, compile time cost
Option 4: Handler Trait Per Message (Full Actix Pattern)
trait Handler<M: Message>: Actor {
fn handle(&mut self, msg: M, ctx: &ActorRef<Self>) -> M::Result;
}
impl Handler<GetName> for NameServer {
fn handle(&mut self, msg: GetName, _ctx: &ActorRef<Self>) -> Option<String> {
self.data.get(&msg.0).cloned()
}
}Pros: Most type-safe, extensible
Cons: Significant API redesign, diverges from Erlang gen_server model
Decision Criteria
- Compile-time type safety vs runtime
unreachable! - API verbosity vs explicitness
- Proc macro dependencies vs pure Rust
- Alignment with Erlang gen_server philosophy vs Actix patterns
References
- Actix Actor documentation: https://actix.rs/docs/actix/actor/
- Current NameServer example uses Option 2 pattern