InferRPC is a TypeScript library that facilitates writing API clients and servers that communicate by a protocol described by a shared schema. InferRPC provides the following benefits:
- Compile-time type checking of both client and server code.
- Run-time validation of requests and responses to ensure they comply with the schema's basic types as well as extended validation rule (e.g. does this string represent a URL?)
- Zero code generation.
- Easy integration into any backend framework. Currently, Koa and NextJS are supported.
- Extensible to support arbitrary serialization protocols (only JSON is currently supported).
InferRPC uses Zod for expressing request/response schemas and for validating payloads.
To implement an API client and/or server using InferRPC, you typically start by implementing a static data structure describing the schema. InferRPC uses type inference to infer the request/response types from this data structure.
InferRPC schemas are TypeScript dictionaries mapping method names to request and response schema.
In TypeScript, a schema is a data structure that adheres to the following type signature:
import * as z from "zod";
type AbstractSchemaType = Record<
string,
{ req: z.ZodType<any>; res: z.ZodType<any> }
>;
Here's an example schema with 2 methods:
sayHi
, which takes aname
parameter and returns a string.divide
, which takes two numbers and returns the result of dividing num1 by num2.
import * as z from "zod";
export const testSchema = {
sayHi: {
req: z.object({
name: z.string(),
}),
res: z.string(),
},
divide: {
req: z.object({
num1: z.number(),
num2: z.number(),
}),
res: z.number(),
},
};
This example only uses basic types (strings and numbers) but Zod lets you express more complex validation rules, such as whether a string contains a date, a URL, or an email address.
A complete Koa-based server for this schema can be implemented with the following code snippet:
import Koa from "koa";
import Router from "koa-router";
import { Server } from "net";
import { createKoaRoute } from "infer-rpc/koaAdapter";
import { ApiHttpError } from "infer-rpc/types";
import { testSchema } from "./testSchema";
const createServer = (port: number): Server => {
const koa = new Koa();
const apiRouter = new Router({
prefix: pathPrefix,
});
createKoaRoute(apiRouter, testSchema, "divide", async ({ num1, num2 }) => {
if (num2 === 0) {
throw new ApiHttpError("Can't divide by 0", 400);
}
return num1 / num2;
});
createKoaRoute(apiRouter, testSchema, "sayHi", async ({ name }) => {
return "Hi " + name;
});
koa.use(apiRouter.allowedMethods());
koa.use(apiRouter.routes());
return koa.listen(port);
};
InferRPC provides an alternative way to implement the server by inferring the interface derived from the schema. When you create a server that implements the inferred interface, the TypeScript compiler automatically checks for you that you've implemented all the required methods.
Here's an example snippet based on the code snippet above:
import { createKoaRoutes } from "infer-rpc/koaAdapter";
import type { InferInterace } from "infer-rpc/types";
...
const server: InferInterface<typeof testSchema> = {
sayHi({ name }) {
return "Hi " + name;
},
divide({ num1, num2 }) {
if (num2 === 0) {
throw new ApiHttpError("Can't divide by 0", 400);
}
return num1 / num2;
},
};
createKoaRoutes(apiRouter, testSchema, server);
NextJS is also supported. This snippet shows to implement a NextJS API handler:
import { createNextHandler } from "InferRPC/nextAdapter"
export default createNextHandler(
testSchema,
"divide",
async ({ num1, num2 }) => {
if (num2 === 0) {
throw new ApiHttpError("Can't divide by 0", 400);
}
return num1 / num2;
}
);
});
Below are a few screenshots from VSCode highlighting the benefits of how InferRPC leverages TypeScript's type checking.
-
Your editor's auto-complete feature can help you choose a valid method name:
-
If you try to implement an invalid method name, you get an error:
-
If you try to add an invalid parameter name, you get an error:
-
If your method returns an invalid response type, you get an error:
Implementing a client that adheres to the schema is easy. Here's an example:
const testClient = async () => {
const client = new TypedHttpClient("http://localhost:3001/api", testSchema);
const result = await client.call("divide", { num1: 10, num2: 2 });
console.log(result);
};
The following screenshots show the benefits of InferRPC's static type checking.
-
Your editor can help you auto-complete the valid method names:
-
If the client sends an invalid request to the server, the server will return a Zod validation error, as shown in this snippet (note that Zod lets you customize error messages if you want them to be more user friendly):
InferRPC also supports statically typed peer-to-peer protocols for usage with bi-directional streams such as WebSockets.
Peer-to-Peer protocols differ from client-server protocol in that they don't have a pre-defined request-response flow. Instead, each peer may send and receive messages at any time. This leads to a different schema definition:
type PeerSchema = Record<string, z.ZodType<any>>;
This schema is a mapping between method names and payloads, which can be any Zod type.
Here's an example, inspired by the client/server example above:
const schema1 = {
divide: z.object({
num1: z.number(),
num2: z.number(),
}),
sayHi: z.object({
name: z.string(),
}),
};
const schema2 = {
divideResult: z.number(),
sayHiResult: z.string(),
};
To use InferRFC in a p2p application, create a Peer object. Its constructor takes 2 schemas: the schema for the incoming messages, the schema for the outgoing messages, and a listener for errors. Here's an example:
const listener: PeerListener = {
onMissingHandler: (msgType) => {
console.error("missing handler", msgType);
},
onParseError: (error) => {
console.error("parse error", error);
},
};
const peer1 = new Peer(schema1, schema2, listener);
const peer2 = new Peer(schema2, schema1, listener);
In this example, the two Peers are initialized. The Peers use opposite schemas for incoming and outgoing messages, but it's also possible for Peers to use the same schema if their protocol is symmetric.
The following snippet shows how to simulate bi-directional communication between peers:
peer1.setHandler("divide", async ({ num1, num2 }) => {
peer2.onMessage(peer1.serialize("divideResult", num1 / num2));
});
peer1.setHandler("sayHi", async ({ name }) => {
peer2.onMessage(peer1.serialize("sayHiResult", "Hi " + name));
});
peer2.setHandler("divideResult", async (num) => {
console.log(num); // prints '2'
});
peer2.setHandler("sayHiResult", async (result) => {
console.log(result); // prints "Hi Sarah";
});
peer1.onMessage(peer2.serialize("divide", { num1: 10, num2: 5 }));
peer1.onMessage(peer2.serialize("sayHi", { name: "Sarah" }));
Just as with the client/server examples above, InferRPC ensures types as statically checked and your IDE can auto-complete valid values for you.
The same features work for serializing messages before they are sent over the wire.