How do tRPC, REST and GraphQL achieve type safety?
All three styles can give a TypeScript consumer accurate types. They differ in where the source of truth lives and how types reach the client.
tRPC has no separate schema. The server router is the contract. The client imports the router's type (a type-only import, so no server code ships) and the compiler infers every procedure's input and output. Rename a field on the server and the client fails to compile immediately, with no codegen step to forget.
REST has no built-in type system. Types come from an OpenAPI document, either generated from server code or written by hand, and then from client types generated from that document or written manually. Each hand-maintained link is a place where drift can occur.
GraphQL has a typed schema at its core. Codegen tools read the schema and your query documents to produce types that match exactly the fields each query selects.
| Style | Source of truth | How client gets types | Typical drift risk |
|---|---|---|---|
| tRPC | Server router code | Type inference, no codegen | Low within a monorepo |
| REST | OpenAPI document | Codegen or hand-written | Spec falls out of sync with handlers |
| GraphQL | Schema | Codegen per query | Stale generated files |
Compile-time checks only catch mismatches between code that the compiler can see. TypeScript types are erased at runtime. Malformed JSON, an outdated client binary or a hand-crafted curl request will pass straight through. You still need runtime validation at the trust boundary:
- a schema validation library on tRPC procedure inputs
- request validation middleware in REST
- input checks in GraphQL resolvers beyond the schema's scalar types
// server.ts: the router is the contract (tRPC v11 + zod)
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
// Runtime check at the trust boundary. TypeScript types are erased,
// so a curl request or an outdated client could send anything.
.input(z.object({ id: z.string().uuid() }))
.query(({ input }) => {
// input is typed as { id: string } and has been validated at runtime
return { id: input.id, displayName: 'Ada Example' };
}),
});
// Export only the type. The client never imports server code.
export type AppRouter = typeof appRouter;
// client.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';
const client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '' })],
});
async function main() {
const user = await client.getUser.query({ id: '3f1c2a9e-8b4d-4c1e-9a2f-6d7e5b0c1a23' });
console.log(user.displayName); // inferred from the router, no codegen
// Compile-time error: 'name' does not exist on the inferred output type.
// console.log(user.name);
// Compile-time error: id must be a string.
// await client.getUser.query({ id: 42 });
}
main();
A tRPC client infers procedure types from a type-only import of the server router, while a zod schema on the procedure input still rejects malformed requests at runtime that the compiler never sees.
How does each API style map onto HTTP, caching and observability?
REST maps operations directly onto HTTP. Resources get their own URLs, verbs carry intent (GET is safe, PUT and DELETE are idempotent), and status codes report the outcome. Because of this, a reverse proxy or CDN can cache GET /orders/42 by URL using Cache-Control and ETag headers without knowing anything about your application. An access log line like GET /orders/42 404 is readable on its own.
tRPC uses HTTP as a transport for procedure calls. Queries usually go out as GET with the input encoded in the query string. Mutations go out as POST. The path is the procedure name, such as /trpc/order.byId. tRPC maps its error codes to HTTP statuses, so dashboards built on 4xx and 5xx rates mostly keep working. Request batching changes this, because one HTTP request can carry several procedures. The URL then stops identifying a single operation, and one status code may summarise mixed results.
GraphQL usually sends everything as POST /graphql. Many servers return 200 even when resolvers fail and report the failures in an errors array in the response body. To every tool that looks only at HTTP, a broken query looks like a success.
| Concern | REST | tRPC | GraphQL |
|---|---|---|---|
| URL identifies | Resource | Procedure (unless batched) | Nothing, single endpoint |
| Shared HTTP caching | Native | Possible for unbatched GET queries |
Needs GET with persisted queries |
| Status codes | Meaningful | Mapped from error codes | Often 200 with body errors |
| Monitoring adaptation | Minimal | Log procedure names, account for batching | Log operation names, parse errors |
For tRPC and GraphQL, add the operation name to logs and traces, alert on application-level errors, and decide on caching explicitly. Don't assume your proxy will handle it.
Why is tRPC type sharing a form of coupling?
A tRPC client doesn't consume a schema file or a generated SDK. It imports the server's router type, usually as import type { AppRouter }, and the TypeScript compiler infers every procedure's input and output from it. Nothing crosses the wire at build time; the contract is the server's source code. Because of this, tRPC works best when client and server live in the same monorepo or depend on a shared package, and are type-checked, built and deployed together.
What you gain
Iteration is fast because there is no schema to edit and no codegen step to rerun. If you rename a field or tighten an input validator, every call site that no longer fits fails tsc in the same pull request. Breaking changes become compile errors, not runtime 500s found in staging.
What you pay
- TypeScript only. A Go service, a Python job or a mobile client written in Swift has nothing to import. tRPC gives them a working HTTP endpoint but no contract they can use.
- Type-checking cost. Deep inference across many merged routers makes the compiler do more work. In large codebases this can show up as slower autocomplete, editor lag and longer CI type-check times.
- Coordination cost. One server refactor can break ten clients at once. Someone has to fix all of them before the change merges, or the work has to be split into staged, backward-compatible steps.
If polyglot or public consumers arrive later, plan on adding a separate contract layer, such as an OpenAPI-described REST surface or a GraphQL schema, alongside tRPC. Don't try to stretch tRPC to cover those consumers.
// packages/api/src/router.ts (server)
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string() }))
// Renamed from `name` to `displayName` in this PR
.query(({ input }) => ({ id: input.id, displayName: 'Ada' })),
});
// Only the type is exported; the client never bundles server code
export type AppRouter = typeof appRouter;
// apps/web/src/client.ts (client in the same monorepo)
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@acme/api/router';
const client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: '' })],
});
export async function greeting(id: string) {
const user = await client.getUser.query({ id });
// This call site still uses the old field name, so `tsc` fails
// in the same pull request instead of returning undefined at runtime:
// error TS2339: Property 'name' does not exist on type '{ id: string; displayName: string; }'.
return `Hello, ${user.name}`;
}
A tRPC client imports the server's router type, so renaming a field on the server becomes a compile error at every outdated call site in the monorepo.
How do REST and GraphQL handle versioning and data fetching shape?
Evolving the contract
REST consumers deploy on their own schedules, so a breaking change usually ships as a new version, in the path or a header. The old version keeps running until a deprecation window closes and access logs show no remaining callers. The quieter risk is drift: a hand-written OpenAPI spec says one thing while the handler returns another. You can prevent this by generating the spec from code, using route schemas or validators as the source. You can also run contract tests in CI that check real responses against the spec, so drift fails a build instead of a consumer.
GraphQL usually avoids versions entirely. You add fields and types freely, mark old fields with @deprecated, and remove them once field-level usage data shows they are unused. This works because clients receive only the fields they select, so adding a field never changes an existing response.
Fetching shape
A REST resource returns a fixed representation. List views over-fetch, and detail screens under-fetch and chain several requests. Teams respond by adding endpoints or bespoke aggregates such as a dashboard summary route, and each one becomes another contract to maintain. GraphQL lets the client choose fields. tRPC procedures return fixed outputs like REST, but adding a procedure is cheap when client and server share a repo.
GraphQL's cost is N+1. Resolving orders { customer } calls the customer resolver once per order, and each call issues its own query. DataLoader collects the keys requested within one execution tick, runs a single batched lookup, and caches results per request.
import DataLoader from 'dataloader';
// Placeholder data access: one SQL round trip for many ids.
declare const db: {
query<T>(sql: string, params: unknown[]): Promise<T[]>;
};
type Customer = { id: string; name: string };
type Order = { id: string; customerId: string };
// Build loaders per request so the cache never leaks across users.
export function createLoaders() {
return {
customer: new DataLoader<string, Customer | null>(async (ids) => {
// One batched query instead of one query per order.
const rows = await db.query<Customer>(
'SELECT id, name FROM customers WHERE id = ANY($1)',
[ids],
);
// DataLoader requires results in the same order as the keys.
const byId = new Map(rows.map((c) => [c.id, c]));
return ids.map((id) => byId.get(id) ?? null);
}),
};
}
type Ctx = { loaders: ReturnType<typeof createLoaders> };
export const resolvers = {
Order: {
// Called once per order, but load() calls in the same tick
// are collected and sent to the batch function together.
customer: (order: Order, _args: unknown, ctx: Ctx) =>
ctx.loaders.customer.load(order.customerId),
},
};
A per-request DataLoader turns the per-order customer resolver from N separate queries into a single batched lookup, which is the fix for the N+1 problem in orders { customer }.
What operational overhead does each style add to internal APIs?
Authentication usually sits in the same place for all three styles: a gateway or HTTP middleware validates the token before any handler runs. Authorization is where they differ.
In REST, authorization rules attach to routes, so DELETE /orders/:id gets its own check. In tRPC, you build a protected procedure from reusable middleware, which keeps checks close to the code they guard. In GraphQL, one endpoint serves every operation, so checks move into resolvers. When individual fields are sensitive, the checks move down to field level. That gives fine-grained control, but you must also prove that no query path reaches a protected field without passing its check.
Rate limiting follows the same pattern. REST and tRPC map one operation to one URL, so you can throttle per route at the proxy. GraphQL sends every request to the same endpoint, and one request can ask for almost any amount of work. You need depth limits, cost analysis or persisted queries before per-client limits mean anything.
| Concern | REST | tRPC | GraphQL |
|---|---|---|---|
| Authorization | Route middleware | Per-procedure middleware | Per-resolver or per-field |
| Rate limiting | Per route at proxy | Per procedure path | Per operation cost |
| Extra tooling | OpenAPI (optional) | None beyond TypeScript | Schema registry, codegen, cost limits |
| Learning curve | Low | Low for TS teams | Moderate to high |
GraphQL's tooling also needs a team that owns the schema, reviews breaking changes and maintains the limits. When only three or four internal services call the API, that ongoing work rarely pays for itself.
Which API style should you choose, and when should you avoid each?
Work through the questions in order, because each answer narrows the field faster than the one after it.
| Question | If yes | If no |
|---|---|---|
| Are all consumers TypeScript? | tRPC stays a candidate | Use REST with OpenAPI or GraphQL |
| Do they share a repo and deploy cadence? | tRPC fits well | Type coupling becomes a release dependency, so prefer REST |
| Are external or polyglot consumers present or on the roadmap? | Use REST with OpenAPI | tRPC remains viable |
| Do several clients need different views of the same data? | Consider GraphQL | Use REST or tRPC |
| Can the team own a schema, its limits and its review process? | GraphQL is sustainable | Avoid GraphQL |
tRPC saves time when one team ships the client and server together. Renaming a field breaks the build instead of production, and there is no spec to maintain. Avoid it when consumers deploy on independent schedules or when any of them is not TypeScript.
REST with OpenAPI saves time at the boundaries. Any language can generate a client, and HTTP caching and gateways work without extra configuration. Avoid it when the spec would only duplicate types that a monorepo already shares.
GraphQL saves time when frontends would otherwise each need their own aggregation endpoints. Avoid it when one client and one backend are the whole system.
On switching cost, the two risks are not equal. Building OpenAPI or GraphQL for consumers that never arrive costs ongoing maintenance. Wrapping tRPC procedures in REST later, after a consumer actually appears, usually costs less. The exception is a polyglot consumer that is already scheduled. In that case, start with REST.
Can you mix tRPC, REST and GraphQL in one system?
Mixing styles is common, and it usually follows ownership and deploy boundaries rather than technical preference.
Where the boundaries usually sit
- Frontend to backend-for-frontend (BFF): tRPC fits here because one team owns both sides and ships them together, so the type coupling costs little.
- Service to service: REST with a schema-first contract such as OpenAPI fits here, because the two sides deploy independently and may not share a language or repository.
- Many clients with different data needs: GraphQL fits as an aggregation layer, with resolvers calling the underlying services.
Validation and auth at each boundary
Compile-time types do not cross the network, so validate inputs at runtime wherever trust or deploy cadence changes. tRPC input validators cover the frontend-to-BFF hop. When the BFF calls a REST service, it should validate the responses too, because that service can change without the BFF being rebuilt.
User authentication typically ends at the BFF or the GraphQL gateway. Downstream calls then carry a service identity plus propagated user context. GraphQL resolvers should still enforce authorization on each object they return, because a gateway-level check cannot know which nested fields a query will reach.
Migrating incrementally
Keep business logic in a service layer that does not depend on the transport, so tRPC procedures, REST handlers and resolvers stay thin adapters over the same functions. You can then add a tRPC router next to existing REST routes on the same server. Expose a REST endpoint only when a second consumer appears. Introduce GraphQL by pointing its resolvers at existing services rather than rewriting them.
+--------------+ +------------+ +------------+
| Web frontend | | Mobile app | | Partner UI |
+--------------+ +------------+ +------------+
| | |
| tRPC + input validators | | GraphQL queries
| user auth ends at BFF | | user auth ends at
v v v gateway
+-------------------------+ +----------------------------+
| BFF | | GraphQL gateway |
| tRPC router REST routes | | resolvers enforce per- |
| | | | | object authorization |
| v v | +----------------------------+
| +---------------------+ | |
| | service layer | | |
| | (no transport deps) | | |
| +---------------------+ | |
+-------------------------+ |
| |
| REST + OpenAPI contract | resolvers call
| service identity + | existing services
| propagated user context |
| BFF validates responses |
v v
+----------------------------------------------------------------+
| Downstream services (deploy independently, any language) |
| Orders API Billing API Inventory API |
+----------------------------------------------------------------+
Illustrative layout of a mixed internal API system: tRPC connects a frontend to its BFF, a GraphQL gateway serves other clients, both call independently deployed services over REST with OpenAPI contracts, and user authentication ends at the BFF and the gateway.
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.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
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.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- 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.