Stopping the 'Out-of-Sync' API Bug with tRPC and Zod
Learn how tRPC and Zod eliminate the 'out-of-sync' API bug by merging runtime validation with compile-time type safety, removing the need for manual API interfaces.
01 Jul 2026, 09:50 UTC

The Fragility of the Network Boundary
In most full-stack applications, the boundary between the frontend and backend is a source of constant friction. You update a field name in a database schema, update the API response, and then spend the next hour hunting down every frontend component that expected the old key name. Even with shared TypeScript interfaces, you are often relying on a "promise" that the server actually sends what the interface claims it does.
The core problem is that TypeScript types disappear at runtime. If your server sends a string where the client expects a number, the type system won't save you from a runtime crash. tRPC addresses this by merging runtime validation with compile-time type safety, so that if your code compiles, the API contract is also enforced when requests actually arrive.
How tRPC Bridges the Gap
tRPC achieves end-to-end type safety without a code generation step (unlike typical GraphQL or OpenAPI workflows). Instead, it leans on TypeScript's type inference. By exporting only the type of your server router—not the implementation—the client gains full knowledge of every procedure's inputs and outputs.
When you integrate Zod (a TypeScript-first schema declaration and validation library), you define the source of truth for your data shape once. tRPC uses that Zod schema for two jobs at the same time:
- Runtime validation: it intercepts incoming requests and rejects data that doesn't match the schema.
- Type inference: it derives the TypeScript types for the client-side proxy, giving you autocomplete and compile-time errors the moment a schema changes.
Implementing a Validated Procedure
This example assumes a tRPC v10-style setup with Zod installed, in a project where the client and server share a workspace (for example, a monorepo). The server file needs permission to export the router type; no special runtime configuration is required beyond your normal tRPC adapter.
Server-Side Definition
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
createUser: t.procedure
.input(
z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().optional(),
})
)
.mutation(async ({ input }) => {
// 'input' is fully typed here based on the Zod schema
return { success: true, userId: '123' };
}),
});
// Export only the type of the router for the client
export type AppRouter = typeof appRouter;
Client-Side Consumption
On the client, you initialize your tRPC hooks (for example via createTRPCReact) with the AppRouter type. From then on, the IDE knows exactly what each procedure's input requires.
// TypeScript flags this call: 'username' is too short per the schema type,
// and any missing or mistyped field is caught at compile time.
const mutation = trpc.createUser.useMutation();
mutation.mutate({
username: 'jd',
email: 'not-an-email',
// age is optional, so omitting it is fine
});
Note that compile-time checks catch structural mismatches, while constraint violations like a short username or malformed email are enforced by Zod at runtime on the server.
The Trade-offs of Tight Coupling
This workflow eliminates boilerplate, but it introduces a specific architectural constraint: tight coupling. Because the client depends on the server's type definitions, tRPC fits best in monorepo-style development where frontend and backend share a workspace.
If you are building a public-facing API for third-party developers, tRPC is the wrong tool. External consumers cannot import your AppRouter type into their own separate projects, so a decoupled specification like OpenAPI remains the standard choice there.
Also be mindful of Zod schema complexity. Extremely deep or recursive schemas add runtime overhead to the validation phase of every request. For typical CRUD workloads this is negligible, but it is worth measuring in high-throughput systems.
Verifying Your Contract
To confirm the type safety is actually working, run a "break test":
- Change a field name in your Zod schema (for example, rename
usernametohandle). - Check your frontend code. Your editor or
tscshould report a type error at every call site that invokescreateUser. - Send a request with the old field name using a tool like cURL or Postman. The server should reject it with a validation error produced by Zod, proving runtime validation is active.
If either check fails, the usual culprits are the client importing the router implementation instead of the type, or the client and server packages resolving different versions of the router type. Fixing the import to use import type { AppRouter } resolves the first case.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.