Keeping Your Types in Sync: Lessons From tRPC in the Real World
Guilherme Marconi
July 8, 2025
•
9 min read
How do you keep your API types in sync between the backend and the frontend without losing your sanity?
At BetaAcid, we've tried just about everything—from manually updating Swagger docs (never again) to generating clients, to fully end-to-end solutions.
The industry consensus these days usually leans on REST APIs documented with OpenAPI: you define a contract up front, then generate types and mocks for both your client and server to keep everything aligned. Tools like Orval make that workflow much smoother—we've even written about our experience with it.
In one recent project, though, the stakes were high: tight deadlines, a TypeScript-heavy stack, and a team moving fast. We needed a way to keep our frontend and backend in lockstep—without extra tooling layers or constant regeneration steps.
This post unpacks why we ended up picking tRPC for this particular build, what we learned along the way, and where other tools like Orval still shine.
Why This Project Was Different
Some projects give you the luxury of a long discovery phase, clear requirements, and a stable contract between frontend and backend. This wasn’t one of them.
From the outset, we knew we’d be learning new things about the business rules as we went. The domain was large and complex enough that discovery and development had to happen in parallel.
Endpoints changed frequently, and the backend models were refined iteratively. We needed a workflow that wouldn’t punish us every time a new property appeared in a response or a validation rule shifted.
Given that context, we chose to set up a monorepo with a backend-for-frontend approach—it let us insulate the frontend from the inevitable adjustments that would surface as the details came into focus while letting us share business logic, types, and components across multiple frontend apps without extra packages or versioning overhead. The API wasn’t intended for third-party consumption, which meant we could optimize purely for our own productivity and leave formal OpenAPI contracts aside.
We’ve used tools like Orval successfully in past projects where a contract-first workflow was the right fit. With Orval, we’d define an OpenAPI spec up front, then generate a typed client and mocks automatically. This works great when your contract isn’t shifting daily. But in this case, the constant iteration, the need for immediate type safety, and the fact that everything lived in TypeScript pointed us toward tRPC as the path of least resistance.
What tRPC Brought to the Table
If you’ve ever looked at tRPC’s tagline—“Move Fast and Break Nothing”—it sounds a little too good to be true. In this project, though, it came remarkably close.
Because our entire stack was TypeScript, tRPC gave us fully inferred types without any extra tooling or build steps. Any time a procedure changed on the backend, our frontend either immediately picked up the new shape or threw a TypeScript error the moment we tried to use an outdated call. No silent drift, no guessing whether the client was up to date.
This was exactly what we needed: a way to keep iterating quickly without creating a trail of broken contracts behind us. In practice, it felt almost frictionless. Once we resolved a few early issues, it was easy to forget we were using it at all. No, really—it works. I was skeptical too.
Here’s a small example of how simple it felt once things were in place. On the backend, we defined a procedure like this:
// server/trpcRouter.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getMyAwesomeData: t.procedure
.input(
z.object({
name: z.string(),
})
)
.query(({ input }) => {
return { message: `Hello, ${input.name}!` };
}),
});
// Export type definition of API
export type AppRouter = typeof appRouter;
And in the frontend, using TanStack Query, we could call it with fully inferred types:
// client/someComponent.tsx
import { useQuery } from '@tanstack/react-query';
import { trpc } from './trpcClient';
const { data: awesomeData, isLoading } = useQuery({
...trpc.getMyAwesomeData.queryOptions({ name: 'BetaAcid' }),
});
One of the other benefits was being able to infer types directly from procedures without duplicating them manually:
import { inferProcedureInput, inferProcedureOutput } from '@trpc/server';
export type GetMyAwesomeDataInput = inferProcedureInput<
AppRouter['getMyAwesomeData']
>;
export type GetMyAwesomeDataOutput = inferProcedureOutput<
AppRouter['getMyAwesomeData']
>;
This meant the input type would look like:
// Equivalent inferred type
type GetMyAwesomeDataInput = {
name: string;
};
If we changed the shape of the response or the expected parameters on the backend, TypeScript would immediately flag it in the frontend - no more running commands to generate types.
What Tripped Us Up
Like any tool, tRPC came with its own set of trade-offs and quirks to work through.
tRPC doesn’t officially support file uploads via form-data, so we worked around that by handling uploads directly on the frontend via signed URLs, and then loading the file from the cloud on the backend when needed. Not ideal, but in our case, it was worth it to keep the rest of the workflow simple.
We initially struggled to ensure proper state invalidation. By default, TanStack Query will aggressively refresh queries, and disabling this behavior led to inconsistent state across pages—for example, adding a “todo” but not seeing it appear in the list until a manual refresh. Having faced this before, we knew we needed to properly invalidate cached data, but digging into the docs to figure out exactly how to do it turned into a small rabbit hole. While this was technically an interplay between TanStack and tRPC, the smaller tRPC community meant there were fewer examples or discussions to learn from compared to more established REST-based workflows.
If you are curious, here’s the approach that worked for us:
queryClient.invalidateQueries({
queryKey: trpc.getMyAwesomeData.queryKey(),
exact: false,
refetchType: 'all',
});
Setting the refetchType
to all
was our missing piece here, since tRPC queries appeared to be stale
by default (for reasons we decided weren’t worth untangling).
We also ran into an issue with TypeScript path aliases: when aliases (e.g., @router/user
) were set on tsconfig, importing tRPC types on the frontend made them appear as any
. It looked like tRPC’s type inference couldn’t follow the alias resolution, possibly due to how it wires types across client/server boundaries. Switching to relative imports resolved it. Not ideal, but a reminder that tools making heavy use of TypeScript inference can be sensitive to your tsconfig
setup.
Finally, using tRPC meant getting used to a different mindset for querying and testing. We could still use tools like Postman or cURL, but the process involved recreating procedure calls instead of sending a simple REST request. For example, with a REST API you might test this endpoint with cURL:
curl -X GET "https://api.example.com/pets"
With tRPC, you’d typically spin up a Node REPL to call your procedure programmatically:
await trpcClient.getMyAwesomeData.query({ name: 'BetaAcid' });
Or, if you really want to use curl
, you’d have to POST a JSON body that tells the server which procedure to call:
curl -X POST "https://api.example.com/trpc/getMyAwesomeData" \
-H "Content-Type: application/json" \
--data '{"input":{"name":"BetaAcid"}}'
This worked fine, but it took longer to feel second nature.
What tRPC Doesn't Give You
Even though tRPC gave us the speed and type safety we needed, there were a few things it doesn't provide:
-
Formal API documentation. Tools like Orval, for example, generate an OpenAPI spec automatically, which can be shared with other teams or used to create external clients. In this case, the API was internal, so we didn’t actually need it—but in a broader integration scenario, it could have been important. (Plus, there’s something oddly comforting about a nice, well-formatted YAML file—said no one ever. But try explaining to your enterprise security team why your API doesn't have a formal spec. Suddenly YAML feels downright cozy.)
-
Auto-generated mocks. Contract-first workflows often include built-in mocking tools. While there might be community solutions for tRPC mocking, we didn’t explore them much because the project moved quickly and we relied on lightweight AI-generated mocks and test data instead.
-
Predictability. Contract-first tooling has been around longer and has more examples of large-scale adoption. With tRPC, some solutions—like cache handling—required a bit more experimentation to find what worked reliably.
-
Ecosystem maturity. tRPC has fewer off-the-shelf integrations, so some production concerns (tracing, metrics, monitoring) require more DIY effort.
Conclusion
tRPC removed a lot of process overhead, but it also removed some of the scaffolding that can help teams coordinate across larger organizations. In this project, that trade-off worked in our favor.
We had exactly what we needed: a fast-moving development workflow, fully inferred types, and the confidence that our backend and frontend wouldn’t drift apart as requirements evolved.
If you're working in a TypeScript monorepo with no external API consumers and don't want to use extra steps and tools for type generation, tRPC will save you time and headaches. If you need formal contracts, external integrations, or coordination across large teams, stick with contract-first approaches. The decision should be straightforward once you're honest about your actual constraints. Because life’s too short to wonder if your types are lying to you.
SHARE THIS STORY
Get in touch
Whether your plans are big or small, together, we'll get it done.
Let's get a conversation going. Shoot an email over to projects@betaacid.co, or do things the old fashioned way and fill out the handy dandy form below.