Typesafe RPC without codegen

May 8, 2021 · app devtool

This post shares a very small Typescript RPC (remote procedure call) library, which I've named roots-rpc (because it's used for Seattle Tree Guide). If you know anything about RPC libraries, your reaction is probably "why must the world have another one of those?" Today your options include the popular choices grpc, Thrift, and json-rpc. So why did I make another one?

The RPC framework lifecycle

Let's take a step back and recall how we would achieve cross-process communication without an RPC framework. A basic and ubiquitous example is HTTP, where the two processes are the web server and the user's browser. HTTP defines a text-based protocol where the two sides send plain text to each other with a specified format. The basic rules of this format have structured the world wide web since its genesis 30 years ago.

HTTP serves as a crucial layer in web architecture that decouples clients from servers. After all, you're not using an Apache Browser or an nginx Browser to see this page, just as I'm not using a Firefox server or Safari server to show it to you. But it's easy to list shortcomings of using HTTP for developers of modern (read: complicated) applications that need to be overcome anew for every usage of one-to-one client-server coordination. What data can the two sides expect from each other? What is the right request path? How are errors interpreted and propogated? These are exactly the problems which RPC frameworks solve by defining a structured way for data to be formatted and transmitted back and forth between processes.

Over time these RPC frameworks branched out and expanded themselves, tackling far more problem classes than I've mentioned here. grpc has evolved to handle just about everything, including cross-language support, load balancing, streaming, authentication, and proxying. That's quite the handful, and you can find similar feature lists for its peers like Thrift. We create these projects to solve a specific need, then the project grows in every direction to incorporate nice-to-haves, and then finally we reach the stage where people come along and say why is this so bloated? And so the wheel is re-invented, again and again.

A simpler way

My thesis is that all of these features are overkill for lightweight projects. We don't need such heavy-handed frameworks if we impose a constraint that greatly simplifies the usage pattern: use Typescript for both client and server.

Sharing the language lets us build a type-safe system without using code generation. Code generation is a common staple of RPC frameworks because it lets a single interface be used across multiple languages. But if we can use the same language on the client and the server, why not? This makes distribution easier because the RPC layer becomes a pure library, rather than a codegen script plus runtime code. Plug and play, if you will.

This works by leveraging io-ts's ability to validate types at runtime based on static type information, which lets the client and server verify that they're passing each other data correctly.

Example

Lets say we have this function example that we want to implement on the web server and call from our browser client through socket.io.

async function example(x: number): Promise<number> {
    return x + 3;
}

With this RPC library, we declare the call interface, then import that to both the server and client.

// interface.ts
import * as t from 'io-ts';

export const RpcInterface = {
  example: () => ({
      // using numbers for brevity, but these can be anything that's JSON-serializable
      i: t.number,
      o: t.number,
  }),
};
// server.ts
import { RpcServer, SocketTransport } from 'roots-rpc';
import { RpcInterface } from './interface';

// socket is a socket.io connection to the browser client
const rpc = new RpcServer(new SocketTransport(socket));
rpc.register(RpcInterface.example, async (x: number) => x + 3);
// client.ts
import { RpcClient, SocketTransport } from 'roots-rpc';
import { RpcInterface } from './interface';

// socket is a socket.io connection to the web server
const client = new RpcClient(new SocketTransport(socket));
const example = client.connect(RpcInterface.example);

const result = await example(10);
// result: 13

That's it!

The input and output types defined in RpcInterface verify the function signatures at build time for both the server-side register and client-side connect functions. So you can't accidentally call the wrong function unless you've got more than one with the same signature.

Note that a websocket version of this example is given in my project's README.

To include:
npm add roots-rpc

Previous: Technical dive into Streetwarp
Next: Networking checklist for AWS Lambda
View Comments