End-to-End Type Safety with tRPC
How we use tRPC to eliminate the API contract gap between frontend and backend in full-stack TypeScript applications.
Every REST API has a gap. The server defines endpoints and response shapes. The client assumes those shapes are correct. The TypeScript types on each side are maintained independently, and they drift.
We've all seen it: a backend developer renames a field from userName to username, the frontend breaks in production, and the TypeScript compiler said nothing because the API contract existed only as hope.
tRPC eliminates this gap entirely.
The core idea
tRPC lets you define server-side procedures that the client calls with full type inference — no code generation, no schema files, no OpenAPI specs. The types flow directly from your server code to your client code through TypeScript's type system.
// Server: define a procedure
export const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.query.users.findFirst({
where: eq(users.id, input.id),
});
if (!user) throw new TRPCError({ code: "NOT_FOUND" });
return user;
}),
});
export type AppRouter = typeof appRouter;
// Client: call it with full type safety
const user = await trpc.getUser.query({ id: "123" });
// user is fully typed — IDE autocomplete works, renames propagate
If you rename id to userId on the server, the client immediately shows a type error. No deployment needed, no runtime failure, no integration test required to catch it.
Where it shines: complex data fetching
The real power shows up with complex queries. Consider a scheduling platform where you need to fetch a week's appointments with related data:
export const appRouter = router({
getWeekSchedule: protectedProcedure
.input(
z.object({
startDate: z.string().date(),
endDate: z.string().date(),
})
)
.query(async ({ input, ctx }) => {
const appointments = await db.query.appointments.findMany({
where: and(
eq(appointments.organizationId, ctx.user.orgId),
gte(appointments.date, input.startDate),
lte(appointments.date, input.endDate)
),
with: {
client: { columns: { id: true, name: true, email: true } },
assignee: { columns: { id: true, name: true } },
services: true,
},
});
return {
appointments,
summary: {
total: appointments.length,
revenue: appointments.reduce(
(sum, a) => sum + a.services.reduce((s, svc) => s + svc.price, 0),
0
),
},
};
}),
});
On the client, the return type is fully inferred — nested relations, computed summary fields, everything. Change the summary shape on the server, and every component that consumes it gets a type error instantly.
Middleware and context
tRPC's middleware system handles cross-cutting concerns cleanly. We use it for authentication, rate limiting, and audit logging:
const isAuthenticated = t.middleware(async ({ ctx, next }) => {
const session = await getSession(ctx.headers);
if (!session) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({
ctx: { ...ctx, user: session.user },
});
});
export const protectedProcedure = publicProcedure.use(isAuthenticated);
Every procedure built on protectedProcedure automatically has ctx.user available and typed. No manual session checking in every handler.
When we don't use tRPC
tRPC is purpose-built for TypeScript-to-TypeScript communication. We don't use it when:
- Third parties consume the API: Mobile apps from other teams, partner integrations, or public APIs need REST or GraphQL with proper documentation.
- The backend isn't TypeScript: Our Django and FastAPI projects use REST with generated TypeScript clients instead.
- The project is very simple: A landing page with one contact form endpoint doesn't need tRPC infrastructure.
The practical setup
For Next.js projects, our tRPC setup lives alongside the App Router:
src/
server/
trpc.ts # tRPC initialization, context, middleware
routers/
index.ts # Root router combining sub-routers
users.ts # User procedures
schedule.ts # Scheduling procedures
lib/
trpc.ts # Client-side tRPC hooks
The server-side router is created once and exported as a type. The client imports only the type — no server code leaks to the client bundle.
Impact on development speed
The biggest win isn't catching bugs — it's development speed. When the API contract is enforced by the compiler:
- Frontend developers don't write API types manually
- Backend changes propagate to the frontend immediately
- Refactoring across the stack is safe and fast
- Code review focuses on logic, not "did you update the types"
For full-stack TypeScript applications where we control both ends, tRPC has become our default. The zero-overhead type safety it provides makes everything else feel like unnecessary friction.