Imagine this: you renamed the title field to heading — in the database and on the server. The server works, the backend tests are green, and yet users see empty note titles in the app, because the frontend still reads note.title. The error was found by a user, not by a compiler. In this lesson we will see how tRPC turns such errors from "a user found it" into "a red squiggle in the editor" — without a schema file and without code generation.

The problem: the border between frontend and backend

The frontend and the backend talk over the network, and the network carries plain text — JSON. TypeScript knows nothing about that JSON: at compile time it cannot see the server, and when the server runs, there are no types left at all. So in an ordinary REST app we simply promise the response type to ourselves:

ts
// REST: the response type is only a promise
type Note = { id: string; title: string };

const res = await fetch('/api/notes');
const notes = (await res.json()) as Note[]; // nobody checks this

console.log(notes[0].title); // undefined, if the server renamed the field

The as Note[] part is not a check, it is a promise: "I know the server will send exactly this". If the server changes, TypeScript will not notice, and notes[0].title will turn out to be undefined at run time.

To close this gap, teams came up with two popular solutions:

  • OpenAPI and code generation (REST). The API is described in an openapi.yaml file, and a special program — a generator — creates the client types from it.
  • A GraphQL schema and code generation. The schema is the very contract from the GraphQL module; the client types are again generated from it.

Both approaches have a price: besides the server code you have to write and maintain one more artifact (a spec or a schema) and not forget to run the generator. What if the contract were the server code itself?

The RPC idea: calling a function on another computer

RPC (Remote Procedure Call) is an old idea: call a function that lives on another computer as if it were right here, in your code.

An analogy: in a tea house you do not write the cook a letter explaining how to brew tea. You tell the waiter "one tea, please" — and you get a glass of tea. The waiter is the RPC library: it takes your "function call", carries it to the kitchen (the server) and brings the result back.

REST is built differently: it revolves around resources and HTTP methods (GET /notes/n2, POST /notes), that is, around nouns. RPC revolves around actions, that is, verbs: note.byId, note.create. tRPC is TypeScript RPC: you call the server's procedures like ordinary functions, and TypeScript checks those calls.

A call looks like an ordinary function, but an ordinary HTTP request travels over the network
A call looks like an ordinary function, but an ordinary HTTP request travels over the network

There is no magic here: familiar HTTP and JSON work under the hood. This is what really goes over the network when the client calls trpc.note.byId.query({ id: 'n2' }):

http
GET /api/trpc/note.byId?batch=1&input={"0":{"id":"n2"}} HTTP/1.1
Host: localhost:3000

And the server's response:

http
HTTP/1.1 200 OK
Content-Type: application/json

[{"result":{"data":{"id":"n2","title":"Call Leyla","tags":["work"]}}}]

What matters here: