Implementing Type-Safe API Logic with tRPC Procedures
Learn how to implement tRPC procedures to eliminate type drift between your TypeScript server and client using Zod validation and middleware.
21 Apr 2026, 12:31 UTC

The Problem: Type Drift Between Client and Server
In traditional REST or GraphQL setups, the server defines a schema, and the client must manually maintain matching types. When a backend developer changes a field from a string to a number, the client-side code often fails at runtime rather than during development. This "type drift" leads to fragile deployments and extensive manual testing of API contracts.
The solution is to use tRPC Procedures. Instead of defining a separate API specification, tRPC allows you to define your server logic and export only the type of that logic to the client. This ensures that if a procedure's input or output changes on the server, the client's TypeScript compiler will immediately flag an error.
Understanding the Procedure Mechanism
A tRPC API is composed of a Router containing multiple Procedures. A procedure is the functional unit of your API—essentially a typed function that lives on the server but is callable from the client. There are two primary types of procedures:
- Query: Used for fetching data. These map to HTTP GET requests.
- Mutation: Used for creating, updating, or deleting data. These map to HTTP POST requests.
To ensure runtime safety, tRPC integrates input validation (typically via Zod) directly into the procedure definition. This prevents malformed data from reaching your business logic.
Worked Example: User Profile Management
This example assumes tRPC v10+ and Zod for validation. The following code would be implemented on your server (e.g., in a server/trpc.ts file).
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
// 1. Initialize tRPC
const t = initTRPC.create();
// 2. Define the router
export const appRouter = t.router({
// A Query procedure to fetch a user by ID
getUserById: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input.id } });
if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
return user;
}),
// A Mutation procedure to update a username
updateUsername: t.procedure
.input(z.object({ name: z.string().min(3).max(20) }))
.mutation(async ({ input }) => {
return await db.user.update({ data: { name: input.name } });
}),
});
// 3. Export ONLY the type of the router for the client
export type AppRouter = typeof appRouter;Implementing Middleware for Authentication
Procedures can be chained with middleware to handle cross-cutting concerns like authentication. By creating a "protected procedure," you can ensure that specific logic only executes if a user is authenticated.
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: { auth: ctx.user },
});
});
// Create a reusable protected procedure
const protectedProcedure = t.procedure.use(isAuthed);
// Use it in the router
export const appRouter = t.router({
getPrivateSettings: protectedProcedure.query(({ ctx }) => {
return db.settings.findMany({ where: { userId: ctx.auth.id } });
}),
});Limitations and Engineering Trade-offs
TypeScript Dependency
The primary limitation of tRPC is that it requires a full-stack TypeScript environment. If your client is written in Swift, Kotlin, or plain JavaScript, you lose the end-to-end type safety. In those cases, a traditional REST API or GraphQL is more appropriate.
State Management
tRPC is a transport layer, not a state management library. It does not provide built-in caching or global state. To handle loading states, caching, and re-fetching, tRPC is almost always paired with TanStack Query (formerly React Query). Without this pairing, you would have to manually manage the lifecycle of every API call.
Router Complexity
In large-scale applications, avoid deeply nesting routers. While tRPC supports sub-routers, excessive nesting can lead to overly long call paths (e.g., trpc.user.settings.notifications.update.useMutation()) and can occasionally cause circular dependency issues during the build process if routers reference each other.
Verification and Diagnostics
To verify that your procedures are working as intended, perform the following checks:
- Type Check: On the client, attempt to pass a number to a procedure that expects a string (via the Zod schema). The TypeScript compiler should highlight the line in red before you even run the code.
- Network Inspection: Open the Browser DevTools Network tab. A
queryshould appear as aGETrequest with the procedure name and input as URL parameters. Amutationshould appear as aPOSTrequest with the input in the request body. - Runtime Validation: Send a request via an external tool (like Postman or cURL) that violates the Zod schema. The server should return a
BAD_REQUESTerror without executing the resolver logic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.