tRPC: End-to-End Type Safety for Your API
Discover how tRPC eliminates the type safety gap between your TypeScript frontend and backend without code generation or schemas. Build fully type-safe APIs in minutes.
While I was looking over some API architectures the other day, I came across a frustrating pattern I was once guilty of myself. We had a beautiful TypeScript backend with perfect type definitions, and a React frontend also written in TypeScript. Yet somehow, we were still manually typing our API responses and praying they matched reality. Little did I know that tRPC would completely change how I think about building APIs.
Why tRPC Changes Everything for TypeScript APIs
I cannot stress this enough! If you're building a TypeScript application where you control both the frontend and backend, continuing to use traditional REST or GraphQL without tRPC is leaving productivity on the table. When I finally decided to try tRPC on a side project, I realized I'd been fighting an unnecessary battle for years.
The magic? Your backend types flow directly to your frontend. No code generation. No schema files. No manual type definitions. Just pure TypeScript inference doing what it does best.
The Type Safety Problem: REST and GraphQL's Hidden Tax
Let me show you what I mean. Here's the typical REST API pattern I was using:
// Backend: users.controller.ts
export async function getUser(req: Request, res: Response) {
const user = await db.users.findUnique({
where: { id: req.params.id }
});
res.json(user);
}
// Frontend: userService.ts
interface User {
id: string;
name: string;
email: string;
// Did the backend add a new field? You'll find out at runtime!
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json(); // Fingers crossed this matches User!
}I was once guilty of shipping this exact pattern to production. The backend would evolve, someone would add a new field or change a type, and the frontend would break in production. Our "type-safe" application had a gaping hole right in the middle.
GraphQL improves this with schemas, but you're still maintaining a separate schema language and running code generators. Wonderful for public APIs, but overkill when you control both ends.

Building Your First Type-Safe tRPC API in 15 Minutes
Luckily we can build something better. Let's create a simple tRPC server and see the type inference in action:
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/users.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const usersRouter = router({
getUser: publicProcedure
.input(z.object({
id: z.string()
}))
.query(async ({ input }) => {
// Your database call here
const user = await db.users.findUnique({
where: { id: input.id }
});
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt
};
}),
createUser: publicProcedure
.input(z.object({
name: z.string().min(2),
email: z.string().email()
}))
.mutation(async ({ input }) => {
const user = await db.users.create({
data: input
});
return user;
})
});
// server/index.ts
import { router } from './trpc';
import { usersRouter } from './routers/users';
export const appRouter = router({
users: usersRouter
});
export type AppRouter = typeof appRouter;Now here's where it gets fascinating. On the frontend, you don't write any types:
// client/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
// client/components/UserProfile.tsx
async function loadUser(userId: string) {
const user = await trpc.users.getUser.query({ id: userId });
// TypeScript knows EXACTLY what properties exist on user!
console.log(user.name); // ✓ Perfect autocomplete
console.log(user.email); // ✓ Perfect autocomplete
console.log(user.password); // ✗ TypeScript error - doesn't exist!
}In other words, the types flow automatically from your backend to your frontend. Change something on the backend? Your IDE immediately shows you every place on the frontend that needs updating. This is the type safety I wish I'd had years ago.
Type Inference Magic: How tRPC Eliminates Code Generation
The secret sauce is TypeScript's type inference combined with the typeof operator. When you export type AppRouter = typeof appRouter, you're creating a type that represents your entire API surface. The tRPC client consumes this type and uses TypeScript's conditional types to infer inputs and outputs for every procedure.
This means zero runtime overhead beyond the actual API call. No code generation step in your build pipeline. No watching for schema changes. Just import the type and you're done.

Real-World Pattern: Building a Type-Safe User Management API
Let me show you a more practical example that handles the patterns I use in production applications. This includes proper error handling, input validation, and nested routers:
// server/routers/users.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
export const usersRouter = router({
list: publicProcedure
.input(z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional()
}))
.query(async ({ input }) => {
const users = await db.users.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' }
});
let nextCursor: string | undefined;
if (users.length > input.limit) {
const nextItem = users.pop();
nextCursor = nextItem!.id;
}
return {
users,
nextCursor
};
}),
getById: publicProcedure
.input(z.string())
.query(async ({ input }) => {
const user = await db.users.findUnique({
where: { id: input }
});
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found'
});
}
return user;
}),
update: publicProcedure
.input(z.object({
id: z.string(),
name: z.string().min(2).optional(),
email: z.string().email().optional()
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
try {
const user = await db.users.update({
where: { id },
data
});
return user;
} catch (error) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to update user'
});
}
})
});The client-side code becomes incredibly clean:
// Infinite scroll pagination
const { data, fetchNextPage } = trpc.users.list.useInfiniteQuery(
{ limit: 20 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);
// Single user fetch with error handling
try {
const user = await trpc.users.getById.query(userId);
console.log(user.name);
} catch (error) {
if (error.code === 'NOT_FOUND') {
// Handle missing user
}
}
// Update with validation
await trpc.users.update.mutate({
id: userId,
name: 'New Name'
// TypeScript prevents invalid fields!
});Error Handling and Input Validation with Zod
Zod integration is where tRPC really shines. Your validation schema IS your type definition. I came across this pattern after fighting with duplicate validation logic in REST APIs.
The beauty is that Zod validates at runtime while TypeScript enforces at compile time. You get both without writing types twice. When a validation fails, tRPC automatically sends a proper error response with details about what went wrong.
tRPC vs REST vs GraphQL: When to Choose What
After using all three in production, here's my pragmatic take:
Use tRPC when:
- You control both frontend and backend
- Both are TypeScript
- You want maximum developer velocity
- Your team is small to medium-sized
Use REST when:
- You're building a public API
- You need language-agnostic clients
- You have complex caching requirements
Use GraphQL when:
- Clients need flexible data fetching
- You're building a platform with many client types
- You have the resources to maintain the infrastructure
For most full-stack TypeScript applications I build now, tRPC is the obvious choice. The productivity gains are massive.
Production Patterns: Context, Middleware, and Authentication
In production, you'll need authentication and authorization. Here's how I handle it with tRPC:
// server/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import { verifyJWT } from './auth';
export async function createContext({ req, res }) {
const token = req.headers.authorization?.split(' ')[1];
const user = token ? await verifyJWT(token) : null;
return {
user,
req,
res
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
// server/trpc.ts
const t = initTRPC.context<Context>().create();
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);
// Usage in router
export const postsRouter = router({
create: protectedProcedure
.input(z.object({
title: z.string(),
content: z.string()
}))
.mutation(async ({ ctx, input }) => {
// ctx.user is guaranteed to exist here!
return await db.posts.create({
data: {
...input,
authorId: ctx.user.id
}
});
})
});This pattern gives you type-safe authentication without repeating yourself. The middleware ensures the user exists, and TypeScript knows it in the procedure.
And that concludes the end of this post! I hope you found this valuable and look out for more in the future!