Catching API Contract Drift at Compile Time With an Incremental tRPC Rollout
tRPC turns a renamed backend field into a TypeScript compile error. How to roll it out endpoint by endpoint next to REST, and where it's the wrong tool.
This article is published in English.
Sources: **I Switched From REST to tRPC. My Team Almost Revolted. Then They Loved It.**
Picture a lending platform where a backend engineer renames applicantName to applicantFullName in the loan application response. The API, the backend tests and the pull request are all updated and approved, yet two days later the loan summary screen quietly renders blank names: no exception, no console warning, just an empty string, because the frontend still reads a key that no longer exists. Both sides were written in TypeScript, and none of that type information crossed the network boundary. Below you will see how tRPC closes that gap, how to adopt it gradually beside REST, and when it does not fit.
What tRPC is, and what it is not
tRPC is not a wire protocol and it does not replace REST or GraphQL at the transport level. Requests are still ordinary HTTP calls. What it adds is a shared type layer: the return types of your server procedures are inferred by TypeScript and flow straight into the client, with no schema file and no code generation step in between.
A useful mental model is a function call that happens to execute on the server. The contract between caller and callee is whatever the TypeScript compiler says it is, not a separate specification document that may or may not have been updated. When the server changes what a procedure returns, every client call site that depends on the old shape fails to compile. The mistake surfaces in the editor at the exact line that reads the missing field, instead of in production days later.
The cost of an untyped API layer
In the scenario above, the frontend service layer looked roughly like this: every method fetched JSON and returned it typed as any, and components read fields off the result on trust.
// services/api.service.ts
async getLoanApplication(id: string): Promise<any> {
const res = await this.http.get(`/api/loans/${id}`).toPromise();
return res;
}
// component
const loan = await this.apiService.getLoanApplication(loanId);
console.log(loan.applicantName); // works until it doesn't
The team counted 47 methods with an any return type. Each one was a place where renaming a field, reshaping a nested object or changing an enum value compiled cleanly and failed only at runtime, in front of a borrower checking the status of an application. In a financial product that is more than a cosmetic bug: showing wrong or missing data to a customer can become a compliance problem.
Migrating with the strangler fig pattern
Rewriting every endpoint at once is risky. The safer route is the strangler fig approach: leave the existing REST endpoints untouched, mount tRPC alongside them, and move one endpoint at a time. Each migrated endpoint gains type safety immediately, and nothing that is not migrated yet has to change.
Defining the router
The server side starts with initTRPC.create(), which returns a builder object conventionally named t. Routers can be nested, so a loans sub-router groups the loan procedures. Notice three things in the snippet. First, each procedure declares its input with a Zod schema, so a malformed ID or an unknown status is rejected at runtime before the handler runs, and the same schema gives the handler a typed input. Second, query is used for reads and mutation for writes. Third, nobody writes a return type by hand: TypeScript infers it from whatever the database call returns. The last line exports only the router's type, which is what the client consumes.
// server/router/index.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
loans: t.router({
getById: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const loan = await db.loans.findUnique({ where: { id: input.id } });
if (!loan) throw new TRPCError({ code: 'NOT_FOUND' });
return loan; // TypeScript infers the return type from the DB model
}),
updateStatus: t.procedure
.input(z.object({
id: z.string().uuid(),
status: z.enum(['PENDING', 'APPROVED', 'REJECTED', 'DISBURSED'])
}))
.mutation(async ({ input }) => {
return db.loans.update({
where: { id: input.id },
data: { status: input.status }
});
}),
}),
});
export type AppRouter = typeof appRouter;
One detail to fix if you copy this: the handler throws TRPCError, which also has to be imported from @trpc/server. It is missing from the import line here.
Creating the client
On the frontend, the client is built from the AppRouter type. Because the import uses import type, no server code ends up in the browser bundle; only the type information is shared. httpBatchLink combines several calls made in the same tick into one HTTP request, and its headers callback runs per request, which is a convenient place to attach a bearer token.
// lib/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../server/router';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
Authorization: `Bearer ${getToken()}`,
};
},
}),
],
});
The factory name reflects the tRPC version in use: createTRPCProxyClient is the v10 API, while newer releases name it createTRPCClient. Check the current docs for the version you install. Sharing the type also assumes the client can import it, typically through a monorepo or a shared workspace package.
Calling a procedure
A call now reads like a method on a local object. The result is fully typed, so referencing a field that the server no longer returns is a compile error rather than a silent undefined.
// In a component or service
const loan = await trpc.loans.getById.query({ id: loanId });
// loan is fully typed - TypeScript knows every field
console.log(loan.applicantFullName); // if this field doesn't exist, compile error
Answering the team's objections
Expect pushback. Three objections usually come up, and each deserves a straight answer.
- Why not OpenAPI with code generation? It is the strongest objection, because generated clients do provide types. The price is a schema that must be maintained, a generation step that must run, generated files that must be committed, and discipline to keep all of it in sync. With tRPC the server code itself is the single source of truth. If you prefer the OpenAPI route, see how to generate a typed Next.js client from NestJS Swagger.
- What about consumers that are not written in TypeScript? In this case, a few internal Python tools called the API. Their endpoints simply stayed on REST. Incremental migration makes that trivial, since both styles live side by side.
- Is the learning curve worth it? Routers, procedures and Zod input schemas are a new vocabulary. A 30-minute walkthrough plus a short internal guide was enough to get the team productive; the concepts are few, but that first session matters.
When the payoff becomes obvious
Roughly six weeks in, the loan application state machine needed a structural change. A flat status field became a nested object that tracks both the review stage and the approval outcome:
// Before
type LoanApplication = {
status: 'PENDING' | 'APPROVED' | 'REJECTED';
}
// After
type LoanApplication = {
applicationStatus: {
stage: 'DOCUMENT_COLLECTION' | 'CREDIT_REVIEW' | 'FINAL_APPROVAL';
approvalStatus: 'PENDING' | 'APPROVED' | 'REJECTED' | null;
};
}
Under the old setup, every component reading application.status would have kept compiling and broken at runtime, and the only defence would have been a codebase-wide search followed by hope. With tRPC, changing the procedure's return type immediately flagged 8 frontend call sites. All of them were fixed in an afternoon, and a change that crossed several systems shipped with no runtime bugs. After that, the conversation shifted from questioning the migration to asking which endpoint would move next.
Results after six months
anyreturn types in the API layer dropped from 47 to 3.- The remaining 3 are third-party webhook payloads with no published types; they are parsed through validated Zod schemas instead of being trusted blindly.
- Migrated endpoints have had zero type-mismatch bugs at runtime since the migration started.
- 11 type errors were caught at compile time that would otherwise have reached production.
The 44 removed any types stand for latent bugs that never got the chance to happen. For patterns to eliminate the last stragglers, see six type-safe replacements for any.
Where tRPC does not fit
tRPC only pays off when TypeScript sits on both ends of the connection. A polyglot backend, or a public API consumed by third parties, needs an explicit, language-neutral contract such as OpenAPI or GraphQL.
Keep in mind, too, that the guarantee is a compile-time one for outputs. Zod validates inputs at runtime, but the inferred return type does not check anything at runtime, and returning a raw database record, as the router above does, sends every column to the client. For sensitive domains, select or map the fields you actually intend to expose.
Key takeaways
- Having TypeScript on both sides is not the same as having a typed contract between them; tRPC supplies that missing link without a schema or codegen step.
- Migrate endpoint by endpoint next to REST, so non-TypeScript consumers keep working and each step is small enough to review.
- The biggest win appears during refactors, when the compiler lists every affected call site for you.
- Where data represents money and customer records, this level of type safety is a baseline requirement rather than a luxury, and tRPC makes it achievable without heavy boilerplate.