Skip to main content
Deno 2 is finally here ๐ŸŽ‰๏ธ
Learn more

typed-rpc

npm bundle size

Lightweight JSON-RPC solution for TypeScript projects that comes with the following features and non-features:

  • ๐Ÿ‘ฉโ€๐Ÿ”ง Service definition via TypeScript types
  • ๐Ÿ“œ JSON-RPC 2.0 protocol
  • ๐Ÿ•ต๏ธ Full IDE autocompletion
  • ๐Ÿชถ Tiny footprint (< 1kB)
  • ๐Ÿšš Support for custom transports
  • ๐ŸŒŽ Support for Deno and edge runtimes
  • ๐Ÿšซ No code generation step
  • ๐Ÿšซ No dependencies
  • ๐Ÿšซ No batch requests
  • ๐Ÿšซ No runtime type-checking
  • ๐Ÿšซ No IE11 support
  • ๐Ÿฅฑ No fancy project page, just this README

Philosophy

The philosophy of typed-rpc is to strictly focus on the core functionality and keep things as simple as possible. The whole library basically consists of two files, one for the client and one for the server.

You wonโ€™t find any unnecessarily complex concepts like middlewares, adapters, resolvers, transformers, queries or mutations.

(If you want queries and mutations though, weโ€™ve got you covered.)

And instead of having adapters for all the different servers, meta frameworks or edge runtimes, we provide a generic package that is request/response agnostic and leave the wiring up to the user.

Basic Usage

Create a service in your backend and export its type, so that the frontend can access type information:

// server/myService.ts

export const myService = {
  hello(name: string) {
    return `Hello ${name}!`;
  },
};

export type MyService = typeof myService;

Note Of course, the functions in your service can also be async.

Create a server with a route to handle the API requests:

// server/index.ts

import express from "express";
import { rpcHandler } from "typed-rpc/express";
import { myService } from "./myService.ts";

const app = express();
app.use(express.json());
app.post("/api", rpcHandler(myService));
app.listen(3000);

Note You can also use typed-rpc in servers other than Express. Check out the docs below for examples.

On the client-side, import the shared type and create a typed rpcClient with it:

// client/index.ts

import { rpcClient } from "typed-rpc";

// Import the type (not the implementation!)
import type { MyService } from "../server/myService";

// Create a typed client:
const client = rpcClient<MyService>("http://localhost:3000/api");

// Call a remote method:
console.log(await client.hello("world"));

Thatโ€™s all it takes to create a type-safe JSON-RPC API. ๐ŸŽ‰

Demo

You can play with a live example over at StackBlitz:

Open in StackBlitz

Advanced Usage

Accessing the incoming request

Sometimes itโ€™s necessary to access the request object inside the service. A common pattern is to define the service as class and create a new instance for each request:

export class MyServiceImpl {
  /**
   * Create a new service instance for the given request headers.
   */
  constructor(private headers: Record<string, string | string[]>) {}

  /**
   * Echo the request header with the specified name.
   */
  async echoHeader(name: string) {
    return this.headers[name.toLowerCase()];
  }
}

export type MyService = typeof MyServiceImpl;

Then, in your server, pass a function to rpcHandler that creates a service instance with the headers taken from the incoming request:

app.post(
  "/api",
  rpcHandler((req) => new MyService(req.headers))
);

Sending custom headers

A client can send custom request headers by providing a getHeaders function:

const client = rpcClient<MyService>({
  url: "http://localhost:3000/api",
  getHeaders() {
    return {
      Authorization: auth,
    };
  },
});

Note The getHeaders function can also be async.

Aborting requests

You can abort requests by passing the Promise to .$abort() like this:

const client = rpcClient<HelloService>(url);

const res = client.hello("world");
client.$abort(res);

CORS credentials

To include credentials in cross-origin requests, pass credentials: 'include' as option.

Custom transport

By default, the client uses the global fetch implementation to perform requests. If you want to use a different mechanism, you can specify custom transport:

const client = rpcClient<MyService>({
  transport: async (req: JsonRpcRequest, abortSignal: AbortSignal) => {
    return {
      error: null,
      result: {
        /* ... */
      },
    };
  },
});

Support for other runtimes

The generic typed-rpc/server package can be used with any server framework or (edge-) runtime.

Fastify

With Fastify, you would use typed-rpc like this:

import { handleRpc, isJsonRpcRequest } from "typed-rpc/server";

fastify.post("/api", async (req, reply) => {
  if (isJsonRpcRequest(req.body)) {
    const res = await handleRpc(req.body, new Service(req.headers));
    reply.send(res);
  }
});

Open in StackBlitz

Deno

๐Ÿฆ• You can also use typed-rpc in Deno like in this example.

Note This package is also published under https://deno.land/x/typed_rpc

Next.js

Hereโ€™s an example that uses typed-rpc in a Next.js project:

Open in StackBlitz

Clodflare Workers

In a Cloudflare Worker you can use typed-rpc like this:

import { handleRpc } from "typed-rpc/server";
import { myService } from "./myService";

export default {
  async fetch(request: Request) {
    const json = await request.json();
    const data = await handleRpc(json, myService);
    return new Response(JSON.stringify(data), {
      headers: {
        "content-type": "application/json;charset=UTF-8",
      },
    });
  },
};

Runtime type checking

Warning Keep in mind that typed-rpc does not perform any runtime type checks.

This is usually not an issue, as long as your service can handle this gracefully. If you want, you can use a library like type-assurance to make sure that the arguments you receive match the expected type.

React hooks

While typed-rpc itself does not provide any built-in UI framework integrations, you can pair it with react-api-query, a thin wrapper around TanStack Query. A type-safe match made in heaven. ๐Ÿ’•

License

MIT