When tRPC's End-to-End Types Actually Pay Off (and When They Don't)
tRPC's real payoff isn't "types" — it's deleting the duplicated contract between server and client. Here's the worked example, the rename test that proves it, and where it falls down.
04 Jan 2026, 19:15 UTC

You rename publishedAt to createdAt in a server handler. TypeScript is green everywhere — the client has its own hand-written Post interface, so nothing complains. The bug ships, and the UI renders "Invalid Date" until someone reports it. This is the failure mode tRPC is designed to eliminate: not by adding more types, but by removing the duplicated ones.
The thesis of this post is simple: tRPC is most compelling when one TypeScript codebase owns both the API and its client, because the procedure definition becomes the contract. There's no schema file to regenerate, no client SDK to publish, no drift between what the server returns and what the client thinks it returns.
Procedures as the contract
In tRPC (this post assumes the v10-era API; check your installed major version before copying imports), you initialize a router and define procedures with publicProcedure.query or publicProcedure.mutation. Adding .input(z.object(...)) with Zod couples runtime validation to a statically inferred input type — one definition serves both purposes.
// server/routers/post.ts — runs on the server
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, publicProcedure } from "../trpc";
export const postRouter = router({
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({ where: { id: input.id } });
if (!post) {
throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" });
}
// Return a DTO, not the raw row, if the shapes differ
return { id: post.id, title: post.title, publishedAt: post.publishedAt };
}),
});On the client, the type of data is inferred from the server's return value — no interface is written anywhere:
// client component — runs in the browser
function PostView({ id }: { id: string }) {
const { data, error, isLoading } = trpc.post.byId.useQuery({ id });
if (isLoading) return <Spinner />;
if (error) return <p>{error.message}</p>; // error.data.code is typed too
return <h1>{data.title}</h1>; // data.publishedAt exists; data.body would not compile
}The client integration typically rides on TanStack Query-style hooks, so you get caching, invalidation, loading states, and optimistic updates without writing fetch logic. The server side stays framework-agnostic through adapters for Node, Next.js, Express, and others.
The verification that sells it
The payoff is easy to demonstrate. Rename publishedAt in the procedure's return object, then run your type-check (tsc --noEmit at the repo root, or your editor's language server). The client component fails to compile before anything runs. That's the entire value proposition in one step: refactors become compiler errors instead of runtime surprises.
Second check: call byId with a non-UUID string. Zod rejects it, and the client receives a typed tRPC error (BAD_REQUEST with validation details) rather than a generic 500. You can inspect error.data.code in the client to confirm the error shape is structured, not a string you're parsing.
Where the trade-offs bite
tRPC does not remove the network boundary. Authorization, rate limiting, idempotency for mutations, and filtering sensitive fields still belong in your procedures or middleware — type safety says nothing about whether a user should see a record.
End-to-end inference also makes it easy to leak database rows straight to the UI. If your persistence shape differs from your API shape (and it usually does eventually), return explicit DTOs from procedures, as in the example above. Otherwise a schema migration becomes a breaking UI change with no warning beyond the type-checker — which catches it, but only after you've coupled everything.
Practical limitations worth knowing up front:
- Third-party consumers: if external teams or other languages need your API, a tRPC router is not a portable contract. OpenAPI/REST or GraphQL serves that case better.
- Public documentation: there's no generated API doc artifact comparable to an OpenAPI spec.
- Binary uploads, long-running jobs, webhooks, and cacheable public GETs: conventional HTTP endpoints are usually a better fit than procedures.
- Version churn: v10-era APIs differ from earlier releases, and newer major lines may adjust React Query integration or app-router conventions. Confirm your installed versions against the official examples before adopting any snippet, including the ones here.
Choosing deliberately
Reach for tRPC on internal product APIs, dashboards, and monorepos where server and client ship together and the same team owns both. Reach for OpenAPI or GraphQL when the contract itself is a product — public APIs, partner integrations, or polyglot consumers.
If you're evaluating it, don't start by migrating anything. Build one Zod-validated query, wire it to one screen, and run the rename test above. Then measure that screen's network waterfall to confirm TanStack Query caching is actually reducing requests rather than hiding over-fetching. If the type-checker catches the rename and the waterfall looks honest, you have real evidence — not a blog post's word — that the pattern fits your codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.