APIs without schemas and without code generation
Static types usually end at the network boundary: the client does not know what the server returns unless it relies on manually maintained interfaces or generated client code. tRPC closes this gap by importing router types directly from the server, with no REST conventions, no GraphQL schema and no separate code generation step at all.
Table of contents
- 1. The core problem: type safety ends at the network boundary
- 2. Defining routers and procedures
- 3. Input validation with Zod in procedures
- 4. The client: importing types without code generation
- 5. Combining queries and mutations with React Query
- 6. Middleware and context in tRPC
- 7. Error handling with TRPCError
- 8. The limits of tRPC: when REST or GraphQL fit better
- 9. tRPC compared to REST and GraphQL
- 10. Summary
- 11. FAQ
1. The core problem: type safety ends at the network boundary
A typical fullstack TypeScript project has fully typed functions on the server and fully typed components on the frontend, but between them lies a network boundary where type safety usually ends. A REST endpoint returns JSON whose structure the client only knows through a manually maintained interface that has to be updated by hand every time the server changes. This is exactly the problem tRPC solves: it enables end to end type safety by importing the type of the server router directly into the client.
The key difference from REST and GraphQL: tRPC needs no separate schema and no code generation step. The router itself is the source of types, TypeScript reads the type information directly from the exported router type without any build step in between. However, this only works in a monorepo or project setup where client and server can access the same TypeScript type, for example through a shared package or workspace.
For teams already working entirely in TypeScript and not needing to serve external consumers such as mobile apps in other languages, tRPC offers the most direct form of end to end type safety in the current ecosystem. This article shows how routers, validation and client integration work together, and where the limits of this approach lie.
2. Defining routers and procedures
A tRPC router consists of a collection of procedures, each representing either a query for read operations or a mutation for write operations. Each procedure is defined via a procedure builder that optionally chains input validation, middleware and finally a resolver. The resolver's return type is automatically inferred by TypeScript and never needs to be repeated manually anywhere.
Routers can be nested, allowing large applications to split their procedures into logical groups, for example a userRouter and a postRouter, which are then merged into an appRouter. It is exactly this appRouter type that later gets imported into the client and describes the entire API surface type safely there.
// server/trpc.ts — base setup
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/post.ts
import { z } from "zod";
import { publicProcedure, router } from "../trpc";
export const postRouter = router({
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
// input.id is typed as string, inferred from the Zod schema
return { id: input.id, title: "Example Post" };
}),
create: publicProcedure
.input(z.object({ title: z.string().min(3) }))
.mutation(async ({ input }) => {
return { id: "p_1", title: input.title };
}),
});
// server/routers/_app.ts
import { router } from "../trpc";
import { postRouter } from "./post";
export const appRouter = router({
post: postRouter,
});
// This type is the only thing the client needs to import
export type AppRouter = typeof appRouter;
3. Input validation with Zod in procedures
The call .input(schema) attaches a Zod schema to a procedure that validates incoming data at runtime before the resolver runs. The type derived from the schema is automatically available under input inside the resolver, no additional type annotation needed. For end to end type safety this means client and server have exactly the same understanding of which fields a request must contain, because both access the same router type.
Zod is the most common choice for tRPC, but not the only one. Any library that follows the Standard Schema interface, such as Valibot or ArkType, can be integrated just as well. The only requirement is that the library provides both runtime validation and a way to statically infer a type, so input type and validation logic can never drift apart.
4. The client: importing types without code generation
The tRPC client is created with createTRPCProxyClient or, in a React context, with createTRPCReact, and receives as its generic type parameter only the AppRouter type from the server, not the actual server code. This separation is crucial: only the type is imported, at runtime no direct call to server functions happens, communication still goes over HTTP.
The practical effect for TypeScript developers: client.post.byId.query({ id: "1" }) is fully type checked, including autocomplete for available router paths and the expected input shape. If the router changes on the server, for example by renaming a procedure, the compiler on the client side immediately reports an error at every place that still uses the old name.
// client/trpc.ts — imports only the type, not server code
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/routers/_app";
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: "https://api.example.com/trpc",
}),
],
});
// Fully typed call, autocomplete works for router path and input shape
const post = await trpc.post.byId.query({ id: "1" });
console.log(post.title); // typed as string, no manual interface needed
5. Combining queries and mutations with React Query
For React applications, tRPC offers an official integration with TanStack Query that automatically generates a typed useQuery hook from every query procedure and a typed useMutation hook from every mutation procedure. Caching, refetching and optimistic updates work exactly as with manually configured React Query, except that query key, input and return type are automatically derived from the router.
This approach significantly reduces the usual boilerplate: instead of maintaining a separate query key and response interface for every endpoint, calling a generated hook like trpc.post.byId.useQuery({ id }) is enough, bringing caching behavior, loading state and type safety along automatically. For TypeScript teams already using React Query, this is usually the most compelling entry point into tRPC.
6. Middleware and context in tRPC
The tRPC context is created once per request and passed to all procedures, for example with a database connection or information about the authenticated user. Middleware can extend this context before it reaches the resolver, narrowing or widening the context type for subsequent procedures in the process. A typical middleware checks authentication and extends the context with a guaranteed present user field.
This pattern allows defining protected procedures via a dedicated procedure builder that already includes the auth middleware. A resolver built on this protected builder can fully rely on ctx.user existing without performing its own null check, because TypeScript already knows the narrowed context type.
// server/trpc.ts — auth middleware narrows the context type
import { initTRPC, TRPCError } from "@trpc/server";
interface Context {
user: { id: string; role: string } | null;
}
const t = initTRPC.context<Context>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
// ctx.user is now guaranteed non-null for downstream procedures
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
// server/routers/profile.ts
export const profileRouter = router({
me: protectedProcedure.query(({ ctx }) => {
// No null check needed — the type system already knows ctx.user exists
return { id: ctx.user.id, role: ctx.user.role };
}),
});
7. Error handling with TRPCError
tRPC defines a fixed set of error codes such as UNAUTHORIZED, NOT_FOUND or BAD_REQUEST, thrown via the TRPCError class. Each code is automatically mapped to a matching HTTP status code, so developers never have to maintain this mapping themselves. On the client side, these errors can be distinguished type safely because TRPCClientError carries the code as part of its type.
A particular advantage for TypeScript projects: validation errors from the Zod schema are automatically converted into a structured TRPCError with code BAD_REQUEST and the original Zod issues in the cause field. Input validation therefore never needs to be manually translated into its own error path, the framework handles this mapping automatically for every procedure.
8. The limits of tRPC: when REST or GraphQL fit better
tRPC assumes that client and server are both written in TypeScript and can import the same router type. As soon as an external consumer is involved, for example a mobile app in Kotlin or Swift, a partner company with its own backend, or a public API with unknown consumers, the type import naturally stops working. In such cases, REST with OpenAPI or GraphQL with its language agnostic schema remains the better choice.
Even with very large, decentrally developed systems with many independent teams, the tight coupling between client and server type can become a problem, because every change to the router theoretically affects all consumers of the type. GraphQL deliberately decouples client and server through an explicit schema, which offers more stability in such organizational forms than tRPC's direct type coupling.
| Criterion | tRPC | REST | GraphQL |
|---|---|---|---|
| Type safety without codegen | Yes, direct type import | No, manual or via OpenAPI codegen | No, via GraphQL codegen |
| Language agnostic consumers | Not supported | Yes, universal | Yes, via schema |
| Setup effort | Low, no schema needed | Low to medium | High, schema and resolvers |
| Public API documentation | No built in schema | Possible via OpenAPI | Built in, introspectable schema |
9. tRPC compared to REST and GraphQL
The comparison clearly shows: tRPC consistently optimizes for developer speed within a TypeScript monorepo, while REST and GraphQL optimize for interoperability across language and organizational boundaries. For an internal dashboard consumed by a single frontend team, tRPC is often the fastest solution with the least boilerplate. For a public API with third party integrations, tRPC is the wrong choice, however.
Some teams deliberately combine both approaches: internal admin tools and dashboards run on tRPC for maximum developer speed, while the public, externally consumed API is provided via REST or GraphQL. This combination leverages the strengths of both approaches without accepting the weaknesses of either.
Mironsoft
TypeScript fullstack, API architecture and end to end type safety
Frontend and backend without consistent type safety?
We build tRPC routers with Zod validation, React Query integration and protected procedures for TypeScript monorepos where client and server share the same type.
Architecture assessment
Evaluating whether tRPC or REST/GraphQL fits your project
Router design
Clean procedure structure with Zod validation and protected routes
Frontend integration
React Query hooks, caching strategy and optimistic updates
10. Summary
tRPC solves a specific but common problem: end to end type safety in TypeScript monorepos, without a schema or a separate code generation step in between. Routers and procedures define the entire API surface, Zod validates input at runtime while simultaneously deriving the static type, and the client only imports the router type, not the server code itself. Middleware extends the context type safely, TRPCError delivers consistent error codes across all procedures.
tRPC's limits are as clear as its strengths: as soon as external, non-TypeScript consumers enter the picture, REST or GraphQL is the better choice. For internal tools, admin dashboards and applications with a single frontend team, however, tRPC remains one of the most efficient ways to actually carry type safety from the server all the way into the UI, without sacrificing developer speed.
tRPC for End to End Type Safety: The essentials at a glance
Router as the type source
The client only imports the AppRouter type, no separate schema file needed.
Zod validation
One schema per procedure delivers runtime validation and the input type at once.
React Query integration
Generated hooks bring caching and type safety without manual query keys.
Limits
Only for TypeScript to TypeScript communication, unsuitable for language agnostic APIs.