tRPC's Real Superpower: Type Inference Without Code Generation
tRPC replaces generated SDKs and OpenAPI specs with a single exported type. Here's how the inference works, a minimal working example, and the monorepo coupling trade-off you need to price in.
26 Jul 2026, 18:31 UTC

Every API client you've written has the same hidden tax: the moment the server changes a field name or an input shape, your client compiles fine and breaks at runtime. Teams patch this with generated SDKs, OpenAPI specs, or sheer vigilance. tRPC takes a different bet: if both sides are TypeScript, you don't need a schema file or a generator at all. You export one type from the server, import it on the client, and the compiler does the rest.
The one-type contract
The entire mechanism fits in one line on the server:
// server/router.ts\nexport const appRouter = t.router({\n userById: publicProcedure\n .input(z.object({ id: z.string() }))\n .query(({ input }) => db.user.findUnique({ where: { id: input.id } })),\n});\n\nexport type AppRouter = typeof appRouter;That AppRouter type is a compile-time-only export — it emits no JavaScript and creates no runtime dependency. The client imports it with import type and hands it to the client factory:
// client/api.ts\nimport { createTRPCClient, httpBatchLink } from '@trpc/client';\nimport type { AppRouter } from '../server/router';\n\nexport const client = createTRPCClient<AppRouter>({\n links: [httpBatchLink({ url: 'http://localhost:3000/trpc' })],\n});From here, client.userById.query({ id: 'u_1' }) is fully typed: the input must match the Zod schema's inferred type, and the return value is whatever the resolver returns. Rename userById to getUser on the server and every client call site fails to compile. That is the feature — no codegen step, no spec drift, no stale SDK.
One schema, two jobs
The Zod schema in .input(...) does double duty. At runtime it validates the incoming payload before your resolver executes — an invalid body is rejected with a BAD_REQUEST TRPCError before your code runs. At compile time, TypeScript infers the input type from the same schema, so the client-side argument and the server-side validation can never disagree. You write the contract once and get both enforcement layers.
You can verify this quickly: after wiring up a minimal router, open the client file in an editor and deliberately pass the wrong shape — client.userById.query({ id: 123 }) — and confirm you get a red squiggle before anything runs. Then send a bad payload over the wire (e.g., with curl against the adapter's endpoint) and confirm the server rejects it with a 400-class error rather than a resolver crash.
Where this fits and where it hurts
The catch is structural: the client must import a type from the server package. In a monorepo — pnpm workspaces, Turborepo, Nx — this is free. Across separately versioned repos, it becomes a real deployment-coupling problem. Type safety only protects you if client and server build against the same commit or a published types package; otherwise you can deploy a server change that silently invalidates an already-deployed client, and nothing catches it.
Other honest limitations:
- TypeScript-only. A mobile app or third-party consumer gets no benefit; the HTTP wire format is an implementation detail, not a stable public REST contract. If you need external consumers, you still need OpenAPI or GraphQL.
- Version sensitivity. The examples above follow the tRPC v10 API (
initTRPC.create(), standalone adapters). Check@trpc/serverin your package.json — v9 and earlier have a different surface, and batching/subscription configuration varies by adapter. - Serialization overhead. tRPC adds a transform layer versus a hand-tuned endpoint. For most CRUD workloads this is irrelevant, but measure before adopting it in a hot path.
A practical decision rule
Reach for tRPC when you control both ends, both ends are TypeScript, and they live in one repo or share a types package with disciplined versioning. That's a lot of modern full-stack apps — Next.js projects especially, where the adapter story is first-class. Skip it when your API has consumers you don't control, or when client and server ship on independent release cycles with no shared build.
If you're evaluating it, the cheapest proof is the rename test: build one query, consume it from a typed client, then rename the procedure. If your client lights up with compile errors before you've saved the file, you've just experienced the entire value proposition — and you can decide from there whether the coupling cost is worth it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.