Intro to tRPC: Integrated, full-stack TypeScript

The tRPC project brings together strong typing with server-side JavaScript capabilities in a single client-server interaction protocol. Let's see how it works.

Leap, jump, risk, adventure. A person leaping between two boulders.
Ipatov/Shutterstock

JavaScript has seen some breathtaking evolutionary leaps in its time. Among these were the introduction of server-side JavaScript with Node.js, and of strong typing with TypeScript. The tRPC project combines these two innovations to let developers define and interweave the client and server in a united syntax. Developers get type inference benefits for the entire stack without an intermediary. Let's take a look at tRPC and where it fits in your full-stack TypeScript toolbox.

TypeScript Remote Procedure Call

tRPC stands for Typescript Remote Procedure Call. Instead of producing an API definition for your back end with something like OpenAPI or GraphQL, tRPC directly infers and applies your TypeScript endpoints. It also applies the client-side parameters to the server. This arrangement makes good sense if you are already writing your entire stack in a strongly typed language like TypeScript. Why introduce another component to orchestrate what you can simply extrapolate?

tRPC’s creator Alex Johansson explains:

GraphQL is great, but it's non-trivial to grok how to add new functionality and to get fundamentals right (complexity analysis, field-based authz, dataloaders, schema design, etc.). We are already using TypeScript for everything, so it's nice to use the full power of language instead of adding another one. Being able to ship quickly and iterate without having a bunch of discussions about API schematics, waiting for code-gen, and never touching a Yaml file; instead, having a transient schema that the language takes care of things automatically.

tRPC gives you a syntax for defining back-end endpoints, but isn’t itself a server. Instead, you can host the tRPC API in a variety of environments from Node to serverless environments like CloudFlare workers. You can even turn a tRPC back end into Express middleware.

On the client side, tRPC has bindings for a wide range of common frameworks like React and SvelteKit.

All of this means that tRPC can improve the developer experience in a range of stacks with minimal effort and changes. Developers gain type information and enforcement across the API boundaries with little overhead.

Client-server communication with tRPC

tRPC is a protocol for interacting between client and server. All client-server communication is managed by the protocol, and therefore the server-side API is specific to tRPC. Although tRPC is perhaps most ideally suited to building a full-stack application that requires interaction between the front and back end, it is possible to transform the tRPC back end into a RESTful API using a project like trpc-openapi.

You could apply tRPC to a variety of stacks. Anytime both the client and server can be defined in TypeScript, tRPC can provide type enforcement and assist at development and compile time. To get to know tRPC, we will use the TodoMVC sample application on CodeSandBox. The TodoMVC application is a canonical to-do app with tRPC and Next.js. The application's landing page looks something like Figure 1. (Note that to edit the sandbox, you’ll need to log in and fork it.)

trpc fig1 IDG

Figure 1. The tRPC TodoMVC application in CodeSandBox

Next.js is already a full-stack framework. tRPC provides additional support for wiring up the application, but the basic process will be familiar to users of Next.js:

  1. Define API router(s) that handle the back-end requests.
  2. Export the router’s type definition.
  3. Make use of the type definition on the front end.

You’ll find the tRPC router definition in /src/server/routers/_app.ts (found in the file explorer on the left). This file just imports the todo.ts file and wraps it in a router object from the trpc library, as shown in Listing 1. 

Listing 1. The _app.ts file imports todo.ts and wraps it in a router object 


import { router } from '../trpc';
import { todoRouter } from './todo';

export const appRouter = router({
  todo: todoRouter,
});

export type AppRouter = typeof appRouter;

Two elements are important to notice in Listing 1. First, it shows how routers can import other routers and use them. This lets us compose complex APIs out of many different routers in different files. Second, notice that this file exports type AppRouter = typeof appRouter. That’s because on the front end, we’ll use the type definition to help drive our API usage (and tRPC will help ensure no breaking changes slip through the build).

If you open the imported todo.ts file, you’ll get a sense of how routes are defined in tRPC. Every endpoint has three basic possible stages:

  1. Input: Receive the raw input, validate it, and transform it into strongly typed parameters.
  2. Query: Handle GET-style requests.
  3. Mutation: Handle PUT/POST-style requests.

Notice, here, that the strong distinction between query and mutation is similar to GraphQL.

You can get a feel for how the router works by exploring the todo.ts and looking at the code snippet in Listing 2.

Listing 2. todo.js


import { z } from 'zod';
import { baseProcedure, router } from '../trpc';
// ...
add: baseProcedure
    .input(
      z.object({
        id: z.string().optional(),
        text: z.string().min(1),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const todo = await ctx.task.create({
        data: input,
      });
      return todo;
    }),

In Listing 2, the add route is defined for handling the addition of new TODOs. It uses a baseProcedure object to abstract the instantiation of the tRPC context. (To see how this is done, check out the /src/server/trpc.ts file. Notice that it uses the superjson project to help deal with rich JSON.) The baseProcedure.input function will handle validation and typing. It uses the zod library to help apply both validation and conversion. Whatever is returned by the input method is then given to the mutation method as an argument.

The mutation method accepts an async function with two parameters: a context and an input. If you mouse over or otherwise engage the IDE’s contextual info, you’ll see that the input object reflects the type of object sent from input with id and text fields.

trpc fig2 IDG

Figure 2. Exploring the input type received by the mutation method

Inside the mutation method, we make use of the ctx object to access the application context, where a Prisma instance is used to persist the new TODO.

tRPC has strong support for Next.js. The linking of the Next.js back end to the tRPC configuration happens in /src/pages/api/trpc/[trpc].ts. It is pretty simple to map what we saw in the routers to the Next.js back end.

tRPC on the front end

Now let’s jump over to the front end. The Next.js front end is united with tRPC in /src/pages/_app.ts. Like the back end, the unification is fairly simple.

The real work of outputting the front end happens in /src/pages/[filter].tsx. This file is mostly a typical React or Next.js UI, but it has superpowers from tRPC. Mainly, it pulls in the AppRouter type from our server-side definition and enforces changes from there. 

To quickly get a sense of the type inference at work, revisit the todo.ts file and make a breaking change; for example, I changed the name of the text field in the add.mutation method to text2. Doing this raises errors in the IDE. 

For example, the error “Property 'text' does not exist on type '{ id?: string | undefined; text2: string; }'.typescript(2339)” is seen in [filter].tsx, similar to Figure 3. If you were building on the server, you’d get the error at compile time. In general, tRPC enforces the types across the stack.

trpc fig3 IDG

Figure 3. Client-side errors reflecting back-end changes

Another benefit of tRPC on the front end is that much of the API interactions are handled by tRPC, rather than needing to wire it together with something like the fetch() API. For example, to make use of the add mutation API, [filter].tsx uses tRPC as seen in Listing 3.

Listing 3. [filter].tsx


const addTask = trpc.todo.add.useMutation({
    async onMutate({ text }) {
      await utils.todo.all.cancel();
      const tasks = allTasks.data ?? [];
      utils.todo.all.setData(undefined, [
        ...tasks,
        {
          id: `${Math.random()}`,
          completed: false,
          text,
          createdAt: new Date(),
        },
      ]);
    },
  });

The addTask function does the work of interacting from the front end to the remote API. It is wrapped in trpc.todo.add.useMutation, which is produced by tRPC based on the server endpoint. This syntax lets us interact with the back end in a transparent way, without a lot of plumbing, and maintaining the typing.

tRPC for full-stack TypeScript

For projects already using TypeScript, it makes sense to let TypeScript drive the client-server interface. tRPC doesn’t require much finagling or changes to how you use existing technologies, which is nice. Having additional information during development is very handy. On the whole, tRPC improves the developer experience without much overhead. It's a welcome addition to the full-stack TypeScript toolbox.

Copyright © 2023 IDG Communications, Inc.