Using tRPC for TypeScript-Enabled APIs

Using tRPC for TypeScript-Enabled APIs

Posted in

tRPC is a rising star in the Typescript and type-safe world, and for a good reason — it offers seamless unity between frontends and backends, promising to deliver a better developer experience, higher quality product, and reduced build time. But what exactly is it, and how does one start to use it? Today, we’re going to dive into exactly that question!

What is tRPC?

tRPC is a solution that allows for the creation and consumption of TypeScript-enabled typesafe APIs. The solution is framework agnostic, with many adapters available through community contributions allowing integration with various frameworks. Notably, tRPC boasts that it is light and responsible, with no code generation, run-time bloat, or dependencies.

Before we look at what tRPC means from a practical point of view, we should briefly discuss the “t” in the name.

What is Type Safety?

Type safety is the basic concept underlying TypeScript, so it bears some discussion here. TypeScript is the implementation of type safety in the development framework. tRPC is the union of RPC — remote procedure calls, a base technology for modern APIs — and TypeScript-centered type safety. But what exactly is type safety anyhow?

If you were to look up the meaning of type safety, the textbook definition is quite dry. Simply, it is a control within a language implementation that ensures variable access is only permitted to the authorized memory locations through defined channels. Put another way, type safety ensures that there is only one defined pathway for a given variable and that the variable used in practice will be validated against the input variable to ensure proper function.

That’s the dry version. What does it really mean in practice?

Let’s say we are creating an API to process orders for a digital storefront. In creating this system, we want to facilitate discounts based on the status of the user’s cart. If they leave the site, for instance, a discount amount should be applied to entice the user to return and make a purchase.

What happens, however, when the types of the internal system is set to integer, and, for whatever reason, this does not match up with the server type? Without type safety, we could have a function that carries out behavior that is not what the operation was built to do. In essence, we would have what we assumed to be identical types with a well-defined and predictable behavior that nonetheless has a result that is outside of expectations.

Type safety is when an operation cannot result in undefined behavior. In our case, during the creation of these codebases, our system would have recognized that the type we had set was incorrect for the value given and would have thrown the error up without waiting for the code to be in production.

Type safety gives you controls and guardrails, enables auto-complete, validates type compatibility, and so much more. Unfortunately, many of the solutions currently available for implementing type safety into APIs hinge on the idea of doing this at the code generation stage. This is a unique build step wherein schema creates types and is used as a form of type safety.

While this certainly gets the job done, tRPC makes a counterargument – what if you could skip that code generation step entirely?

The Unique Argument for tRPC

tRPC’s main argument is that typesafe APIs should be something you can create without leaning on schema and resultant code generation. It does this by tightly coupling the backend typing to the client typing through an internal routing system. By doing this, the types on both the backend and the client are unified, and changes to each are validated using the internal typescript validation.

This means that, as you are creating code, each input at the creation stage is validated, and the wrapper that validates is checking both the server and client code. In effect, you are validating in real time what previously had to be validated at the compiling or code generation stage.

Implementing tRPC

Once tRPC has been initialized, the first step in implementing the system is to define a router. These routers will expose the endpoints to the frontend, facilitating the direct connection between the two and enabling type safety.

To do this, you can use the following router code supplied in the tRPC documentation:

import * as trpc from '@trpc/server';
import { publicProcedure, router } from './trpc';

const appRouter = router({
  greeting: publicProcedure.query(() => 'hello tRPC v10!'),
});

// Export only the type of a router!
// This prevents us from importing server code on the client.
export type AppRouter = typeof appRouter;

From here, you need to define a procedure. The procedure functions as a sort of REST-endpoint equivalent, representing the actual procedure for data queries and mutations. In this example, tRPC uses its built-in Zod implementation to validate the input against a stated set of types.

import { publicProcedure, router } from './trpc';
import { z } from 'zod';

export const appRouter = router({
  hello: publicProcedure
    .input(
      z
        .object({
          text: z.string(),
        })
        .optional(),
    )
    .query(({ input }) => {
      return {
        greeting: `hello ${input?.text ?? 'world'}`,
      };
    }),
});

export type AppRouter = typeof appRouter;

This is the most simple instance of a router implementation, but there are many more complex functions enabled through nesting procedures and merged routers. Multiple procedures can be defined as properties on the object passed through the router, allowing for complex data retrieval and manipulation. Notably, however, tRPC suggests the creation of separate routers for each particular set of functions. In doing so, you can create routers that each carry out a set of procedures that then merge into a single router core. You do this by importing the routers and merging them as follows:

// @filename: trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();


export const middleware = t.middleware;
export const router = t.router;
export const publicProcedure = t.procedure;

// @filename: routers/_app.ts
import { router } from '../trpc';
import { z } from 'zod';

import { userRouter } from './user';
import { postRouter } from './post';

const appRouter = router({
  user: userRouter, // put procedures under "user" namespace
  post: postRouter, // put procedures under "post" namespace
});

export type AppRouter = typeof appRouter;


// @filename: routers/post.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const postRouter = router({
  create: publicProcedure
    .input(
      z.object({
        title: z.string(),
      }),
    )
    .mutation(({ input }) => {
      // [...]
    }),
  list: publicProcedure.query(() => {
    // ...
    return [];
  }),
});

// @filename: routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
  list: publicProcedure.query(() => {
    // [..]
    return [];
  }),
});

The API Handler and Agnosticism

One major element of tRPC that sets it aside as a strong value offering is the fact that tRPC offers its features and syntax independent of any backend allegiance. This framework agnosticism is a big benefit and is something often touted by adoptees. It does this by providing an API Handler, which is also referred to by the community as adapters. These adapters act almost like a shim between the HTTP requests and tRPC, converting requests from various frameworks into something tRPC can handle.

An example implementation in the documentation for Next.js looks like this:

import { createNextApiHandler } from '@trpc/server/adapters/next';
import { createContext } from '../../../server/trpc/context';
import { appRouter } from '../../../server/trpc/router/_app';

// export API handler
export default createNextApiHandler({
  router: appRouter, // your outermost router, see https://trpc.io/docs/procedures
  createContext, // your request context, see https://trpc.io/docs/context
});

This solution results in a system that creates the context (a system in tRPC that holds the connection, authentication, etc., information that a procedure accesses) link between routers, allowing for agnosticism with the external framework while bridging to the internal tRPC logic.

tRPC Benefits

tRPC is, at its core, a great offering that unifies the backend and frontend. Other solutions do exist for this, but they typically happen at the code generation stage and are a bit stuffy to use. tRPC creates a close connection between client and server, resulting in a cleaner process of creation and a higher level of quality.

Another great benefit of tRPC is the fact that it’s relatively lightweight, especially for what it does. The tRPC approach is to simply define procedures and context and route them appropriately, meaning that you reduce a lot of the overhead you would incur with something heavier like GraphQL.

In theory, the biggest benefit of tRPC is the delivery of higher-quality results through type enforcement and quicker delivery time via real-time error checking. While this does require some work on the backend to get going, it can be a strong argument for adoption in most use cases.

tRPC Drawbacks

All that being said, tRPC does have some drawbacks. Typescript-centric development suggests that you have a set of well-known and simple types that stay uniform throughout the corpus of the API. The reality is that many situations need more flexibility, and especially complex systems may see such transformations go through several procedures to accomplish this. In such a situation, something like GraphQL will be more efficient and sensible.

Additionally, tRPC can require quite a lot of definition to get off the ground effectively. For some products and projects, this may not be a high cost, but for a monolithic system or an overly complex API, the idea of defining all of your functions as procedures AGAIN could be too high a cost to bear, and could be a strong argument for avoiding implementation.

Conclusion

Ultimately, tRPC is a strong tool for unifying the frontend and backend while keeping them in distinct parts. The implementation of tRPC will be successful in larger part if it is a good fit for the specific niche it occupies, where complexity is high but not extreme, and the product is in the build stage. In such a case, tRPC should be seriously considered.

What do you think about tRPC? Let us know in the comments below!