tRPC: Building Type-Safe and Efficient APIs in TypeScript

7 mins read

tRPC revolutionizes client-server communication in TypeScript applications, offering a robust and efficient framework for building APIs. By providing type safety, flexibility, and scalability, tRPC simplifies the development process and improves application performance. In this blog post, we will explore how tRPC facilitates client-server interaction, from API definition to implementation, error handling, and middleware usage. Whether you’re building a small-scale web application or a large-scale enterprise system, tRPC is a powerful tool that can elevate your API communication to the next level.

 

Client-Server Interaction with tRPC

tRPC simplifies client-server communication by providing a structured approach that ensures type safety and efficiency throughout the process. Let’s delve into the key components of client-server interaction in tRPC and how they streamline API communication:

 

tRPC server-side implementation

Initialization of tRPC backend which Should be done only once.

 

import { initTRPC } from '@trpc/server';

const t = initTRPC.create();

export const router = t.router;

export const publicProcedure = t.procedure;

 

API Definition

In tRPC, API routes are defined using TypeScript types, making it easy to specify request parameters and response shapes. This ensures type safety both on the client and server side, reducing the risk of runtime errors.

 

type User = { id: string; name: string };




// Using a user array list a database

const users: User[] = [];

export const db = {

  user: {

    findMany: async () => users,

    findById: async (id: string) => users.find((user) => user.id === id),

    create: async (data: { name: string }) => {

      const user = { id: String(users.length + 1), ...data };

      users.push(user);

      return user;

},

},

};

 

On the server, tRPC simplifies the implementation of API routes by providing a clean and structured approach. Developers can focus on writing business logic while tRPC handles the underlying communication and serialization/deserialization of data.

 

import { createHTTPServer } from '@trpc/server/adapters/standalone';

import { z } from 'zod';

import { db } from './db.js';

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




const appRouter = router({

// route to get all users

  user: {

    list: publicProcedure.query(async () => {

      const users = await db.user.findMany();

      return users;

    }),

// route to get one user by Id

    byId: publicProcedure.input(z.string()).query(async (opts) => {

      const { input } = opts;

      const user = await db.user.findById(input);

      return user;

    }),

// route to create a user

    create: publicProcedure

      .input(z.object({ name: z.string() }))

      .mutation(async (opts) => {

        const { input } = opts;

        const user = await db.user.create(input);

        return user;

      }),

  },

});

 

// Export type router type signature, this is used by the client.

export type AppRouter = typeof appRouter;

const server = createHTTPServer({

  router: appRouter,

});

server.listen(3000);

 

Client-Side Implementation

On the client side, tRPC provides a client library that generates type-safe API methods based on the defined routes. This allows developers to make API calls with confidence, knowing that the request and response types are validated at compile-time.

import { createTRPCClient, httpBatchLink } from '@trpc/client';

import type { AppRouter } from '../server/index.js';

// Initialize the tRPC client

const trpc = createTRPCClient<AppRouter>({

  links: [

    httpBatchLink({

      url: 'http://localhost:3000',

    }),

  ],

});

const users = await trpc.user.list.query();

console.log('Users=====>:', users);

const createdUser = await trpc.user.create.mutate({ name: Addis });

console.log('Created user======>', createdUser);

const user = await trpc.user.byId.query('1');

console.log('User======>', user);

 

 

Error Handling and Middleware

tRPC offers a middleware and an interface for error handling, allowing developers to handle errors gracefully and implement custom logic as needed. All errors that occur in a procedure go through the onError method before being sent to the client. The TRPCError interface ensures consistency between error messages returned by the server.

export default trpcNext.createNextApiHandler({

  onError(opts) {

    const { error, type, path, input, ctx, req } = opts;

    console.error('Error:', error);

    if (error.code === 'INTERNAL_SERVER_ERROR') {

      // do something

    }

  },

});

// The get one user route can be updated with an error handler as follows

const appRouter = router({

  byId: publicProcedure.input(z.string()).query(async (opts) => {

      const { input } = opts;

      const user = await db.user.findById(input);

      if(!user) 

      throw new TRPCError({

      code: ‘NOT_FOUND',

      message: ‘User not found!',

      // optional: pass the original error to retain stack trace

      cause: theError,

      });      return user;
   });
});


The above example code expects a monorepo setup or the client and server to be in the same working directory. However, a monorepo is not mandatory to implement tRPC. While a monorepo can offer certain advantages such as simplified dependency management and easier code sharing between client and server applications, tRPC can still be effectively implemented in scenarios where the client and server are in separate repositories. 

 

In fact, tRPC is designed to be flexible and can accommodate various project setups, including distributed architectures. Here are a few approaches to implementing tRPC without a monorepo:

 

  1. Shared Library: Create a shared library or package containing the API contracts (types and schemas) that can be used by both the client and server applications. Each application can then use this shared library as a dependency to generate client-side code (for the client application) and implement server-side logic (for the server application).

 

  1. API Contract Repository: Maintain a separate repository dedicated to storing the API contracts. Both the client and server applications reference this repository and use its contents to generate client-side code and implement server-side logic. Changes to the API contracts can be managed through version control, ensuring consistency between client and server implementations.

 

  1. Code Generation: Use code generation tools provided by tRPC to generate client-side code directly from the API contracts. This allows you to avoid manual synchronization of types between client and server applications, ensuring type safety and consistency without the need for a monorepo.

 

  1. API Gateway: Implement an API gateway or proxy layer that sits between the client and server applications. The API gateway handles routing requests to the appropriate backend services (implemented using tRPC) and provides a unified interface for clients. This approach allows you to decouple the client and server applications while still benefiting from tRPC’s features.

 

In conclusion,  tRPC facilitates seamless collaboration between backend and frontend developers by providing a shared language for defining API contracts. Frontend developers can generate type-safe API clients directly from the API definitions, eliminating the need for manual communication with backend developers about type changes or query modifications. This streamlines the development process, reduces potential errors, and improves productivity across the team.  tRPC’s  seamless integration into existing projects through its cutting-edge adapters, such as:  Express, Next.js, and Fastify, and AWS, makes it worth giving a try.