Skip to content

Improve type safety for request/reply message handling #144

@ElFantasma

Description

@ElFantasma

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions