Hermes Wiki
Developer/Languages/TypeScript/APIsAndTypeSafety/Fundamentals/trpc-vs-openapi-codegen-vs-zod-validated-rest

tRPC vs. OpenAPI Codegen vs. Zod-Validated REST: Keeping Frontend/Backend Types Honest

Concept

"End-to-end type safety" between a frontend and backend means a change to a backend response shape produces a compile error on the frontend, not a runtime surprise. There are three genuinely different ways TypeScript codebases achieve this, and they trade off differently depending on who's on the other end of the API:

  • tRPC (code-first, TypeScript-only). The server defines procedures as plain TypeScript functions, typically with Zod schemas for input validation. The client imports the server's router type (not its implementation) and gets full inference — argument types, return types, and even nested procedure names — with zero code generation step and zero schema drift, because the TypeScript type system itself is the schema. This only works when client and server share a TypeScript type-checking boundary, in practice a monorepo.
  • OpenAPI + codegen (schema-first, language-agnostic). A spec (YAML/JSON) describes every endpoint, parameter, and payload independent of any implementation language. Tooling (e.g. openapi-typescript, or FastAPI's built-in OpenAPI generation) turns that spec into TypeScript types and often a full typed client. The spec is the source of truth; both the server's implementation and the generated client are expected to conform to it, and a mismatch between the actual server response and the spec is a real, silent failure mode this approach doesn't catch by construction.
  • Zod-validated REST (runtime-first, framework-agnostic). Zod schemas are written once and used for two purposes simultaneously: z.infer<typeof Schema> produces the TypeScript type, and Schema.parse(data) performs actual runtime validation at the boundary. This is not by itself an end-to-end solution — it validates one side's input, and typically the same schema (or a mirrored one) has to be duplicated or shared to cover both the request the server expects and the response the client expects.

Tradeoffs

Approach Source of truth Codegen step Cross-language clients Cost
tRPC The TypeScript implementation itself None — pure type inference No — requires the consumer to also be TypeScript sharing the same type-checking boundary Locks the API to a TypeScript monorepo; adding a non-TS or truly external consumer later means bolting on OpenAPI anyway
OpenAPI + codegen The spec document Yes — a build/CI step regenerates types from the spec Yes — the entire point; any language with an OpenAPI client generator can consume it Three-part maintenance burden: implementation must match spec, codegen must actually run when the spec changes, and generated output must be wired into the client's types — teams routinely ship a schema change without re-running codegen and don't find out until a runtime shape mismatch
Zod-validated REST The Zod schema (per side) None, but schema often needs sharing/duplication across server and client Yes, in that it's just REST underneath — but no automatic client generation Real runtime enforcement (catches malformed data live, not just at compile time) — but doesn't by itself solve keeping frontend and backend schemas in sync unless the schema package is shared between them

The tRPC-vs-OpenAPI choice really turns on one fact: does the schema need to exist as a document independent of any implementation? If every consumer is TypeScript code the team owns, that independent document is pure overhead — tRPC's "the code is the schema" insight removes a redundant layer. If a partner team, a mobile app in a different language, or a public API consumes the same backend, that independent document stops being optional — OpenAPI's schema-first model is what makes generating a Swift or Kotlin client possible at all.

When to use / when not to

  • Use tRPC when the frontend and backend live in the same TypeScript monorepo and every consumer of the API is that frontend — the fastest path to inferred, drift-proof types with the least ceremony.
  • Use OpenAPI + codegen when there are non-TypeScript consumers (a mobile app, a partner integration, a public API), or when API governance requires a versioned, reviewable, language-agnostic contract document independent of any one implementation.
  • Use Zod (on top of either approach, not instead of) whenever request data crosses a trust boundary — Zod's runtime .parse() is what actually rejects malformed data; neither tRPC's type inference nor OpenAPI's generated types run at runtime, so a payload that's structurally wrong but not TypeScript-visible (a bad JSON.parse, a hand-crafted malicious request) sails through unless something validates it live.
  • Don't add OpenAPI codegen to a project whose only consumer is its own TypeScript frontend in the same repo — that's the specific case tRPC exists to remove the ceremony from.
  • Don't rely on OpenAPI-generated types alone as "type safety" without a CI check that codegen actually ran against the current spec — a stale generated client that still compiles against an old spec is worse than no types, because it's confidently wrong.

Common pitfall

Assuming any of these three approaches provides runtime protection just because it provides compile-time types. TypeScript types (from tRPC inference or OpenAPI codegen alike) are erased entirely at runtime — they catch a mismatch between what code expects and what code sends, at the call sites the compiler can see, but they do nothing about data arriving from outside that compiled boundary: a webhook payload, a response from a third-party API that silently changed shape, a request crafted by hand. Zod's .parse() is the only one of the three that actually executes a check when the bytes arrive. A codebase that has "full type safety" via tRPC or OpenAPI but never runs .parse() at its actual I/O boundaries (request bodies, webhook payloads, third-party responses) has compile-time safety and zero runtime safety — and it's the runtime gap that produces a bad-data bug in production, not the compile-time one.

Engineering Lens

The underlying design question these three approaches all answer differently is: where does the schema live, and does it need to be a first-class artifact independent of implementation code? tRPC's answer — no, TypeScript's own type system is expressive enough to be the schema, so a separate schema is redundant work — is a genuinely novel insight for the TypeScript-monorepo case, and explains why it displaced hand-written REST clients so quickly in that niche. But that answer stops working the moment "independent of implementation" becomes a real requirement rather than an accident of repo layout — a public API, a polyglot client base, or a compliance requirement for a reviewable contract all need the schema to exist as something other than "read the server's source." Recognizing which of those two worlds a given service actually lives in — before building out the API layer — avoids the expensive migration of bolting OpenAPI onto a service built as tRPC-only once an external consumer shows up.

Sources

Hermes Wiki