Back to home

My Journey with tRPC

A deep dive into my experience with tRPC, exploring its type-safety benefits, comparing it with Next.js server actions, and understanding why it became an indispensable tool in my stack.

When I first encountered tRPC, I'll be honest - I didn't get it. Another fucking API layer? More boilerplate? Haven't we solved this problem already? But damn, as I dove deeper into building type-safe applications, tRPC transformed from a "nice-to-have" into a "holy shit, I can't work without this" tool. Let me share my journey and why tRPC has become an essential part of my development stack.

The Initial Skepticism

Like many developers, I started with REST APIs, moved to GraphQL when I needed more flexibility, and thought I had my bases covered. When tRPC entered the scene, it seemed like an unnecessary abstraction. The selling point of "end-to-end type safety" sounded good, but was it worth adding another layer to my stack?

The Turning Point

The "aha" moment hit me like a truck when I found myself writing TypeScript interfaces for the hundredth freaking time, trying to keep my client and server types in sync. You know the drill, it's a pain in the ass:

// Server
interface User {
  id: string;
  name: string;
  email: string;
}

// Client
interface UserResponse {
  id: string;
  name: string;
  email: string;
}

With tRPC, this redundancy just vanished into thin air. The type definitions flow naturally from your server to your client, and your IDE knows exactly what data to expect. No more manual type synchronization, no more banging your head against the wall because of those damn runtime type errors from mismatched interfaces.

tRPC vs Server Actions: A Close Call

When Next.js introduced Server Actions, it seemed like they might make tRPC redundant. After all, Server Actions offer a similar promise: write server-side functions and call them from the client with full type safety. However, after using both, I still find tRPC to be the superior choice for several reasons:

1. Procedural Thinking

tRPC's router-based approach aligns perfectly with how we think about APIs. Each procedure is a clearly defined entry point with its own validation, transformation, and error handling:

const userRouter = t.router({
  getUser: t.procedure
    .input(z.string())
    .query(async ({ input }) => {
      return await db.user.findUnique({ where: { id: input } });
    }),
});

Server Actions, while powerful, feel more like enhanced form submissions. They're great for simple CRUD operations but can become unwieldy for complex API architectures.

2. Better Error Handling

tRPC's error handling is more sophisticated and type-safe. You can catch and handle errors at any point in the chain, with full type information:

try {
  const user = await trpc.user.getUser.query(userId);
} catch (error) {
  if (error instanceof TRPCError) {
    // Type-safe error handling
  }
}

3. Middleware and Hooks

tRPC's middleware system is more flexible and powerful than Server Actions. You can easily add authentication, logging, or any custom logic across multiple procedures:

const middleware = t.middleware(async ({ ctx, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;
  console.log(`Call took ${duration}ms`);
  return result;
});

The Type Safety Advantage

The true beauty of tRPC lies in its type safety guarantees. It's not just about catching errors - it's about preventing them from happening in the first place. Here's what makes it special:

1. Inference All the Way Down

Your entire API chain is type-safe. From client to server, through transformations and validations, TypeScript knows exactly what's happening:

// The type information flows through the entire chain
const user = await trpc.user.create.mutate({
  name: "John", // IDE knows exactly what fields are required
  email: "john@example.com",
});

console.log(user.id); // TypeScript knows the shape of the response

2. Zero Type Generation

Unlike GraphQL, there's no need to run a code generator every time you change your API. The types are inferred automatically from your implementation.

3. Runtime Validation

Combined with libraries like Zod, you get both compile-time and runtime type safety:

const createUser = t.procedure
  .input(z.object({
    name: z.string().min(2),
    email: z.string().email(),
  }))
  .mutation(async ({ input }) => {
    // Input is validated and typed
    return await db.user.create({ data: input });
  });

The Developer Experience

What ultimately made tRPC irreplaceable for me is the developer experience. The instant feedback from TypeScript, the confidence in refactoring, and the elimination of an entire category of bugs has significantly improved my development workflow.

When you rename a field in your database schema, TypeScript immediately shows you every place that needs to be updated. When you add a new API endpoint, your client code immediately knows about it. This tight feedback loop makes development faster and more reliable.

Conclusion

While tRPC might seem like "just another API layer" at first, its true value becomes apparent when you experience the confidence and productivity boost it provides. Yes, there's a learning curve, and yes, it requires buying into the TypeScript ecosystem. But the benefits - end-to-end type safety, excellent developer experience, and robust error handling - make it a game-changer for modern web development.

If you're on the fence about tRPC, just fucking try it already. Build something non-trivial with it, experience the type safety benefits firsthand, and see if it transforms your development experience like it did mine. Trust me, you won't regret this shit.

Happy coding! 🚀