Eliminating API Drift with tRPC Router Type Exports
tRPC achieves end-to-end type safety by exporting server router types. This eliminates manual schema synchronization and prevents runtime API drift.
07 Nov 2025, 22:24 UTC

The cost of API drift in full-stack TypeScript
In REST or GraphQL stacks the server and client are separated by a contract. That contract is a secondary artifact: an OpenAPI document, a GraphQL schema, or manually maintained TypeScript interfaces. When a field changes from string to number on the server, the client often discovers it at runtime.
The useful takeaway is to move failure from the browser console to the IDE. tRPC treats the server router definition as the single source of truth and exports only the type of that router to the client. No server implementation ships to the frontend.
How the type export creates end-to-end safety
tRPC uses TypeScript generics and infer to map server procedure definitions to client call signatures. Instead of code generation, the client proxy is typed against the exported router type.
On the server you define procedures with input validation, typically Zod or Valibot, and export the shape via type AppRouter = typeof appRouter. This exports which procedures exist, their input types and return types, without leaking database code or private helpers.
Context injection is typed the same way. initTRPC.context<Ctx>().create() makes database connections or auth state available to all procedures with a shared type.
Practical implementation in a monorepo
The client must be able to import the type from the server package. That requires a TypeScript monorepo or shared codebase.
Server definition
// packages/server/trpc.ts
import { initTRPC, router } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
return { id: input.id, name: 'John Doe' };
}),
});
export type AppRouter = typeof appRouter;
Client consumption
// packages/web/api.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@repo/server/trpc';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000/api/trpc' })]
});
const user = await trpc.getUser.query({ id: 'user-123' });
The proxy provides autocomplete for procedure names and validates input shape against the Zod schema defined on the server. Changing the schema to z.number() makes the client call a TypeScript error without changing client code.
Verifying the safety net
A practical check is to modify a Zod input on the server and observe the compiler. The client file importing AppRouter should flag the mismatched argument.
Inspect the network tab to confirm transport. tRPC uses standard HTTP/JSON despite the high-level type abstraction. No custom schema language is required.
Trade-off: coupling and deployment
Type safety requires a shared TypeScript boundary. tRPC cannot provide this across separate repositories without complex tooling.
Tight coupling means server and client versions must stay compatible. Deploying a breaking router change before the client updates will cause runtime failures once the client is redeployed. Versioning or atomic deployments mitigate this.
Over-reliance on complex Zod schemas adds runtime validation cost on the server for each request.
Actionable closing
For a full-stack TypeScript app inside a monorepo, replace manual API interfaces with an exported AppRouter type. Start with volatile endpoints to gain immediate feedback from the type checker and reduce API drift.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.