trpctypescriptrestgraphqltype-safetyapi

tRPC vs REST vs GraphQL: End-to-End Type Safety for JSON APIs

·9 min read·APIs

The problem tRPC solves: type drift

In a normal REST JSON or GraphQL app, the server's types and the client's types are maintained separately. You change a response field on the backend, forget to update the frontend type, and TypeScript happily compiles a bug that only shows up at runtime. This client/server type drift is a large share of production bugs in full-stack apps. tRPC eliminates it: the client imports the server's types *directly*, so a change to a backend procedure instantly shows up as a type error everywhere the frontend uses it — no schema file, no code generation.

By 2026 tRPC (~2M weekly downloads) is a default for full-stack TypeScript apps. Here's how it works and, importantly, when it's the wrong choice.

How it works: procedures, not endpoints

Instead of URLs and verbs, you define procedures — plain functions grouped into a router — on the server:

ts
// server: router.ts
export const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => db.user.find(input.id)),   // returns a User

  createUser: publicProcedure
    .input(z.object({ name: z.string() }))
    .mutation(({ input }) => db.user.create(input)),
});

export type AppRouter = typeof appRouter;   // ← the only thing the client needs

The client imports just the type AppRouter and calls procedures as if they were local functions, fully typed:

ts
// client — input and output types are inferred from the server
const user = await trpc.getUser.query({ id: "u_1" });
//    ^? User — autocomplete on user.name, type errors if the server changes

There's no generated SDK and no schema to keep in sync. The types *are* the contract, and they're checked at compile time across the network boundary.

It still ships JSON over HTTP

A common misconception is that tRPC is a new protocol. It isn't — tRPC sends ordinary JSON over HTTP. The type safety is a *compile-time* illusion built from shared TypeScript types; at runtime a query is a normal HTTP request with a JSON body and a JSON response. That means:

  • You can inspect tRPC calls in the network tab like any JSON APIformat the payload and it's just JSON.
  • Input validation is real and runtime — the .input(z.object(...)) uses Zod (or any Standard Schema validator) to actually validate the incoming JSON, so bad data is rejected server-side, not just mistyped client-side.
  • Because it's HTTP + JSON, it works with normal caching, batching, and middleware.

The hard limitation: TypeScript on both ends

tRPC's superpower is also its boundary: it only works when the client and server are both TypeScript and can share types. That rules it out for:

  • Public APIs consumed by third parties who don't have your types.
  • Polyglot stacks — a Python data service, a Go microservice, a mobile app in Swift/Kotlin.
  • Anything where the consumer isn't your codebase.

For those, you need a language-agnostic contract, which is exactly what REST and GraphQL provide.

Choosing between the three

tRPCRESTGraphQL
ContractShared TS typesOpenAPI / conventionSDL schema
Type safetyEnd-to-end, automaticManual / generatedGenerated from schema
ConsumersTS client onlyAnyoneAnyone
CodegenNoneOptionalUsually
Best forFull-stack TS appsPublic/polyglot APIsComplex client-driven data

The decision is mostly about who calls your API:

  1. TypeScript frontend + TypeScript backend, same team?tRPC for the velocity and zero drift.
  2. Public API, third-party or multi-language consumers?REST (with OpenAPI) for universality.
  3. Many clients with varied, nested data needs?GraphQL for client-driven queries.

A newer option, oRPC, keeps tRPC's ergonomics but adds built-in OpenAPI output and works across languages — worth a look if you want tRPC's feel without the TypeScript-only ceiling. But the core rule stands: tRPC for internal full-stack TS, REST or GraphQL when the contract must cross a language boundary. For that comparison see GraphQL vs REST with JSON.

Frequently asked questions

tRPC is a TypeScript library for building end-to-end typesafe APIs without a schema or code generation. The client imports the server's router *type* directly and calls procedures like local functions, so any change to the backend surfaces as a compile-time type error on the frontend.

Only for internal full-stack TypeScript apps. tRPC requires the client and server to share TypeScript types, so it can't serve public APIs or multi-language consumers. For those, REST or GraphQL — which expose a language-agnostic contract — remain the right choice.

Yes. Despite the "RPC" name, tRPC sends normal JSON over HTTP. The type safety is entirely compile-time; at runtime a tRPC call is an ordinary HTTP request with a JSON body and response that you can inspect in the network tab.

No. That's a core selling point — the client imports the server's exported router type, so types flow across the boundary automatically with no generated SDK or schema file to maintain.

When the API consumer isn't part of your TypeScript codebase: public APIs, third-party integrations, or polyglot stacks with Python, Go, Swift, or Kotlin clients. In those cases use REST or GraphQL, or consider oRPC for tRPC-style ergonomics with cross-language support.

Try JSONKit — JSON Developer Tools

Format, validate, convert and diff JSON. Instant, accurate and 100% private.