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.

Prototype

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:

pub enum Rpc {
    Echo(RpcMessage<String, String>),
    LogOnServer(RpcMessage<String, ()>),
}
  • The enum variant name (Echo) is the method name.
  • The variant payload is RpcMessage<I, O>, which carries an input of type I and an output of type O.
  • #[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:

use serde::{Deserialize, Serialize};
use specta::Type;
use std::mem;

#[derive(Type)]
pub enum RpcMessage<I, O> {
    Input(I),
    Output(O),
    #[serde(skip)]
    Empty,
}

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:

#[derive(Type, Serialize, Deserialize)]
pub enum Rpc {
    Echo(RpcMessage<String, String>),
    LogOnServer(RpcMessage<String, ()>),
}

impl Rpc {
    pub async fn handle(&mut self) {
        match self {
            Rpc::Echo(m) => m.handle(echo).await,
            Rpc::LogOnServer(m) => m.handle(log_on_server).await,
        }
    }
}

async fn echo(input: String) -> String {
    format!("Hello, {input}")
}

async fn log_on_server(msg: String) {
    println!("Message from client: {msg}");
}

RpcMessage::handle is the state transition. It takes any async function from I to O:

impl<I, O> RpcMessage<I, O> {
    pub async fn handle(&mut self, handler: impl AsyncFn(I) -> O) {
        *self = match mem::replace(self, Self::Empty) {
            RpcMessage::Input(i) => RpcMessage::Output(handler(i).await),
            RpcMessage::Output(o) => RpcMessage::Output(o),
            RpcMessage::Empty => unreachable!(),
        };
    }
}

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:

specta_typescript::Typescript::new()
    .export_to("./bindings.ts", TypeCollection::default().register::<Rpc>())
    .unwrap();

That produces:

// This file has been generated by Specta. DO NOT EDIT.

export type Rpc =
  | { Echo: RpcMessage<string, string> }
  | { LogOnServer: RpcMessage<string, null> };

export type RpcMessage<I, O> = { Input: I } | { Output: O };

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 () becomes null.
  • The skipped Empty variant 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:

import type { Rpc } from "./bindings";

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I,
) => void
  ? I
  : never;
type RpcKeys = keyof UnionToIntersection<Rpc>;

type ExtractInput<T> = T extends { Input: infer I } ? I : never;
type ExtractOutput<T> = T extends { Output: infer O } ? O : never;

export type Input<K extends RpcKeys> = ExtractInput<
  Extract<Rpc, Record<K, any>>[K]
>;
export type Output<K extends RpcKeys> = ExtractOutput<
  Extract<Rpc, Record<K, any>>[K]
>;

export function call<K extends RpcKeys>(
  key: K,
  input: Input<K>,
): Promise<Output<K>> {
  // The prototype stops here: the transport is not written.
  throw new Error("Not implemented");
}

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:

const result: Promise<string> = call("Echo", "bruh");
const onServer: Promise<null> = call("LogOnServer", "Some Message");

// Type error: Echo takes a string, not a number.
const wrong: Promise<string> = call("Echo", 42);

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.

impl<I, O: Serialize> Serialize for RpcMessage<I, O> {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        match self {
            RpcMessage::Input(_) => todo!(), // a client would need the inverse impl
            RpcMessage::Output(m) => m.serialize(serializer),
            RpcMessage::Empty => unreachable!(),
        }
    }
}

impl<'de, I: Deserialize<'de>, O> Deserialize<'de> for RpcMessage<I, O> {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        Ok(RpcMessage::Input(I::deserialize(deserializer)?))
    }
}

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:

client → server   {"Echo": "Oscar"}
server → client   {"Echo": "Hello, Oscar"}

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:

{
  "Echo": "Hello, Oscar"
}
Message from client: Hello From Server
{
  "LogOnServer": null
}

Add a method

Adding an RPC method means adding a variant and an arm. A tuple input and a named struct both export cleanly:

#[derive(Type, Serialize, Deserialize)]
pub struct User {
    name: String,
    age: u32,
}

#[derive(Type, Serialize, Deserialize)]
pub enum Rpc {
    Echo(RpcMessage<String, String>),
    LogOnServer(RpcMessage<String, ()>),
    Add(RpcMessage<(i32, i32), i32>),
    Greet(RpcMessage<User, String>),
}

Specta regenerates the union, including a named User type and a tuple mapped to a TypeScript tuple:

export type Rpc =
  | { Echo: RpcMessage<string, string> }
  | { LogOnServer: RpcMessage<string, null> }
  | { Add: RpcMessage<[number, number], number> }
  | { Greet: RpcMessage<User, string> };

export type RpcMessage<I, O> = { Input: I } | { Output: O };

export type User = { name: string; age: number };

The client helper needs no changes. call picks up the new keys automatically:

const sum: Promise<number> = call("Add", [20, 22]);
const greeting: Promise<string> = call("Greet", { name: "Oscar", age: 30 });

The server side is the same pattern:

Rpc::Add(m) => m.handle(|(a, b)| async move { a + b }).await,
Rpc::Greet(m) => m.handle(|user: User| async move { format!("Hi, {}", user.name) }).await,

What the prototype leaves out

tsrpc is useful as a design sketch, not as a library:

  • No transport. call throws, 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. Empty is a workaround for mem::replace; a real implementation would seal the variants or use Option so invalid states cannot be constructed.
  • An old Specta API. It pins =2.0.0-rc.22 and uses TypeCollection; current Specta uses Types and 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