Quick Answer

tRPC lets you call server procedures from your client with full type inference and no schema file or code generation step. You define a router of procedures on the server; the client imports only the router's TypeScript type and gets autocomplete plus compile-time checks on inputs and outputs. It works when client and server share one TypeScript codebase, usually a monorepo. Runtime input validation still needs a library like Zod.

The problem it solves

With REST or GraphQL, your API shape is described in one place, an OpenAPI document or a GraphQL schema, and the client's types are generated or hand-written from that description. Over time the two drift apart. Rename a field on the server, forget to regenerate the client types, and the mismatch surfaces as a runtime error in the browser, often only on the code path nobody tested.

tRPC removes the intermediate artifact entirely. There is no schema file and no generation step. The server's TypeScript types are the contract directly, and the client consumes those same types.

The trade-off is the constraint that makes it work: both sides must be TypeScript, and the client must be able to import from the server's code, in practice a monorepo or a shared internal package. If that describes your project, and for a great many full-stack apps it does, tRPC deletes an entire category of glue code and the bugs that live in it.

Defining a router

A router is an object of procedures. This example uses @trpc/server 11.18 and zod 4, and type-checked cleanly:

import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  userById: t.procedure
    .input(z.object({ id: z.number().int().positive() }))
    .query(({ input }) => findUser(input.id)),
  addUser: t.procedure
    .input(z.object({ name: z.string().min(1), role: z.enum(['student', 'admin']) }))
    .mutation(({ input }) => createUser(input)),
});

export type AppRouter = typeof appRouter;

Use query for reads and mutation for writes; the distinction is not cosmetic, since client libraries cache queries and never cache mutations. The .input() schema does two jobs from one declaration: it validates the incoming payload at runtime, and it supplies the static input type. You compose larger APIs by nesting routers, so t.router({ user: userRouter }) gives you caller.user.byId(...) with the namespacing carried through the types. Middleware attaches with t.procedure.use(...), which is how auth and logging are added once rather than per procedure.

Calling procedures

On the server you can build a direct caller, which is also how you test procedures without spinning up HTTP:

const caller = t.createCallerFactory(appRouter)({});

await caller.userById({ id: 1 });
// -> { id: 1, name: 'Asha', role: 'student' }

await caller.addUser({ name: 'Ravi', role: 'admin' });
// -> { id: 2, name: 'Ravi', role: 'admin' }

In a browser app you use @trpc/client, again with AppRouter as the only shared import. The client bundle contains none of the server's implementation, just its type signature, which is erased at build time and adds nothing to the shipped JavaScript. Your editor autocompletes the procedure names and, on hover, shows the exact input and return shapes inferred straight from the server functions. There is no step where you tell the client what the server looks like; it already knows. The React bindings wrap TanStack Query, so each procedure becomes a typed hook with loading and error state handled for you.

What type safety buys you

These failures were confirmed by running the TypeScript compiler against a caller. Passing a field the input schema does not define:

caller.greet({ username: 'Asha' })  // input is { name: string }
// error TS2353: Object literal may only specify known
// properties, and 'username' does not exist in type
// '{ name: string; }'.

Reading a field the procedure never returns:

const r = await caller.greet({ name: 'Asha' });
r.foo
// error TS2339: Property 'foo' does not exist on type
// '{ message: string; length: number; }'.

The clean version, with both mistakes removed, compiled with zero errors. That is the whole value proposition in one sentence: rename a field on the server, and every stale call site across the client turns red in your editor immediately, before the code is committed, let alone deployed. The same check runs in CI, so a type mismatch fails the build rather than reaching a user. You are not maintaining a second description of the API that can fall out of sync, because there is only one.

Validation and errors

TypeScript types are erased at runtime, so real requests still need checking, and this is where new users get caught. The .input() schema runs on every call regardless of what the compiler thinks. A payload that does not match throws a TRPCError. Confirmed:

await caller.addUser({ name: '', role: 'teacher' });
// error code: BAD_REQUEST
// zod issue: Too small: expected string to have
//            >=1 characters

For your own error cases, throw new TRPCError({ code: 'NOT_FOUND', message: '...' }); a caller catches it with code equal to 'NOT_FOUND', also confirmed here. tRPC ships a fixed set of codes, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, and others, and over HTTP transport each maps to the matching HTTP status automatically, so BAD_REQUEST returns 400. An unexpected throw that is not a TRPCError becomes INTERNAL_SERVER_ERROR and HTTP 500.

When not to use it

tRPC depends on the client importing the server's types, and that single fact rules out several situations. A public API consumed by third parties will not work, because outside developers cannot import your TypeScript. Non-TypeScript clients, a mobile app in Swift or Kotlin, are out. So are separate repositories with no shared package between them.

The mental model that keeps you out of trouble: tRPC is a TypeScript convenience, not a documented, versioned network protocol. Nothing about the wire format is designed to be consumed by anyone who is not compiling against your types. If your API is a product, or your consumers live outside your codebase, or your team is genuinely multi-language, use REST with OpenAPI or GraphQL, and accept the codegen step as the price of that reach. For an internal full-stack app in one repository, tRPC is hard to beat.

Frequently Asked Questions

Does tRPC need code generation? No. It infers types directly from the server router at compile time. There is no build or codegen step.
Do I still need Zod with tRPC? For runtime input validation, yes. TypeScript types are erased at runtime, so the .input() schema needs a validation library to actually check incoming payloads.
Can I use tRPC for a public API? Not well. Third-party clients cannot import your TypeScript types. Use REST or GraphQL for public, documented APIs.
What is the difference between query and mutation? query is for reads with no side effects and is cacheable on the client; mutation is for writes. The distinction drives client caching behaviour.
How do errors reach the client? tRPC wraps them as a TRPCError with a code such as BAD_REQUEST or NOT_FOUND, which maps to an HTTP status code when using HTTP transport.