tRPC’s Shared Router Types: Convenient Coupling for TypeScript Stacks
tRPC removes separate API schemas by exporting your server router type to the client. See a protected-procedure example with Zod and context, plus the coupling limits that matter for third-party consumers.
31 Dec 2025, 13:03 UTC

The schema drift problem
Your API changed, but the client did not find out until a user hit a runtime error. In a typical stack, the server exposes an OpenAPI or GraphQL schema, and the frontend consumes generated types. The schema is a second artifact that must stay in sync with the implementation. When it drifts, TypeScript cannot help because the contract lives in a separate file.
tRPC approaches this from the other direction. Instead of generating a schema and then deriving types, you define procedures once on the server and export the router type. The client imports that type directly. There is no intermediate contract to maintain.
How shared router types work
A tRPC router is a plain object of procedures. A procedure bundles an input validator, a handler, and optional middleware. Because the router is written in TypeScript, the compiler can infer the exact input and output shapes of every procedure.
Three pieces make this practical in real applications:
- Context: a per-request object created by a context factory. It carries request-specific data such as the authenticated user, a database connection, or a logger. Every procedure handler receives this context.
- Input validation: libraries like Zod are attached to a procedure with
.input(). The schema validates runtime data before the handler executes, and the same schema refines the TypeScript type. - Middleware: functions that run before a handler. They can inspect the context, enforce permissions, or add logging. A
protectedProcedureis simply a public procedure with authentication middleware attached.
On the client, tRPC exposes a proxy object. When you call trpc.post.create.useMutation(), the TypeScript language server reads the exported router type and offers autocompletion for procedure names and input shapes. The call is still a standard HTTP request under the hood.
Worked example: a protected mutation
Suppose you are building a blogging API. The server needs to know who is making the request, validate the payload, and reject unauthenticated users. The following pattern is representative of a tRPC v10 or v11 setup; verify exact initialization syntax against your installed version.
// server/context.ts
export async function createContext(opts: { req: Request }) {
const token = opts.req.headers.get('authorization');
const user = token ? await getUserFromToken(token) : null;
return { user, db: database };
}
export type Context = Awaited<ReturnType<typeof createContext>>;
The context factory runs for every request. It extracts the Authorization header, resolves a user, and attaches a database handle. The exported Context type is what procedures will see.
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import type { Context } from './context';
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
Here protectedProcedure narrows the context: inside its handler, ctx.user is guaranteed to be non-null.
// server/routers/post.ts
import { z } from 'zod';
import { router, protectedProcedure } from '../trpc';
export const postRouter = router({
create: protectedProcedure
.input(z.object({
title: z.string().min(1),
content: z.string(),
}))
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: { ...input, authorId: ctx.user.id },
});
}),
});
The .input() call does two jobs. At runtime, Zod rejects malformed payloads before the handler runs. At compile time, the handler receives input typed as { title: string; content: string }. If the client sends a number for title, TypeScript flags the error in the editor.
To verify the type safety locally, run npx tsc --noEmit from the project root. No special permissions are required. The expected check is that an incorrect client call—such as passing { title: 42 }—produces a compile error. The risk is that very large routers can make this command slow.
To verify the transport, open the browser network tab and trigger the mutation. You should see a standard HTTP request to the tRPC endpoint, typically a POST with a JSON body. This confirms that tRPC is not inventing a new protocol; it is using HTTP as the wire format.
The coupling trade-off
Sharing the router type is useful, but it is also a coupling decision. The client and server now share a single source of truth, which means they must be released together or kept compatible by convention.
Three limitations follow from this:
| Limitation | Impact |
|---|---|
| TypeScript-only boundary | Non-TypeScript clients cannot import the router type. They need a separate contract such as REST or OpenAPI. |
| Compiler overhead | Very large routers increase TypeScript inference work, slowing IDE responsiveness and tsc runs. |
| Third-party exposure | Because the API shape is defined in application code rather than a language-agnostic schema, exposing it to external consumers requires extra work. |
An actionable decision
Choose tRPC when your primary consumers are first-party TypeScript applications that you control. The elimination of schema drift usually outweighs the coupling cost in that setting.
If you must serve third-party developers, mobile clients, or public APIs, treat tRPC as an internal transport. Keep the shared router for your own frontend, and expose a separate documented HTTP interface for external traffic. That hybrid approach preserves the type-safety benefit where it matters most without forcing non-TypeScript consumers to reverse-engineer your router.
Before committing, run a small spike: define one procedure with Zod input and context, confirm the client type error appears in your editor, and check the network tab to see the HTTP request. If that loop feels fast, the trade-off is likely worth it for your stack.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.