tRPC: Your API and Your Client, Sharing One Type
Every team that builds a TypeScript API hits the same frustration. The server changes a field name, or a procedure starts expecting a different argument, and the client keeps calling the old shape. Nothing complains until a user hits the broken page in production. The usual fix is a shared schema file, a code generator, or an OpenAPI document that someone has to remember to regenerate. tRPC takes a different route. It skips the schema file entirely and lets the client import the server router type, so the editor knows exactly which procedures exist and what each one accepts. The question is what it costs in practice, and whether the trade is worth it for a small team.
What I would check before using it
My first check is whether the whole stack is TypeScript. tRPC only shares types between a TypeScript server and a TypeScript client, so a mobile app in another language gets nothing from it. If not, you still need a documented HTTP contract, and tRPC adds a second one.
My second check is who the API is for. An internal API that only your own frontend calls is the sweet spot: you control both ends, and a server rename shows up as a red squiggle on the client the same afternoon. A public API is the opposite. Strangers need stable docs and versioning they can read without your source code, and tRPC gives them none of that.
My third check is how the team feels about framework coupling. tRPC has a React integration through TanStack Query, and it works with plain Express too. But adopting it ties the shape of your API to one library's idea of routers and procedures, so moving away later means rewriting every call site, not just swapping a fetch wrapper.
My fourth check is validation. Types disappear at runtime, so a type alone cannot stop a bad request from curl or a hand-written fetch. tRPC pairs with Zod for that, and the example shows the two layers working together: TypeScript catches the mistake in the editor, Zod catches it on the wire.
My verdict, as judgement: use tRPC when the client and server are both TypeScript, the API is internal, and the team is tired of syncing a schema file by hand. Skip it for a public API or a mixed-language system. The recommendation flips the moment a non-TypeScript consumer appears, or the API needs a written contract that outlives your codebase.
One order lookup, run end to end
The example is a small order lookup service for an online shop. Three orders live in memory, each with an order id, a customer name, an item, a total in rupees, and a status. The router exposes two procedures: getOrder, which takes an order id and returns one order, and listOrders, which returns all three. The input is validated with Zod, so an order id must be a non-empty string. The server is plain Express with tRPC's Express adapter, and the client imports only the router type, never the server code.
import express from "express";
import { initTRPC } from "@trpc/server";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { z } from "zod";
const t = initTRPC.create();
const orders = [
{ orderId: "ORD-1041", customer: "Mehwish Raza", item: "Mechanical keyboard", total: 18500, status: "shipped" },
{ orderId: "ORD-1042", customer: "Danish Ali", item: "USB-C hub", total: 6400, status: "processing" },
{ orderId: "ORD-1043", customer: "Hira Naveed", item: "Laptop stand", total: 9200, status: "delivered" },
];
const appRouter = t.router({
getOrder: t.procedure
.input(z.object({ orderId: z.string().min(1) }))
.query(({ input }) => {
const order = orders.find((o) => o.orderId === input.orderId);
if (!order) {
throw new Error(`No order found with id ${input.orderId}`);
}
return order;
}),
listOrders: t.procedure.query(() => orders),
});
export type AppRouter = typeof appRouter;
const app = express();
app.use("/trpc", createExpressMiddleware({ router: appRouter }));
app.listen(4000, () => console.log("tRPC server listening on http://localhost:4000/trpc"));
The client is short, because the type does most of the work. Importing AppRouter as a type gives the client full autocomplete for getOrder and listOrders, and the editor knows the shape of the returned order without any generated code.
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "./server";
const client = createTRPCProxyClient<AppRouter>({
links: [httpBatchLink({ url: "http://localhost:4000/trpc" })],
});
async function main() {
const order = await client.getOrder.query({ orderId: "ORD-1042" });
console.log("Order ORD-1042:", order);
const all = await client.listOrders.query();
console.log("Total orders:", all.length);
}
main();
Here is the real output from the run:
Order ORD-1042: {
orderId: 'ORD-1042',
customer: 'Danish Ali',
item: 'USB-C hub',
total: 6400,
status: 'processing'
}
Total orders: 3
The thing that went wrong came from skipping the typed client. A raw HTTP call passed the order id as a number, 42, instead of a string. The server never ran the procedure. Zod rejected the input first, and the response came back with code BAD_REQUEST and HTTP status 400, carrying this message: "Invalid input: expected string, received number". The fix is simple, pass a string, but the more useful point is that the typed client would never have let that call be written. A second client file with the same mistake, orderId: 42, failed type-checking before it ran at all, with this error: client-typo.ts(10,47): error TS2322: Type 'number' is not assignable to type 'string'. That is the two-layer safety net in action. TypeScript guards the code you write, Zod guards the requests that arrive anyway. One more detail from the run is worth knowing: asking for an unknown id, ORD-9999, returned the plain message "No order found with id ORD-9999", but with code INTERNAL_SERVER_ERROR and HTTP 500, because the procedure throws a plain Error. A missing order is not a server crash, and the fix is to throw a TRPCError with code NOT_FOUND instead.
Common mistakes and how to fix them
First, passing the wrong argument type from the client. The symptom is error TS2322, "Type 'number' is not assignable to type 'string'", when you run tsc, and the code never compiles. The fix is to match the Zod input schema, which is the single source of truth for what the procedure accepts.
Second, calling the API with a hand-written fetch or curl and skipping validation. The symptom is a BAD_REQUEST response with a Zod message like "Invalid input: expected string, received number". The fix is to use the typed client where possible, and to treat the Zod schema as the contract, not as decoration.
Third, throwing a plain Error for a missing record. The symptom is a 500 INTERNAL_SERVER_ERROR for something that is really a 404, which confuses both monitoring and the frontend. The fix is to import TRPCError from @trpc/server and throw new TRPCError({ code: "NOT_FOUND", message: ... }) so the client gets the right status.
My call on tRPC
tRPC earns its place when both ends speak TypeScript and the API belongs to your team. The order lookup needed one router, one Zod schema, and a typed client, and a wrong argument type was caught twice, once by the compiler and once by the validator. That is a genuine saving over a hand-kept schema file. It stops being the right tool when the API has to serve strangers or other languages, because the shared type is invisible to the people who need the contract most. If your setup looks like this shop, try it on one router and see whether the editor feedback feels like help or noise. If it feels like help, keep going.
Further reading
- tRPC Quickstart, https://trpc.io/docs/quickstart
- tRPC Input and Output Validators, https://trpc.io/docs/server/validators
- TypeScript Handbook, The Basics, https://www.typescriptlang.org/docs/handbook/2/basic-types.html

Comments
Post a Comment