What a Type-Safe Socket.IO API Should Feel Like in Rust
At $job, we use Rust -> TypeScript codegen pretty widely.
For our HTTP endpoints, utoipa and axum make this seamless: the router definition serves as the OpenAPI spec, which in turn generates fully typed client libraries.
When we introduced Socket.IO, naturally I wanted to find a codegen answer to avoid rewriting the Rust types in TypeScript and manually aligning event names and payloads on both sides. But Socket.IO events are typically stringly typed.
Generating payload interfaces does not give you a shared protocol. The compiler has no opinion on whether "queued" actually exists on the backend, which direction an event is allowed to travel, or which namespace it belongs to. A client can emit an event the server only expects to send, or deserialize an unexpected payload shape at runtime.
So, what should a Socket.IO API look like when the entire protocol is enforced at compile time in Rust?
The Interface I Wanted
The experience I had in mind centered around a declarative API:
let router = EventRouter::new()
.on(Chat::COMMAND, on_command)
.on(Chat::INPUT, on_input);
socket.emit(Chat::QUEUED, &queued)?;
Under the hood, the compiler should know three things:
COMMANDis sent by the client and carriesWireCommand.QUEUEDis sent by the server and carriesQueuedPayload.- Both belong strictly to the
Chatnamespace.
If any of those assumptions are violated, the compiler should reject the code immediately:
socket.emit(Chat::COMMAND, &command); // Error: wrong direction (client-to-server only)
socket.emit(Chat::QUEUED, &other); // Error: wrong payload type
monitor_socket.emit(Chat::QUEUED, &q); // Error: wrong namespace
The system needed to satisfy two core requirements simultaneously: local compile-time safety in Rust, and schema discovery so a generator could mirror the protocol to TypeScript.
Two Approaches to the Protocol
Approach 1: The Macro DSL
The immediate, instinctive answer in Rust is to reach for a declarative macro:
socketio_protocol! {
pub Chat {
namespace "/chat";
// every event, direction, payload, auth rule
}
}
This is promising for basic events. But as protocol considerations crept in, the macro idea fractured:
- Acknowledgements required callback syntax.
- Binary payloads needed explicit representation.
- Duplex events required different payload definitions for each direction.
- Payload-free events (
socket.emit("ping")) behave differently on the wire from empty objects (socket.emit("ping", {})), needing separate clauses.
Because of the macro usage, language server support degraded, and compiler diagnostics began pointing deep into macro expansion blocks rather than into my code.
Creating this mini-language inside macro_rules! or a procedural macro added more friction than it removed.
Approach 2: Events as Typed Values
Instead of inventing a macro DSL, the alternative is to rely on Rust's type system directly using phantom types and typed constants:
pub struct Chat;
impl Chat {
pub const COMMAND: ClientEvent<Self, WireCommand> =
ClientEvent::new("command");
pub const QUEUED: ServerEvent<Self, QueuedPayload> =
ServerEvent::new("queued");
pub const STREAM_CONTROL: DuplexEvent<Self, StreamCommand, StreamEvent> =
DuplexEvent::new("stream_control");
}
The descriptor types encode the operational rules directly:
ClientEvent<Chat, WireCommand>can be registered on an inbound router, but can never be emitted by the server.ServerEvent<Chat, QueuedPayload>can be emitted, but can never be attached to an inbound listener.DuplexEvent<Chat, StreamCommand, StreamEvent>cleanly models bidirectional control channels with distinct payloads per direction.- A
TypedSocket<Chat>enforces the namespace, refusing events defined forNotificationseven if their payload shapes are identical.
Comparing the two
String-based
socket.emit("queued", &payload)
|
+--> Event name is an arbitrary string literal
+--> Payload serialization checked, but receiver schema unchecked
+--> Direction unchecked (can emit client-only events)
`--> Any mismatch fails silently or errors at runtime on the client
Typed Values
socket.emit(Chat::QUEUED, &payload)
|
+--> Namespace check: TypedSocket<Chat> matches Chat::QUEUED? -> Verified
+--> Direction check: Chat::QUEUED implements ServerEmission? -> Verified
+--> Payload check: &payload matches QueuedPayload? -> Verified
`--> Emits "queued" with zero runtime overhead
The Architectural Split: Catalog vs. Router
Coming from HTTP frameworks like axum and utoipa, the router is typically the contract: you register routes, and documentation or client code is derived directly from that router.
I initially tried to make the Socket.IO runtime router serve as the source of truth for TypeScript generation. Unfortunately for this, real-time connections have distinct state phases. A connection waiting in a lobby should not handle events intended for an active session:
socket.install(waiting_events());
// later, after state transition:
socket.install(live_events());
If the runtime router were also the protocol specification, code generation would have to locate and merge multiple dynamic, phase-specific routers. Handlers valid across multiple phases would appear duplicated, and namespaces that only push server events wouldn't have an inbound router to inspect at all.
Splitting the Catalog from the Router:
impl Protocol for Chat {
type Auth = ChatAuthPayload;
const NAMESPACE: &'static str = "/chat";
fn events(catalog: &mut EventCatalog<Self>) {
catalog
.add(Self::COMMAND)
.add(Self::INPUT)
.add(Self::QUEUED)
.add(Self::STREAM_CONTROL);
}
}
The distinction:
- The Catalog defines what the namespace can do (the static contract for client codegen).
- The Router defines what a connection accepts right now (dynamic runtime dispatch).
Rust cannot enumerate associated constants on a struct at compile time without reflection or an opaque proc-macro. Rather than adding macro complexity, having events() explicitly list the constants keeps things transparent. It repeats an identifier once, but doesn't repeat event names, directions, or payload types.
I've made a prototype sketch for this post: stringless.
The resulting integration is pleasantly boring. Because the EventCatalog is an explicit, inspectable inventory of event names, directions, and payload types, a build script or CLI tool walks the catalog and emits standard Socket.IO event maps:
// ui/src/chat/generated.ts
import type { Socket } from "socket.io-client";
export interface ClientToServerEvents {
command: (payload: WireCommand) => void;
}
export interface ServerToClientEvents {
queued: (payload: QueuedPayload) => void;
stream_control: (payload: StreamEvent) => void;
}
export type ChatSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
On the frontend, you don't need a custom client wrapper or runtime layer. The standard socket.io-client library becomes completely type-safe:
import { io } from "socket.io-client";
import type { ChatSocket } from "./generated";
const socket: ChatSocket = io("/chat", { auth });
socket.emit("command", command); // Autocompleted & checked
socket.on("queued", (payload) => {
// payload is automatically typed as QueuedPayload
});
On the server, handler setup remains clean and familiar:
EventRouter::new().on(Chat::COMMAND, on_command);
socket.emit(Chat::QUEUED, &queued)?;
Going fully declarative with a macro DSL introduced unnecessary friction. But replacing raw event strings with typed descriptor constants and decoupling the protocol catalog from runtime routers gave compile-time verification without fighting the compiler.