tRPC: end-to-end types with no codegen, and what that costs
tRPC gives end-to-end type safety with no codegen: the client infers its whole API type from `typeof appRouter`, so types flow across the wire at compile time. 'No codegen' means client and server share the type (`import type`); the cost is inference time and editor lag.
You rename a server procedure from getUser to getUserById and add a includeDeleted: boolean argument. You hit save. Before you touch the client at all, three call sites across two frontend files light up red: Property 'getUser' does not exist, and the one place that called it is now missing the new argument. No codegen ran. No OpenAPI file regenerated. No npm run gen. The client just knew — because the client’s API type is not a separate copy, it is an inference of the server’s router type, recomputed on every keystroke. That is tRPC, and the magic is entirely the type system you already learned.
The whole trick: typeof appRouter
On the server you build a router. Its type — not its value — encodes every procedure name, input, and output. You export that type and hand it to the client as a generic:
// server/router.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
getUserById: t.procedure
.input(z.object({ id: z.number(), includeDeleted: z.boolean() }))
.query(({ input }) => findUser(input.id)), // returns User
});
export type AppRouter = typeof appRouter; // ^? the entire router's type// client/trpc.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/router"; // type-only — see below
const trpc = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url: "/trpc" })] });
const user = await trpc.getUserById.query({ id: 42, includeDeleted: false });
// ^? User — inferred end-to-end from the server's return type
// trpc.getUser // Error: Property 'getUser' does not existcreateTRPCClient<AppRouter> takes the server router type and, through a large internal mapped/conditional type, transforms it into the client’s call interface: appRouter’s getUserById.query procedure becomes trpc.getUserById.query(input): Promise<output>. The input type comes from your zod schema’s z.infer; the output type comes from the resolver’s return type. Both flow to the client with zero generated files. (In a real app you rarely call .query directly — you feed it through a client cache like React Query, where these inferred types carry through to your hooks; see the Frontend track on client caches.)
▸Why this works
This is typeof in type space (the operator from unit 04) doing the heavy lifting. typeof appRouter lifts a runtime value (the router object) into a type the client can consume. The client never imports the router’s runtime — only its type — so the server’s database calls, secrets, and dependencies never reach the browser bundle. The contract is the type; the implementation stays home.
”No codegen” — what it actually means and its hard limits
Before you reach for tRPC on your next project, ask: can my client always import type from the server? If the answer is yes, you get live inference for free. If the answer is no, you need a wire contract — and that changes everything.
REST + OpenAPI generates a client.ts file from a spec; GraphQL generates types from a schema. tRPC generates nothing. The trade is that the client must be able to import type the server’s AppRouter — which means:
- Same TypeScript build / monorepo. Client and server compile together (or the server ships a
.d.tsthe client consumes). There is no wire format describing the API; the type is the contract, and a type can only cross a compile boundary, not a network or language one. - TypeScript on both ends. A Swift or Kotlin client cannot consume a TS type. For polyglot or third-party consumers you need a real wire contract — OpenAPI or GraphQL — which is exactly when their codegen pays off.
The cost: router inference time and editor lag
That convenience is paid for in compiler work. The client’s type is a function of the entire server router type, recomputed by tsc and the editor’s language service. On a router with hundreds of procedures, deeply nested sub-routers, and complex zod schemas, you feel it: hover tooltips that spin, autocomplete that lags, tsc runs that crawl. The inference doesn’t get cached the way a generated file would — there is no file. Mitigations:
- Split into sub-routers and avoid one monolithic 500-procedure router; smaller units re-infer faster.
- Annotate procedure return types explicitly so the resolver’s output type doesn’t have to be inferred through your whole data layer each time.
- Keep zod schemas reasonable — a giant
z.discriminatedUnionwith 40 members is expensive to infer everywhere it’s used. - Reach for codegen when inference stops scaling: this is a genuine point where OpenAPI/GraphQL’s “generate once, read a file” model wins on compiler performance, trading live inference for a build step.
Together these mitigations reduce the re-inferred surface area; without them a 500-procedure monolith makes the entire language service crawl on every keystroke.
The one footgun: importing server runtime by accident
Because the server and client live in one repo, it is easy to write import { appRouter } (value) instead of import type { AppRouter } (type). The value import drags the entire server runtime — database drivers, secrets, Node-only modules — into the client bundle, and may even ship credentials to the browser. Always import type for the router on the client. Enable "verbatimModuleSyntax": true (unit 07) so a stray value-import of a type is a compile error, not a silent bundle bloat.
tRPC gives 'end-to-end type safety with no codegen'. What does 'no codegen' require in exchange?
Order how a server procedure's types reach a typed client call site in tRPC:
- 1 Server builds appRouter; each procedure carries a zod input schema and a resolver return type
- 2 Export the router's TYPE: type AppRouter = typeof appRouter
- 3 Client imports it with `import type` and passes it: createTRPCClient<AppRouter>
- 4 A mapped/conditional type turns the router type into the client interface, inferring each input/output
- 5 The call site trpc.x.query(input) is fully typed; renaming a server procedure breaks the client at compile time
- 01Mechanically, how does the client get its API types in tRPC with no codegen?
- 02What does 'no codegen' force in exchange, and when should you prefer OpenAPI/GraphQL codegen instead?
- 03Why does a giant tRPC router slow the editor, and what is the runtime import footgun?
You now understand the engine behind tRPC’s “no codegen” claim: typeof appRouter lifts the router into a type, the client infers its whole API from it at compile time, the price is shared-type coupling plus inference cost, and the footgun is a value import that leaks the server into the browser. You’ve now seen three points on the boundary-typing spectrum — hand-modelled contracts and Result types, zod runtime validation, and tRPC’s compile-time inference. This capstone of consuming typed systems sets up the inverse skill: in the next lesson you’ll design generic library APIs that infer this beautifully for your own callers, and it all comes together in the typed-feature capstone in unit 09. Now when you see import { appRouter } on the client side of a tRPC project, you know to change it to import type { AppRouter } before the server’s secrets reach the browser bundle.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.