
tRPC: Building Type-Safe APIs with TypeScript
May 15, 2023
Over the years, we’ve seen many approaches to HTTP API design. While REST APIs are still very popular throughout the industry, they offer no inherent guarantees that the client’s assumptions about the response structures will be valid.
GraphQL fills this gap to an extent by allowing client-side code greater control over the resulting structures but at the cost of added complexity. RPC (remote procedure call) frameworks attempt a different solution by sharing generated type definitions between the client and server implementations. What if there was a way to achieve the type safety of RPC by simply inferring the type definitions from the server’s code?
Enter tRPC. Since JavaScript (and specifically TypeScript) can already span across client and server implementations, tRPC allows a client to directly consume structures defined by the server’s exposed procedures. Essentially, you import your dependencies from the server to access these procedures, their return types are inferred and checked at build time, and your client code can confidently consume the returned data.
In this post, we’ll look at how it achieves these goals and what limitations it places on your project stack.
Limitations
Okay, bad news first. tRPC assumes a full-stack TypeScript implementation. The type safety mechanisms hinge on the idea that the server exports a defined router that the client will consume for its API calls. If your project cannot supply this router via TypeScript from your server and consume it within TypeScript on your client, then your project doesn’t get to use tRPC.
Additionally, I’ll reiterate one of the inherent limitations of using RPCs in general: you are bound by the response definitions created by the server. If your server’s procedure returns more data than you want your client to receive, you need to update your server’s implementation. The client is afforded no ownership of the data contract used by the API.
When combined, these limitations mean that tRPC is only applicable to a subset of projects. GraphQL or language-agnostic RPC frameworks like gRPC have fewer stack restrictions in place, each of which would be a valid alternative to consider. However, for those projects that do fit into its criteria, tRPC can be a very powerful tool.
How It Works
The tRPC Getting Started page includes a link to a minimal example, hosted in an online editor. The examples I use in this section were obtained from that project, with some minor reorganization for brevity.
At a high level, tRPC is a combination of the following concepts working together:
- Server-defined procedures are defined on a router.
- The router’s type definition is exported.
- A client is instantiated using the imported type definition.
From this bird’s eye view, you might think that there’s nothing interesting happening here, and to an extent, you would be right. You can accomplish a similar pattern with explicit HTTP endpoints returning strongly-typed models, assuming your server consumes those same models and its serialization layer can preserve the types.
The magic is in the implementation.
// Database model, from server/db.ts type User = { id: string; name: string }; // server/index.ts, abridged import { z } from 'zod'; import { db } from './db'; import { publicProcedure, router } from './trpc'; const appRouter = router({ userList: publicProcedure.query(async () => { // Retrieve users from a datasource, this is an imaginary database const users = await db.user.findMany(); return users; }), userById: publicProcedure.input(z.string()).query(async (opts) => { const { input } = opts; // Retrieve the user with the given ID const user = await db.user.findById(input); return user; }), userCreate: publicProcedure .input(z.object({ name: z.string() })) .mutation(async (opts) => { const { input } = opts; // Create a new user in the database const user = await db.user.create(input); return user; }), }); // Export type router type signature, // NOT the router itself. export type AppRouter = typeof appRouter; const server = createHTTPServer({ router: appRouter, }); server.listen(3000);
This is the closest a tRPC server gets to defining endpoints or API response models. Note that the only thing being exported here is the AppRouter
type definition, which transitively depends on the User
data model.
Also note that some of these publicProcedure
items being added to the router are defining their own input parameters, all without type declarations. Surely a client implementation would need more than this in order to consume the server, right?
Actually, no.
// client/index.ts, abridged import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import type { AppRouter } from '../server'; import './polyfill'; const trpc = createTRPCProxyClient<AppRouter>({ links: [ httpBatchLink({ url: 'http://localhost:3000', }), ], }); async function main() { const users = await trpc.userList.query(); console.log('Users:', users); const createdUser = await trpc.userCreate.mutate({ name: 'sachinraja' }); console.log('Created user:', createdUser); const user = await trpc.userById.query('1'); console.log('User 1:', user); } main().catch(console.error);
What’s Happening Here?
The client is importing the AppRouter
defined by the server and using it to instantiate the generic trpc
proxy client.
Afterward, the client is able to consume the server’s procedures as properties on that object, again, without needing to provide any type definitions. The types are inferred and deserialized correctly, and the terminal prints what we would expect when starting from an empty set of users.
Users: [] [dev:client] Created user: { id: ‘1’, name: ‘sachinraja’ } [dev:client] User 1: { id: ‘1’, name: ‘sachinraja’ }
This shows that a minimal example application works, but how do we know that type safety is being preserved during builds? Let’s try consuming a non-existent username
property on createdUser
.
const createdUser = await trpc.userCreate.mutate({ name: 'sachinraja' }); console.log('Created user:', createdUser); console.log('Username of created user:', createdUser.username);
Correctly, I get this error when I try to build.
Property 'username' does not exist on type '{ name: string; id: string; }'.
The client respects the response type definitions propagated by the server. Sure enough, if we add that property to the User
type on the server (and populate it within the db.user.create
implementation), that error goes away and the property shows up in our terminal results.
Users: [] [dev:client] Created user: { id: '1', username: 'user-sachinraja', name: 'sachinraja' } [dev:client] Username of created user: user-sachinraja [dev:client] User 1: { id: '1', username: 'user-sachinraja', name: 'sachinraja' }
It turns out that inputs from the client are validated in a similar way. If you look back at the userCreate
implementation on the server, you should see this line:
.input(z.object({ name: z.string() }))
This fluent method call is using Zod to add a parser to that procedure, informing it that inputs to the procedure should be objects that contain a name
property as a string. If our client breaks that expectation by supplying username
instead, we see the following error:
Argument of type '{ username: string; }' is not assignable to parameter of type '{ name: string; }'. Object literal may only specify known properties, and 'username' does not exist in type '{ name: string; }'.
And just like before, the error goes away if we adjust the server’s implementation to compensate.
userCreate: publicProcedure .input(z.object({ username: z.string() })) .mutation(async (opts) => { const { input } = opts; // Create a new user in the database const user = await db.user.create({ name: input.username }); return user; }),
All this through the magic of type inference. As a developer working on the client, I can hover my mouse over the response items from my API calls, and my language server will show me the exact structure of my data, without adding any custom deserialization logic or explicitly importing API models.
The Bottom Line
Even though this example is minimal, it’s easy to see tRPC’s potential as a project begins to scale. Routers can be divided up based on different concepts, middleware can be added to protect procedure calls with authorization checks, and integrations with other niceties like response caching and ORMs are well-supported.
Teams can get immediate confirmation that their client-side code is staying compliant with the server code it consumes, and any breaking changes to the server-side code will produce errors in the dependent clients.
At least, it will upon the next build cycle.
It’s time to address the elephant in the room. The immediate feedback that we’ve experienced in these examples is happening because the TypeScript compiler is rebuilding both the client and the server implementations. If we introduced a breaking change to the server, any dependent clients would only find out upon consuming the updated artifacts exported by the server.
tRPC is at its best when both implementations are within the same project, allowing the client to consume updated server artifacts without requiring package versions to be incremented and published. The further our projects stray from that pattern, the more tRPC’s developer experience resembles the “traditional” way of sharing of type definitions.
However, even in the world of multiple clients consuming a versioned server package, tRPC still gives us a nice syntax for abstracting away the underlying request and deserialization logic. Combine this with the fact that our server keeps the benefits of type inference, as well as the middleware integration support mentioned above, and it can still be a viable option for multi-repo projects.
Conclusion
tRPC is a great answer for its target audience. It provides TypeScript developers with an easy-to-use abstraction layer for explicit HTTP operations and associated models, all without sacrificing type safety.
Having build-time assurances for your API logic makes for a better developer experience, especially in monorepo scenarios where your client and server implementations exist within the same build process.
It may be possible to achieve similar results using different tools, but if you’re planning a full-stack TypeScript project where RPCs are an option, then the simplicity of tRPC is hard to beat.
More From Jake Everhart
About Keyhole Software
Expert team of software developer consultants solving complex software challenges for U.S. clients.