---
title: Dispatch JSON-RPC via an internally tagged enum with a macro
impact: MEDIUM-HIGH
impactDescription: eliminates hand-rolled method dispatch that drifts from typed param validation
tags: proto, serde, jsonrpc, macros
---

## Dispatch JSON-RPC via an internally tagged enum with a macro

Hand-writing a dispatcher that matches `request["method"]` and then runs `serde_json::from_value::<FooParams>(request["params"])` drifts silently every time a method is added — the match, the param type, and the response type each live in a different file. Codex defines a macro (`client_request_definitions!`) that generates a `#[serde(tag = "method", rename_all = "camelCase")]` enum where each variant carries `request_id` and `params` as struct fields. Internally tagging lines up with JSON-RPC's wire format — one `serde_json::from_value` call validates method and parses typed params in a single pass.

**Incorrect (hand-rolled dispatch drifts from typed params):**

```rust
let method = request["method"].as_str().unwrap();
match method {
    "initialize" => {
        let params: InitializeParams =
            serde_json::from_value(request["params"].clone())?;
        handler.initialize(params).await
    }
    "threadStart" => {
        let params: ThreadStartParams =
            serde_json::from_value(request["params"].clone())?;
        handler.thread_start(params).await
    }
    // Missed any? Silent ignore. Added a method? Edit three places.
}
```

**Correct (internally tagged enum, one from_value call):**

```rust
// app-server-protocol/src/protocol/common.rs — macro expansion
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "method", rename_all = "camelCase")]
pub enum ClientRequest {
    Initialize {
        #[serde(rename = "id")]
        request_id: RequestId,
        params: InitializeParams,
    },
    ThreadStart {
        #[serde(rename = "id")]
        request_id: RequestId,
        params: ThreadStartParams,
    },
    /* 30+ more — each generated by the macro */
}

impl TryFrom<JSONRPCRequest> for ServerRequest {
    type Error = serde_json::Error;
    fn try_from(value: JSONRPCRequest) -> Result<Self, Self::Error> {
        serde_json::from_value(serde_json::to_value(value)?)
    }
}
```

The macro invocation pairs each variant with both a params *and* response type, so you cannot add a method without declaring both sides of the conversation. The `TryFrom<JSONRPCRequest>` becomes a one-liner because all the work is in the serde tag attribute.

Reference: `codex-rs/app-server-protocol/src/protocol/common.rs:86`.
