Post

Understanding tRPC: Separate Systems, Shared Contracts

Understanding tRPC: Separate Systems, Shared Contracts

tRPC is often introduced with an impressive promise: end-to-end type-safe APIs. That phrase sounds useful, but it can also sound as if tRPC is undoing years of advice about separating the frontend from the backend.

It is not.

The key idea is simple:

The frontend and backend should be separate in responsibility and execution, but consistent in the data contract they use to communicate.

This article explains that distinction, translates tRPC’s unfamiliar syntax into ordinary TypeScript, and compares it with REST—including performance.

Separation and Consistency Are Different Things

The frontend and backend still have different jobs:

1
2
3
4
5
Browser                          Server
-------                          ------
Render the interface   HTTP      Check permissions
Handle interactions   ------>    Run business logic
Manage UI state        <------    Access the database

This separation is a security boundary. Database credentials, authorization rules, and private business logic must remain on the server.

However, the two sides need to agree on the messages crossing that boundary. If the backend expects this:

1
{ userId: string }

but the frontend sends this:

1
{ id: number }

the systems are separated, but their contract is broken.

A useful analogy is a restaurant. The dining room and kitchen have separate responsibilities, but both must understand the same menu and order format. tRPC gives a TypeScript frontend and backend a shared, automatically checked menu.

The Problem with a Typical REST Call

Consider a REST endpoint:

1
2
3
4
5
// Server
app.get("/api/users/:id", async (req, res) => {
  const user = await database.user.findById(req.params.id);
  res.json(user);
});

The frontend calls it with fetch:

1
2
3
// Browser
const response = await fetch("/api/users/123");
const user = await response.json();

At runtime, this is perfectly valid. The problem is that TypeScript does not automatically know the shape of user. We could declare it manually:

1
2
3
4
5
6
type User = {
  id: string;
  name: string;
};

const user: User = await response.json();

But that type is now a second description of the server response. If the backend renames name to displayName, the frontend type can become outdated without producing an immediate compiler error.

REST does not prevent type safety. OpenAPI schemas and generated clients can solve this problem very well. They simply require a schema or generation step. tRPC takes another approach: when both sides use TypeScript, it infers the contract directly from the server router.

A Small tRPC Example

Here is a server procedure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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() }))
    .query(async ({ input }) => {
      return {
        id: input.id,
        name: "Ada",
        age: 36,
      };
    }),
});

export type AppRouter = typeof appRouter;

The client receives the router’s type, not its server implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "./server";

const trpc = createTRPCClient<AppRouter>({
  links: [
    httpBatchLink({
      url: "http://localhost:3000/trpc",
    }),
  ],
});

const user = await trpc.getUser.query({ id: "123" });

user.name; // string
user.age;  // number

Now TypeScript can reject incorrect calls before the code runs:

1
2
3
4
5
await trpc.getUser.query({ id: 123 });
//                                 ^ Type error: id must be a string

user.email;
//   ^ Type error: email does not exist in the response

If the server changes name to displayName, every frontend use of user.name becomes a visible compile-time error. This is the practical meaning of end-to-end type safety.

Translating the Syntax into Plain English

This chain is the part that often looks unnecessarily abstract:

1
2
3
4
5
t.procedure
  .input(z.object({ id: z.string() }))
  .query(({ input }) => {
    return { name: "Ada" };
  });

Read it one line at a time:

1
t.procedure

Create a server operation that a client can call.

1
.input(z.object({ id: z.string() }))

Require an object containing a string id, and validate the real network input at runtime.

1
.query(({ input }) => { ... })

Run a read operation after validation succeeds.

In imaginary, more verbose code, it means something like this:

1
2
3
4
5
createRemoteOperation({
  inputValidator: object({ id: string() }),
  operationType: "query",
  handler: ({ input }) => ({ name: "Ada" }),
});

The chaining syntax is not inherently faster or safer than a function. It is a builder API that lets tRPC combine routing, validation, middleware, and type inference in one definition.

Compile-Time Types Are Not Runtime Security

TypeScript disappears when the application is compiled. A user—or an attacker—can ignore the frontend and send an arbitrary HTTP request directly to the server.

That is why this line matters:

1
.input(z.object({ id: z.string() }))

It provides runtime validation. These are two different protections:

1
2
TypeScript  → Helps developers send the correct data
Zod         → Checks the data that actually arrives

A well-designed REST API also needs runtime validation. This cost is not unique to tRPC; it is part of treating all network input as untrusted.

Queries and Mutations

tRPC distinguishes operations that read data from operations that change it.

A query reads data:

1
2
3
getUser: t.procedure
  .input(z.object({ id: z.string() }))
  .query(({ input }) => database.user.findById(input.id));

A mutation changes data:

1
2
3
4
5
6
7
8
createUser: t.procedure
  .input(
    z.object({
      name: z.string().min(2),
      age: z.number().int().min(18),
    }),
  )
  .mutation(({ input }) => database.user.create(input));

The client mirrors those meanings:

1
2
3
4
5
6
const user = await trpc.getUser.query({ id: "123" });

const newUser = await trpc.createUser.mutate({
  name: "Ada",
  age: 36,
});

The calls look local, but they still cross the network. tRPC serializes the input, sends an HTTP request, finds the matching server procedure, validates the input, runs the handler, and returns the result.

Is tRPC Faster Than REST?

Usually, neither choice has a meaningful automatic performance advantage.

For equivalent logic, both follow roughly the same path:

1
Browser → HTTP → Server handler → Database → HTTP response

In most applications, database queries, network latency, payload size, and caching dominate the small amount of framework overhead.

tRPC can batch multiple operations into one HTTP request with httpBatchLink, which may reduce request overhead. REST, on the other hand, maps naturally onto conventional GET URLs and mature HTTP/CDN caching infrastructure.

The better choice depends on the system:

SituationUsually a better fit
TypeScript frontend and backend owned by one teamtRPC
Internal application with frequent API changestRPC
Public API used by third partiesREST + OpenAPI
Swift, Kotlin, Python, and other clientsREST or GraphQL
Heavy reliance on standard HTTP and CDN cachingREST

Choose tRPC primarily for development speed, safe refactoring, and fewer duplicated contracts—not because it promises dramatically lower response times.

When Not to Use tRPC

tRPC is not a universal replacement for REST.

Avoid making it the default when:

  • the API must be consumed by many programming languages;
  • third-party developers need a stable public contract;
  • the organization already has a strong OpenAPI workflow;
  • the team finds the abstraction harder to maintain than explicit routes;
  • a few simple endpoints do not justify another framework.

There is nothing wrong with starting with fetch and REST. tRPC becomes compelling when duplicated request and response types, stale API documentation, and risky cross-boundary refactoring become real problems.

Final Mental Model

tRPC does not remove the frontend/backend boundary. It adds a checked contract across it.

1
2
3
4
5
6
7
8
9
10
11
Separate:
- execution environments
- responsibilities
- secrets and database access
- security boundaries

Shared:
- procedure names
- input shapes
- output shapes
- TypeScript understanding of the contract

The frontend stays in the browser. The backend stays on the server. HTTP still carries the request between them. tRPC’s contribution is making both sides agree—automatically—about what that request and response should contain.

For more details, see the official tRPC documentation.

This post is licensed under CC BY 4.0 by the author.