./ahmedhashim

The Joys of tRPC

With XMLHttpRequest, browsers could now load information from a remote source without needing to reload. The client would render this information, and REST-style JSON APIs became popular.

However, there was no binding between client and server. If you changed the name of a property, you wouldn’t know about it until your app failed on the client side.

OpenAPI helped by defining a spec for these APIs, but your API contract still wasn’t within your application. You’d still need to define what your request/response objects looked like on your frontend and hope that your backend was matching up.

tRPC solves this problem.

tRPC is great if you’re using TypeScript on your full-stack app. It allows you to define your server’s procedures and use them directly on your client. There’s no need to generate an SDK or write fetch calls. If your server updates a field, your client will know about it at compile time.

Server-defined contracts

You can create procedures on your server and combine them into a router for your client to use:

import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";

type Context = {
  user: { id: string } | null;
};

const t = initTRPC.context<Context>().create();

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

They’ll all have types defined for their input and output, so your API contract is simply TypeScript.

Auth boundaries

Make a public base procedure, and add auth to it when you need to protect a route:

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }

  return next({
    ctx: {
      user: ctx.user,
    },
  });
});

export const protectedProcedure = t.procedure.use(isAuthed);

Your router will become:

export const appRouter = router({
  healthcheck: publicProcedure.query(() => {
    return { ok: true };
  }),

  me: protectedProcedure.query(({ ctx }) => {
    return {
      id: ctx.user.id,
    };
  }),

  // assumes a `db` module for data access
  getPost: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return db.posts.findById(input.id);
    }),
});

Notice how the auth sits right next to the procedure it runs on. Most REST frameworks will require you to dig through multiple route files and middleware to figure that out.

Input validation

Zod is another great library that works with tRPC. You can use the same schema to validate your input at runtime and get compile-time types:

const createPostInput = z.object({
  title: z.string().min(1).max(120),
  body: z.string().min(1),
  tags: z.array(z.string()).max(5),
});

export const appRouter = router({
  createPost: protectedProcedure
    .input(createPostInput)
    .mutation(async ({ ctx, input }) => {
      return db.posts.create({
        authorId: ctx.user.id,
        title: input.title,
        body: input.body,
        tags: input.tags,
      });
    }),
});

Since you have the schema, you can create a regular TS type from it as well:

type CreatePostInput = z.infer<typeof createPostInput>;
// { title: string; body: string; tags: string[] }

This isn’t something you’ll need to do for your procedure, since tRPC will automatically create a type for your input. But this can come in handy if you need to use it somewhere else, maybe in tests or client-side form validation.

The procedure will never be called if your client sends invalid input. When your server alters the schema, your client will be made aware of the change during its next type check. This means they can never become unsynchronized.

A typesafe client

After you create your router, you can export its type:

export type AppRouter = typeof appRouter;

The client can use this type:

import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@acme/api";

export const trpc = createTRPCProxyClient<AppRouter>({
  links: [
    httpBatchLink({
      url: "https://example.com/api/trpc",
    }),
  ],
});

Now when calling your functions, you’ll have autocomplete and type safety:

const post = await trpc.getPost.query({ id: "123" });

await trpc.createPost.mutate({
  title: "Hello",
  body: "World",
  tags: ["typescript"],
});

If you rename getPost on your server or modify its input, your client will no longer compile, rather than failing in production.

I love that you can do this with tRPC. You no longer need glue code to keep your backend and frontend types in sync. When using a monorepo, it feels as if you’re calling a function that just happens to be on your server.

Error handling

Sending a TRPCError from your procedure will cause your client to receive an error with both a code and message. You can even send additional data with your error. On the client, you can use these error codes to differentiate between errors:

import { TRPCClientError } from "@trpc/client";

try {
  await trpc.createPost.mutate({ title: "", body: "", tags: [] });
} catch (err) {
  if (err instanceof TRPCClientError) {
    console.error(err.data?.code); // "BAD_REQUEST"
  }
}

If you don’t pass Zod validation, you’ll see BAD_REQUEST errors. But by default, you’ll only see those field errors in the message. To receive them as data, add an error formatter when creating your tRPC instance:

import { ZodError } from "zod";

const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

Now the client has access to err.data?.zodError?.fieldErrors and can show them below each input field.

Trade-offs

There are some downsides to using tRPC.

It’s best suited for a project where you have control over both the frontend and backend in TypeScript. If you want consumers of your API to be able to use other languages, or you have to maintain backwards compatibility for a long period of time, you’ll probably want to publish an OpenAPI spec and stick to JSON.

An additional downside is abstraction. With tRPC, you can forget you’re making a network request. Network requests can be slow, and they can fail, or your client can be out of date. Types can help with this, but you still want to design your API correctly.

I feel like tRPC is one of the best things added to the modern-day web stack when writing a full-stack TypeScript application. Your contract stays right next to your code, and you get end-to-end types with no second source of truth to maintain.