What end-to-end type safety actually means for an internal TypeScript API

End-to-end type safety means two things hold at once. At compile time, the client and server agree on the shape of every input and output. If a server handler changes a field from string to number, or drops it, the client code that depends on it fails to type-check before anything ships. At runtime, the server validates whatever actually arrives at the boundary and rejects payloads that don't match.

You need both because TypeScript types are erased during compilation. A request body annotated as CreateOrderInput is just parsed JSON when the server receives it. Nothing in the type system stops a stale mobile build, a curl command, or a misconfigured service from sending {"quantity": "ten"}. Unless a schema validator checks the payload, that value flows into your business logic with a type it doesn't have. Compile-time agreement without runtime validation is a promise the network doesn't keep.

So the useful question for comparing tRPC, REST, and GraphQL is not which one is "type-safe." The question is where the type contract lives and who consumes it. It can be inferred directly from server code, generated from a schema such as OpenAPI or GraphQL SDL, or written and maintained by hand on both sides. Each choice changes how a contract drifts and who notices first.

Teams care about this because an enforced contract turns integration bugs into compiler errors. It also makes refactors cheaper: you can rename a field and let the type checker list every caller, instead of searching for string matches and hoping you found them all.

How each approach works: inference, OpenAPI, and GraphQL schemas

The three approaches differ mainly in how the contract reaches the client: inferred from server code, generated from a schema file, or derived from validated route definitions.

tRPC: inference across the import boundary

A tRPC client imports the router's type, not its implementation. A type-only import is erased at compile time, so no server code ships to the client. The compiler still sees every procedure's input and output, so a change to a resolver's return value shows up in client code on the next type check, with no codegen step. Static types disappear at runtime, which is why procedures declare input validators, typically Zod schemas. The same schema validates incoming data and supplies the input type.

REST: three ways to get a contract

  1. Hand-written shared types. The compiler trusts a declaration that nothing enforces, so a server change that skips the shared file drifts silently.
  2. Schema-first OpenAPI. The spec is authored first, and clients and validators are generated from it. The spec is enforced, but generation becomes a build dependency.
  3. Code-first. Validated route definitions emit OpenAPI, so the spec follows the code.

GraphQL: the schema as contract

The server validates every query against the schema before running it. Client codegen reads each operation and produces types for only the fields that operation selects, not for the whole type.

Approach Where the contract lives How the client gets types
tRPC Router code Type inference
REST, shared types Hand-maintained file Manual import
REST, OpenAPI Spec file, written or emitted Codegen
GraphQL Schema plus operations Per-operation codegen
// server/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  userById: t.procedure
    // One Zod schema: validates the request at runtime AND supplies the input type
    .input(z.object({ id: z.string().uuid() }))
    .query(({ input }) => ({ id: input.id, name: 'Ada', email: 'ada@example.com' })),
});

// Export only the router's type; the client never imports the implementation
export type AppRouter = typeof appRouter;

// client/api.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/router'; // type-only import, erased at compile time

const trpc = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: '' })],
});

const user = await trpc.userById.query({ id: '3f2c8a1e-0000-4000-8000-000000000001' });
// user is inferred as { id: string; name: string; email: string }, with no codegen step

// GraphQL, for contrast: the operation is input to a codegen tool, not to the compiler
const USER_NAME = /* GraphQL */ `
  query UserName($id: ID!) {
    user(id: $id) { name }
  }
`;
// Codegen emits a type covering only the selected fields. If the schema
// declares user as nullable, the result is roughly:
// type UserNameQuery = { user?: { name: string } | null };

A tRPC router and client in which the response type is inferred through a type-only import, shown next to a GraphQL operation whose per-operation type has to be generated from the selected fields.

Where the source of truth lives and how breaking changes surface

The table shows where each contract is declared. In practice, what matters is when a change to it becomes visible. Take two edits: renaming a response field from userName to displayName, and making an optional teamId input required.

In tRPC, both edits are ordinary changes to router code. The client imports the router's inferred type, so the next typecheck in the same build fails at every call site that reads userName or omits teamId. The catch is in review. The diff looks like normal server code, so reviewers must recognize it as a contract change on their own.

With OpenAPI, the spec diff is the review artifact. It shows a renamed property and a new entry in the required array. Clients break only after codegen regenerates their types. A spec-diff step in CI can flag both edits as breaking before that happens.

GraphQL works the same way. The rename removes a field from the schema, and changing teamId: ID to teamId: ID! on an input type is breaking. Schema-diff tooling classifies both, and validating stored operations against the new schema fails for affected queries.

Approach Rename response field Make input required Surfaces at
tRPC Property missing on inferred type Missing argument error Client typecheck, same build
OpenAPI Property removed in spec Added to required Codegen or spec diff in CI
GraphQL Field removed from SDL Nullable to non-null Schema diff or operation validation in CI

None of this protects a consumer that doesn't rebuild. An already-deployed service or a scheduled job will keep sending the old payload. For independently deployed clients, you still need additive changes: add displayName alongside userName, mark the old field deprecated, and enforce teamId only after callers have migrated.

// server/router.ts: the router code is the contract
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  getMember: t.procedure
    // Change 2: teamId was z.string().optional(), now required
    .input(z.object({ memberId: z.string(), teamId: z.string() }))
    .query(({ input }) => ({
      id: input.memberId,
      teamId: input.teamId,
      // Change 1: renamed from userName
      displayName: 'Ada Lovelace',
    })),
});

// Clients import only this type. No codegen step sits in between.
export type AppRouter = typeof appRouter;

// client/member.ts: typechecked in the same build as the server
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '../server/router';

type MemberInput = inferRouterInputs<AppRouter>['getMember'];
type MemberOutput = inferRouterOutputs<AppRouter>['getMember'];

// Fails tsc (TS2741): property 'teamId' is missing but required
const req: MemberInput = { memberId: 'm_123' };

declare const member: MemberOutput;
// Fails tsc (TS2339): property 'userName' does not exist on the output type
console.log(member.userName);

// A deployed client built before this change still sends { memberId } only.
// Its request fails input validation at runtime instead of at compile time.

Both edits in a tRPC router: the client's inferred types change right away, so the next tsc run in the same build fails at the call sites, but a client that was deployed earlier and not rebuilt gets no compile-time error.

Who should use each approach and when

The deciding question is coupling: can every consumer import the server's types? tRPC's safety comes from the client importing the router's type definition, so every consumer must be TypeScript and must reach that type through a monorepo workspace or a shared package. REST and GraphQL contracts are language-neutral documents. A Go service, a Python job, or a Kotlin app can generate a client from them without knowing what language the server uses.

tRPC fits a single TypeScript monorepo with a few tightly coupled consumers, such as a Next.js frontend and its backend, or an admin tool and the service it manages. Each new consumer adds another deploy that has to track the server's types. That cost stays manageable with two or three.

REST plus OpenAPI fits when consumers span languages, when the API may later be exposed to partners or customers, or when you depend on HTTP semantics such as status codes, conditional requests, and resource-level caching. The spec acts as a contract that outlives any one codebase.

GraphQL fits many clients that each need different shapes of the same data, especially when one API sits in front of several backends. A schema plus resolvers lets the mobile, web, and internal dashboard teams each query only the fields they need without filing endpoint requests.

Consumer topology Recommended approach Why
A few TypeScript clients in one monorepo tRPC Shared inferred types, no codegen step
Polyglot services or future external users REST + OpenAPI Language-neutral contract, standard HTTP behavior
Many clients with divergent data needs across multiple backends GraphQL Client-shaped queries over one schema

When to avoid each approach: trade-offs and failure modes

tRPC

tRPC's strength is that client and server share types directly, and that is also its main hazard. Types are checked at build time, not at the network boundary, so a client bundle built last month carries last month's assumptions. Mobile apps, long-lived browser tabs, and services deployed on separate schedules will call procedures whose input shape has since changed, and the compiler never sees that mismatch. tRPC also has no language-neutral contract. When a Python job, a Go service, or a native mobile team needs the API, you either hand-maintain a parallel spec or migrate those procedures to REST or GraphQL. Large routers also slow down type checking and editor responsiveness, because every client file pulls in the inferred type of the whole router.

REST with hand-written types

A shared interfaces.ts that nobody generates gives a false sense of safety. The server changes a field and the client's cast still compiles. The drift shows up as undefined in production, not as an error in review.

OpenAPI and GraphQL codegen

Generated types are only as current as the last regeneration. Unless CI regenerates the artifacts and fails on a diff, they go stale without anyone noticing. Output quality also varies by generator. Some produce loose unions, awkward naming, or weak handling of discriminated types.

GraphQL

Naive resolvers fetch per item and produce N+1 queries, so you need batching. Public-shaped queries require depth and complexity limits. Fields are nullable by default, which pushes null checks into every client unless the schema marks fields non-null deliberately.

Operational concerns: caching, observability, and errors

Each approach shapes what your existing HTTP infrastructure can see, and that determines how much custom tooling you need before an incident is debuggable.

Concern REST GraphQL tRPC
Caching Standard HTTP caching on GET plus Cache-Control and ETags; CDNs and proxies understand it Usually one POST endpoint, so HTTP caches see nothing useful; caching moves to the client or resolver layer, or to persisted queries sent over GET Queries can go over GET, but inputs are serialized into the query string, and batching merges procedures into one URL, which makes cache keys awkward
Monitoring Per-route latency and error rates from any gateway or APM Requires per-operation and per-resolver instrumentation; every request hits the same path Procedure names appear in the path, but batched calls hide individual latencies
Error signalling HTTP status codes carry meaning Errors often arrive in an errors array with HTTP 200, alongside partial data Error codes map to HTTP statuses, but a batched response mixes outcomes
Extra tooling Little beyond what you already run Operation naming, query cost limits, error-array alerting Gateway rules that understand procedure paths, and possibly batching disabled on hot paths

The GraphQL 200-with-errors pattern matters operationally because it can make a failing resolver look healthy. Dashboards built on 5xx rates stay green while clients receive nulls. If you adopt GraphQL, alert on the contents of the response body, not on its status code.

For tRPC, decide early whether to batch. Batching reduces round trips, but it also reduces what your logs and gateways can attribute to a single procedure.

Impact on development cost, engineering time, and mixing approaches

Inside a single monorepo, tRPC's main saving is work that never happens. There is no spec to write, no generator to run, and no generated client to commit or review. A procedure change shows up as a compiler error in the consuming component on the next type-check. OpenAPI and GraphQL each add a build step, a CI job that verifies the schema matches the implementation, and a schema review on every change. That overhead buys you a contract that a team in another repository, or writing in another language, can consume without importing your server code. Once several teams depend on an API, coordinating through a reviewed schema usually costs less than coordinating through Slack threads and broken builds.

The cost that is easy to miss is a late migration off tRPC. Router types often stand in for documentation, so when an external consumer appears, someone has to reconstruct a contract from inferred types and implicit behaviour.

Illustrative estimate (assumed numbers, not a benchmark): migration hours ≈ procedures × (spec authoring + consumer rewiring + contract tests)

With 120 procedures at 0.5 + 1 + 0.5 hours each, the total is 120 × 2 = 240 hours. The same procedures specified one at a time, as they were built, would have cost far less attention in any single sprint.

A coexistence pattern holds up well in practice. Use tRPC for an app's own frontend-to-backend link. Expose REST or GraphQL at the boundary other teams consume, implemented as a thin layer over the same service functions. In every option, the types stop at the network boundary, so you still need runtime validation of inputs and outputs. You also still need versioning discipline, because inferred or generated types only show you that a change breaks consumers and do nothing to stop you shipping it.

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.