tRPC

tRPC is a TypeScript library for remote procedure calls. It was released in 2020, primarily targeting web applications, and by 2022, its server package had over 100,000 downloads a week.

In a web application, the code that runs in the browser and the code on the server are often both written in TypeScript, by the same developers, in one repository. tRPC’s documentation calls these full-stack TypeScript monorepo projects. The two sides still talk over an API, and tRPC is a way to make that API look like local function calls. The added benefit is that tRPC provides type safety by ensuring that the client and server share the same TypeScript types.

Let’s use tRPC for the application’s call to the runner. On the runner, we write runSubmission as a procedure in a router, an object that lists the runner’s procedures. The router does the job of the procedures object in the server stub we wrote earlier.

tRPC has two main kinds of procedure. A query reads data and does not change it. A mutation creates, updates, or deletes data. runSubmission changes nothing on the runner, so by this definition it is a query. But tRPC sends a query as a GET and puts its input in the URL. The input of runSubmission is Nadia’s code and the problem’s tests. That is already a few kilobytes. Servers limit how long a URL can be. For example, nginx, a common choice for a load balancer, rejects a request whose first line is longer than 8 KB by default. It is possible to configure tRPC to send queries as POST with their input in the body, but that is not idiomatic. So I make runSubmission a mutation, and its input goes in the body.

Here is the runner’s router:

import { initTRPC } from "@trpc/server";
import { z } from "zod";

const t = initTRPC.create();

export const runnerRouter = t.router({
  runSubmission: t.procedure
    .input(
      z.object({
        language: z.string(),
        code: z.string(),
        tests: z.array(
          z.object({
            name: z.string(),
            args: z.array(z.unknown()),
            expected: z.unknown(),
          }),
        ),
      }),
    )
    .mutation(({ input }) => runSubmission(input.language, input.code, input.tests)),
});

export type RunnerRouter = typeof runnerRouter;

.input says what runSubmission accepts. z.object, z.string, and z.array come from Zod, a library that checks a value while the program runs. If a call is missing tests, or sends a number as code, the runner does not call runSubmission, and the response is 400 Bad Request. In our JSON-RPC design, this was the error -32602, wrong arguments.

Shared types

The last line of the router exports RunnerRouter. It is a TypeScript type that describes every procedure in the router: its name, the type of its input, and the type of what it returns. TypeScript works out this type from the router’s code.

The application imports the type and creates a tRPC client from it:

import { createTRPCClient, httpLink } from "@trpc/client";
import type { RunnerRouter } from "../runner/router";

const runner = createTRPCClient<RunnerRouter>({
  links: [httpLink({ url: "http://runner.codedrill.internal/trpc" })],
});

const result = await runner.runSubmission.mutate({
  language: "java",
  code,
  tests: problem.tests,
});

runner does what our client stub did earlier. runner.runSubmission.mutate(...) sends the call to the procedure named runSubmission and returns what the procedure returned. tRPC builds this object for us, so we do not write a function for each procedure.

The type lets the compiler check every call. When we write runner.runSubmission.mutate(...), the TypeScript compiler checks that the router has a procedure named runSubmission, that its input has language, code, and tests with the right types, and what it returns. If we rename runSubmission on the runner, or change its input, the application does not compile until we change the call too. This is the added benefit of type safety that I mentioned earlier.

Compare this with gRPC. There, the .proto file describes the interface, and a tool generates the stubs from it. With tRPC, the router’s own code describes the interface. There is no IDL, and no step generates code.

Over HTTP

We mount the runner’s router at /trpc. Each procedure then has its own path. To run Nadia’s code, the application sends:

POST /trpc/runSubmission HTTP/1.1
Host: runner.codedrill.internal
Content-Type: application/json

{
  "language": "java",
  "code": "public static boolean isBalanced(String s) { ... }",
  "tests": [ ... ]
}

The body is the input itself. In our JSON-RPC call, the body also named the procedure, in method. With tRPC, the path names it. The runner responds with 200 OK, and the body holds what runSubmission returned.

When a call fails, tRPC sends a status that says why. A call without tests gets 400 Bad Request. A procedure that throws an unexpected exception gets 500 Internal Server Error. Our JSON-RPC design sent 200 OK with every response object and kept the outcome in the body. With tRPC, the status also says whether the call succeeded.

Subscriptions

tRPC has a third kind of procedure, a subscription, for a server that pushes data to the client. The client keeps a connection open, and tRPC sends the data over Server-Sent Events or a WebSocket, both of which we saw earlier. tRPC’s documentation recommends Server-Sent Events, because they need no WebSocket server. CodeDrill does not need a subscription as it is designed now.

When tRPC fits

We used tRPC for the call between two of our services, the application and the runner (assuming both are written in TypeScript). More broadly, tRPC is used between a web client and its own server. We could consider designing all of CodeDrill with tRPC (again, assuming it is all written in TypeScript). The application would offer procedures to Nadia’s client in place of its REST API, and the runner would offer runSubmission in place of its JSON-RPC API.

CodeDrill already has a REST API, and there is no reason to replace it with tRPC. The JSON-RPC API between the application and the runner serves the requirements of the system well, so I would leave it as it is. Broadly speaking, I would choose tRPC when one team writes both the browser client and its server in TypeScript, and no other client calls the server.