Where the contract lives in tRPC, REST and GraphQL
Every API has a contract: the agreed shape of requests and responses. The three styles differ mainly in where that contract is written down, who can read it, and whether anything enforces it at runtime.
tRPC has no separate schema file. The contract is the TypeScript type of your router, inferred from procedure definitions and their input validators. The client imports that type, not the server code, and the compiler checks every call. This works because server and client share one TypeScript type system, usually in a monorepo. Outside that boundary, there is no artifact a Swift or Go client can consume.
REST has no built-in contract. Teams typically describe it with an OpenAPI document in YAML or JSON. You can write the document first and generate code from it, or generate the document from annotated server code. Either way, the document and the implementation can drift unless you validate requests against the spec or test for drift.
GraphQL defines the contract in SDL (Schema Definition Language). The server parses every query against the schema and rejects invalid ones before any resolver runs, so the schema is enforced by default. Introspection lets tools in any language read it.
| Style | Contract source | Format | Language-neutral |
|---|---|---|---|
| tRPC | Router and procedure definitions | Inferred TypeScript types | No, TypeScript only |
| REST | OpenAPI document (hand-written or generated) | YAML or JSON | Yes |
| GraphQL | Schema | SDL, enforced by the server | Yes |
The rest of this comparison follows from one question: who are your clients, and can they read the place where your contract lives?
How end-to-end type safety works in each style
For tRPC, the server's code is the contract. The server exports the type of its router (export type AppRouter = typeof appRouter), and the client imports it with import type. That import is erased at compile time, so no server code is bundled into the client. The TypeScript compiler, however, must be able to resolve the server's source files. In practice, server and client live in one monorepo or share a workspace package. Procedure input types are inferred from the validator attached to each procedure, and output types are inferred from the resolver's return value. Rename a field on the server and every client call site fails type-checking right away in the editor. No intermediate artifact is involved.
REST and GraphQL put a language-neutral artifact between the two sides: an OpenAPI document or a GraphQL SDL schema such as type User { id: ID! email: String! }. A code generator reads that artifact and emits TypeScript types for the client. This lets any language consume the API, but it adds a build step, and generated types are only as fresh as the last run. Drift can come from two places:
- Stale generated code. Someone changes the schema and nobody regenerates the client types.
- Mismatched implementation (REST). The handlers return something the OpenAPI document never described.
GraphQL narrows the second gap because the server validates responses against the SDL at runtime. Stale client types remain a risk unless codegen runs in CI.
tRPC (shared type)
server router ──typeof──▶ AppRouter ──import type──▶ client
(same compiler, no build step)
REST / GraphQL (schema + codegen)
server ──▶ OpenAPI / SDL ──codegen──▶ generated .ts ──▶ client
▲ │
└──── drift if not rerun ──┘
// --- packages/api/src/router.ts (server, shared workspace package) ---
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
userById: t.procedure
// Input type is inferred from this validator
.input(z.object({ id: z.string() }))
// Output type is inferred from the resolver's return value
.query(({ input }) => ({ id: input.id, email: 'ada@example.com' })),
});
// Export only the type. This is the client's entire contract.
export type AppRouter = typeof appRouter;
// --- apps/web/src/client.ts (client, same monorepo) ---
import { createTRPCClient, httpBatchLink } from '@trpc/client';
// `import type` is erased at compile time, so no server code is bundled.
// tsc must still be able to resolve the server package's source.
import type { AppRouter } from '@acme/api/router';
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '' })],
});
const user = await trpc.userById.query({ id: 'u_123' });
user.email; // string. Rename it on the server and this line fails type-checking.
// --- GraphQL equivalent: the SDL file is the contract ---
// schema.graphql
// type User { id: ID! email: String! }
// type Query { userById(id: ID!): User }
// A codegen step reads this file and emits client .ts types.
// Those types are only as fresh as the last codegen run.
A minimal tRPC router whose type is consumed by the client through a compile-time-only import type, shown next to the GraphQL SDL that a codegen step would turn into client types.
Runtime validation: why static types are not enough
Everything in that diagram happens at compile time. TypeScript erases types when it emits JavaScript, so at runtime input: { userId: string } is only a comment. A request body is whatever bytes the caller sent. It might come from your typed client, an outdated mobile build, curl, or an attacker. The compiler never sees any of these.
Each style handles this boundary differently:
- tRPC adds a schema validator to each procedure through
.input(). The schema does two jobs. tRPC infers the static input type from it, and it parses the payload before your resolver runs. A procedure without.input()gets no validation. The accompanying example passes a schema requiring a UUIDuserIdand a boundedlimitto.input(). A malformed call gets aBAD_REQUESTerror and never reaches your code. - GraphQL checks every operation against the schema before execution. Unknown fields, missing non-null arguments and mismatched scalar types are rejected. Business rules still need explicit checks in resolvers or custom scalars, for example "positive integer" or "valid email."
- REST validates nothing by default. You need validation middleware, a schema parse inside each handler, or a request validator generated from your OpenAPI document. The accompanying handler parses
req.bodyagainst a schema and returns400with field-level errors before reaching the database.
The risk is that inferred types make the code look checked when it isn't. Suppose a handler casts req.body as CreateOrder. It will type-check, ship, and then accept { "quantity": "-5" } without complaint. The resulting bad data surfaces later, far from the boundary where it got in.
import { initTRPC } from '@trpc/server';
import express from 'express';
import { z } from 'zod';
const t = initTRPC.create();
// tRPC: one schema provides the inferred input type AND the runtime parse.
export const appRouter = t.router({
listOrders: t.procedure
.input(z.object({
userId: z.string().uuid(),
limit: z.number().int().min(1).max(100).default(20),
}))
// Runs only if parsing succeeded; bad input is rejected with BAD_REQUEST.
.query(({ input }) => ({ userId: input.userId, limit: input.limit, orders: [] })),
});
// REST: nothing is validated unless the handler does it explicitly.
const CreateOrder = z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(), // rejects "-5", -5 and 2.5
});
type CreateOrder = z.infer<typeof CreateOrder>;
declare function saveOrder(order: CreateOrder): Promise<void>; // your persistence layer
const app = express();
app.use(express.json());
app.post('/orders', async (req, res) => {
// Do NOT write `req.body as CreateOrder`: it compiles but checks nothing.
const result = CreateOrder.safeParse(req.body);
if (!result.success) {
res.status(400).json({ errors: result.error.flatten().fieldErrors });
return; // stop before touching the database
}
await saveOrder(result.data); // result.data is now parsed and typed
res.status(201).json(result.data);
});
A tRPC procedure validates its input with a Zod schema passed to .input(), while an Express handler must parse req.body itself and return 400 with field-level errors instead of casting the body to a type.
Client reach, HTTP caching and data-fetching shape
Type safety only pays off for clients that can consume it, and that is where the three styles diverge most.
tRPC's value depends on a TypeScript client importing the server's router type. A Swift app, a Python partner integration or a public developer sees plain HTTP calls with procedure-named paths, JSON-encoded input in a query string, and responses wrapped in a tRPC-specific envelope. There is no standard schema document for them to generate a client from. REST can publish an OpenAPI description, and GraphQL can expose an introspectable schema. Both give non-TypeScript consumers a contract they can use.
HTTP caching follows the same pattern. REST maps reads to GET on stable URLs, so browsers and CDNs can cache responses using Cache-Control and ETag without any extra work. GraphQL usually sends POST requests to a single endpoint, and shared caches do not cache those. You need persisted queries sent over GET, or a normalized client-side cache. tRPC sends queries as GET and mutations as POST, so caching is possible. Batching combines several procedures into one URL, which fragments cache keys.
Response shape matters too. GraphQL lets clients select fields, which reduces over-fetching. Naive resolvers, however, issue one database query per parent row (the N+1 problem). The dataloader pattern addresses this by batching and deduplicating lookups within a single request. REST and tRPC return fixed shapes.
Operational tooling also favours REST. Gateways, rate limiters and access logs work best when the method and path identify the operation. A single /graphql endpoint, or a tRPC batch call, hides the operation inside the request body or a combined URL.
| Concern | REST | GraphQL | tRPC |
|---|---|---|---|
| Non-TS clients | OpenAPI codegen | Schema introspection/codegen | No standard schema |
| CDN/browser caching | Native via GET | Needs persisted queries | Possible, complicated by batching |
| Response shape | Fixed per endpoint | Client-selected fields | Fixed per procedure |
| Gateway/log visibility | Method + path | Single endpoint | Procedure path, batched |
API evolution and versioning
REST APIs usually evolve through additive changes. New fields and endpoints are safe because well-behaved clients ignore what they don't recognize. Breaking changes go behind a new version, either in the URL path (/v2/orders) or in a header. The version marker is explicit, so a gateway can route old and new traffic separately, and you can run both versions until the logs show the old one is idle. The cost is maintenance: every live version is code you have to keep correct.
GraphQL avoids versioning by design. You add fields freely and mark old ones with the @deprecated directive, which shows up in introspection and in client tooling. Because clients name every field they read, you can record per-field usage at the server and know exactly when a deprecated field has no remaining callers. That only works if you instrument resolvers or operations to collect it. Removal becomes a decision based on data, not a guess.
In tRPC, refactoring is cheap. Rename a procedure or change an input type, and the compiler flags every broken call site in the monorepo. That guarantee only covers clients built from the same commit, though. A mobile app installed months ago, or a third-party integration, keeps sending the old shape. The compiler never sees those callers, and you only find out at runtime, through validation errors or missing fields.
This is the cost of build-time coupling. The type contract is shared source code rather than a published, versioned artifact, so server and client effectively have to deploy together. To deploy them independently, you need to bring back REST-style discipline: changes that are only additive, procedures with versioned names, and a deprecation window.
When to use tRPC, REST or GraphQL, and when to avoid each
The deciding question is who controls the client's release schedule and what language the client is written in. Developer preference matters less.
| tRPC | REST | GraphQL | |
|---|---|---|---|
| Use when | Full-stack TypeScript app whose web client deploys with the server | Public or partner APIs, simple CRUD, cache-heavy reads | Several different clients over a connected domain |
| Avoid when | Public API, non-TypeScript clients, independently released mobile apps | Many clients need very different views of the same data | You cannot staff schema ownership, query-cost limits, per-field authorization and observability |
| Client reach | TypeScript clients only, in practice | Any HTTP client | Any client with a GraphQL library |
| Ecosystem maturity | Younger and TypeScript-centric | Most mature: gateways, CDNs, OpenAPI tooling | Mature servers and clients, with more specialised security tooling |
| Hiring familiarity | Narrower, but quick for TypeScript developers to learn | Nearly universal | Common for API use, rarer for running it in production |
tRPC is cheapest when one team owns both ends of the wire. Once an outside party consumes the API, you lose the benefit of a shared type import, and you still carry the coupling.
REST costs little to run because proxies, CDNs and every language already understand it. That advantage fades when each client needs its own shape of the data, because you end up adding endpoints for each client or composing many calls on the client.
GraphQL handles that variety of client needs well. The cost is a recurring operational load. Someone has to own the schema, limit query depth and cost, enforce authorization on each field and trace slow resolvers. Without a team for that work, those problems turn into incidents.
Hybrid patterns and their impact on cost and engineering time
Each hybrid keeps one style where it pays off and translates at a boundary. That boundary is a second contract someone has to maintain.
- tRPC internally, OpenAPI or REST externally. Clients in the monorepo import router types directly, and a REST layer serves partners and non-TypeScript consumers. If you derive that layer from the same input and output schemas, drift stays small. If you write it by hand, every procedure change needs a matching spec edit. When the two disagree, partners find the bug, because the compiler cannot.
- GraphQL gateway in front of REST services. Backend teams keep simple REST services, and the gateway composes them for UI clients. You now own a schema, resolvers that wrap HTTP calls, request batching to avoid N+1 fan-out, and error mapping between two models.
| Style | Saves engineering time | Adds cost |
|---|---|---|
| tRPC | No codegen step; renames propagate across client and server | Type checking slows as routers grow; only TypeScript clients benefit |
| REST + OpenAPI | Broad tooling in any language; standard HTTP caching | Codegen CI checks; drift between spec and handlers |
| GraphQL | New screens often need no new endpoints | Server complexity, query cost limits, field-level authorization |
Illustrative estimate (assumed numbers, not a benchmark)
Monthly hours = (changes per month × share touching the public API × hours to mirror each change) + fixed CI and review hours.
Assume 20 procedure changes per month, 15% of them public, 2 hours to mirror each, and 4 fixed hours:
20 × 0.15 × 2 + 4 = 10 hours per month.
If you generate the spec from shared schemas, the mirroring term moves toward review time only. Weigh the result against the cost of supporting partners without a published contract.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to trpc vs rest vs graphql, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.