Typed RPC with tsrpc
A prototype that turns a Specta-exported Rust enum into a typed RPC protocol for TypeScript.
tsrpc is a small prototype that uses Specta to make
one Rust enum do three jobs: describe every RPC method, dispatch requests on the server, and
generate the TypeScript union a client calls. It is an experiment, not a published crate.
This page describes the repository as it stands: a single commit, roughly a
hundred lines of Rust and TypeScript, and several deliberate gaps. It targets
Specta 2.0.0-rc.22; current Specta renamed TypeCollection to Types and
changed the exporter signature.
The idea
An RPC protocol is usually three separate things: a schema, a server dispatcher, and a generated
client. tsrpc collapses them into one declaration:
- The enum variant name (
Echo) is the method name. - The variant payload is
RpcMessage<I, O>, which carries an input of typeIand an output of typeO. #[derive(Type)]exports the whole enum to TypeScript as a union of variant objects.
The generated union is the protocol. If the Rust enum changes, the TypeScript client stops type-checking until it is updated.
Define the protocol
RpcMessage is a two-state payload with a private placeholder:
The Empty variant is an implementation detail: handle needs to move the payload out of
&mut self before awaiting the handler, and mem::replace needs something to leave behind.
#[serde(skip)] keeps it out of the generated type, and the custom serialization below never
puts it on the wire.
The server dispatch is one match arm per method:
RpcMessage::handle is the state transition. It takes any async function from I to O:
Input goes in, the handler runs, Output replaces it. Calling handle again on an already-handled
message is harmless: Output passes through unchanged.
Generate the bindings
The exporter registers Rpc and writes the client-side types:
That produces:
Three Specta behaviours are doing the work here:
- A generic Rust enum becomes a generic TypeScript type, so
RpcMessage<I, O>is exported once rather than monomorphised per method. - The unit type
()becomesnull. - The skipped
Emptyvariant is absent, so the generated union has exactly the two real states.
Call it from TypeScript
The generated file intentionally contains no runtime. tsrpc keeps a hand-written client helper
that derives a per-method signature from the union:
UnionToIntersection turns the union of variant objects into one object whose keys are every
method name. Extract<Rpc, Record<K, any>>[K] picks the variant for a key, then
ExtractInput/ExtractOutput read its Input or Output member. The result is a call whose
arguments and return type depend on the method:
Without the generated union, call would need a hand-written interface per method. With it, the
Rust enum is the only place method names and payload types are declared.
The wire format
The interesting move is that RpcMessage serializes differently depending on direction, so the
same variant works for request and response.
Deserialization always produces Input, because a server only ever deserializes requests.
Serialization only handles Output, because a server only ever serializes responses. The payload
is written transparently, without an Input/Output tag:
The direction is implied by who sent the message, and the type carries which side owns which shape. That is why one enum variant can describe both halves of the call.
Running the prototype confirms both paths:
Add a method
Adding an RPC method means adding a variant and an arm. A tuple input and a named struct both export cleanly:
Specta regenerates the union, including a named User type and a tuple mapped to a TypeScript
tuple:
The client helper needs no changes. call picks up the new keys automatically:
The server side is the same pattern:
What the prototype leaves out
tsrpc is useful as a design sketch, not as a library:
- No transport.
callthrows, and the server example reads JSON values directly. - One direction only. Input serialization is
todo!(); a Rust client would need the mirror image of the server's impls. - No error channel. Handlers cannot fail, so every response is a success.
- No streaming or cancellation. One request, one response, one
RpcMessage. - JSON only. The state machine is independent of the format, but the prototype uses Serde JSON.
- A placeholder variant.
Emptyis a workaround formem::replace; a real implementation would seal the variants or useOptionso invalid states cannot be constructed. - An old Specta API. It pins
=2.0.0-rc.22and usesTypeCollection; current Specta usesTypesand passes a serde format to the exporter.
What survives all of those caveats is the core idea: export the protocol shape instead of
writing it twice. Specta already exports data types; tsrpc shows that an enum can export the
calls between them too.
See also
- tsrpc on GitHub
- Core: deriving
Type, collecting types and Serde support. - Frameworks: how Tauri Specta and rspc generate typed calls.
