Type Safe APIs with tRPC

The benefits of TypeScript are numerous, from strong type safety, code maintenance to refactoring, and more. It’s also not uncommon today for web developers to be responsible for both the front end and the back end of a project. This can be highlighted by the popularity of frameworks like Next and Remix, both of which allow developers to work on full-stack TypeScript applications.

Normally, if you wanted to maintain type safety between your back end and front end, you could use codegen tooling to generate typings from OpenAPI or GraphQL schemas. The downside was that any change to the backend APIs requires you to run the codegen tooling again. This is a problem that tRPC sets out to solve.

Getting Started

tRPC is a typesafe Remote Procedure Call framework. tRPC shines when used with a framework like Next or Remix. It really benefits developers working in an environment where the backend and frontend code are maintained side by side, whether that be with a framework like Next or a monorepo application.

We can get started with a basic Next application, and add a few additional dependencies in order to use tRPC.

npm install @trpc/client @trpc/server @trpc/next zod

Before we dive right into how to use tRPC in our project, let’s take a quick look at how you can enforce your type validation in your schemas.

Kneel before Zod

One dependency that might stand out from the rest here is Zod. We won’t go into detail on Zod, but we should have an idea of how Zod works so we can quickly see how it benefits tRPC. Zod allows you to define a schema, use it for validation, and then infer the TypeScript type based on that schema.

Assume we wanted a type that looked like this.

type Data = {
  uid: string,
  url?: string,
  values: number[]
}

That could be defined by Zod with the following,

const zData = z.object({
  uid: z.string(),
  url: z.string().nullish(),
  values: z.number().array()
})

Then we can create the type using a special infer method.

type Data = z.infer<typeof zData>;

The main benefit here is data validation using the defined schema. A secondary benefit though is that the schema can be used to infer typings too. Now we can use this type, and if at any point during development, we update the schema with Zod, the type will also be updated. tRPC leverages Zod to help provide type safety and validation for our application. Zod is not the only option for defining and validating schemas in tRPC, but it is a very popular one. None of these are required, as you can provide your own parser and tRPC will still be able to infer the types from a custom validator.

Creating a RPC endpoint

There are a few basic steps to setting up tRPC. You can create a single instance of tRPC and then you can start defining your router. The router can be configured with some options.

  • A procedure to a define the endpoint
  • An input to define the parameters of the procedure
  • An output to define the response
  • A query to return the response

It is within the input and output validation that we can use Zod to help us define the schemas of the endpoint. We can look at the following code snippet to see how we might define an API with tRPC and Zod.

// pages/api/trpc/[trpc].ts
import { initTRPC } from '@trpc/server';
import * as trpc from '@trpc/server/adapters/next';
import { z } from 'zod';

const t = initTRPC.create();

const appRouter = t.router({
	info: t.procedure
	.input(
		z.object({
			name: z.string().nullish()
		})
		.nullish()
	)
	// strongly type the return value
	.output(
		z.object({
			title: z.string(),
			url: z.string(),
			imageUrl: z.string()
		})
	)
	.query(({input}) => {
		return {
			title: input?.name ?? "SitePen",
			url: "https://www.sitepen.com/",
			imageUrl: "https://openjsf.org/wp-content/uploads/sites/84/2019/04/sitepen.svg",
		}
	})
});

export type AppRouter = typeof appRouter;

export default trpc.createNextApiHandler({
	router: appRouter,
	createContext: () => ({})
});

There’s a few things happening in this snippet. You can see how we can use a fluent API to configure our procedure. tRPC even provides various adapters you use for your server-side environment. In this case we use our router with a Next adapter.

Integrate with the Client

RPC provides a set of actions, or functions that you can communicate with over HTTP. These actions can be chained via links in tRPC, where you can compose a series of queries and mutations. It also currently supports subscriptions via Web Sockets, however, this is currently a beta feature. We have only one action for our sample, but could expand on this in future iterations.

With our server-side capabilities set up, we can move on to integrating tRPC with the client. Because we are using Next and React in this case, we can create a higher-order component (HOC) to wrap our application for use with tRPC.

// utils/trpc.ts
import { httpLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import type { AppRouter } from '../pages/api/trpc/[trpc]';

function getBaseUrl() {
	if (typeof window !== undefined) {
		return '';
	}
	if (process.env.VERCEL_URL) {
		return `https://${process.env.VERCEL_URL}`;
	}
}

export const trpc = createTRPCNext<AppRouter>({
	config() {
		return {
			links: [
				httpBatchLink({
					url: getBaseUrl() + '/api/trpc'
				})
			]
		}
	},
});

// pages/_app.tsx
// wrap the exported App component
export default trpc.withTRPC(App);

In this snippet we can define the routes for the tRPC endpoints using the Next HOC and httpBatchLink module for tRPC clients. With only one action, we only have a terminating link, so we could also have used httpLink as our single link function.

We can use the HOC to wrap the default App component used for the application. This will let us send a query to the tRPC endpoint and provide the type safety and validation with Zod we are looking for. Now we can use tRPC to query our endpoint in a component and do something with the results.

// pages/index.tsimport { trpc } from '../utils/trpc';

export default function Home() {
	const result = trpc.info.useQuery({ name: 'SitePen' });
	if (!result.data) {
		return (
			// loader message
		);
	}
	return (
		// UI of results
	)
}

The tRPC Next connector provides a useQuery method to send our request to the tRPC endpoint. This will provide an output like this image.

Why tRPC?

You might be asking yourself why tRPC is so useful? Afterall, we could simply define a type or interface for our entire application and use it in the client and server. However, any change to the interface would require some extra effort to update both to match the new interface. With most modern tooling, you can update the schemas and it will update throughout your application including the inferred types. So if we refactor the output value of imageUrl to just image, that change would be propagated everywhere it is used, providing end-to-end typing and validation. This allows us to write, test, and update our code much faster in monorepo TypeScript applications.

Still in flux

As of the writing of this article, tRPC is at version v10. There were some major changes from v9 to v10 that should be pointed out if you have looked at tRPC in the past. The main change is in how the routers and procedures were defined.

In v9, the router in our demo might have looked something like this.

const appRouter = trpc
  .router()
  .query("info", {
    input: z.object({
      name: z.string().nullish()
    }).nullish(),
    output: z.object({
      title: z.string(),
      url: z.string(),
      imageUrl: z.string()
    }),
    resolve({ input }) {
      return {...};
    },
  });

If you refer back to the demo code in this article, you can see that in v9, we can define the query of the route via a string, whereas in v10, the route is defined as the property of an object passed as an argument to the router.

The other big change was in how these procedure methods were called from the client.

// v9
const result = trpc.useQuery("info", { name: 'SitePen' }));
// v10
const result = trpc.info.useQuery({ name: 'SitePen' });

This is one change I think I preferred in v9. Admittedly though, in v10 it does provide a more concise binding of the procedure name. That is just my opinion of course. There are a few more changes in the versions, and tRPC does provide a thorough migration guide. Updates like these from one major version to the next are something to keep in mind, as it is still a relatively young project. But since it is TypeScript, updates and any refactors should be fairly straight forward!

Summary

It should be pointed out that the real benefits of tRPC shine when working wholly with TypeScript applications. If your server-side code is maintained in a silo, tRPC could still be used, but would not be used to its full potential. It should also go without saying that it also assumes your entire stack is written in TypeScript, which may not be the case for all teams. If your stack consists of a mix of languages, a better solution might be GraphQL, which can still provide generated types. It is an extra step, but be sure to choose the right tool for the job.

However, if you and your team are working in a full-stack monorepo TypeScript environment, then tRPC is definitely something you should consider. Although the sample shown here uses Next and React, tRPC is framework agnostic, so you can use it with your current framework of choice or in any workflow. Also note, this is an intro to the basics of tRPC. There are many more topics to consider for your production applications, such as authorization, error handling, data transformation, caching, and more.