Full-Stack Questions
tRPC, REST and OpenAPI
tRPC for your own TypeScript app, REST plus OpenAPI for outside callers: generated clients, contract tests, breaking changes, problem-details errors and idempotency keys.
On this page
- Words used in this lesson
- Part 1. tRPC: types without a schema file
- Part 2. REST with an OpenAPI document
- Part 3. Contract testing
- Part 4. Versioning and breaking changes
- Part 5. Error shapes: RFC 9457 problem details
- Part 6. Idempotency keys
- Part 7. How to choose
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: tRPC gives you type safety between your own TypeScript client and server with no schema file, and an OpenAPI document gives the same safety to anyone (any language, any team) through a written contract. Pick tRPC when you own both sides; pick REST plus OpenAPI when other people depend on your API.
"Should we use tRPC or REST?" is a common design question, and the honest answer is "it depends on who calls the API". This lesson shows both styles in small programs you can run, then covers the parts that decide whether an API stays healthy for years: contract tests, versioning, error shapes and idempotency keys.
This lesson does not repeat the basics. For resources, status codes, pagination and URL versioning, see Backend, Lesson 02. For GraphQL and Hasura (the third option), see Full-Stack Q&A, Lesson 09. For validating input at the edge, see Backend, Lesson 03. For contract tests in a test plan, see Backend, Lesson 11.
What I ran. Node 22.22, TypeScript 5.9.3, @trpc/server and @trpc/client 11.19.0, zod 4.6.5, openapi-typescript 7.13.0, openapi-fetch 0.17.0, trpc-to-openapi 3.3.0, ajv 8.20 and ajv-formats 3.0.1. I ran the tRPC server and client, the type checker (the errors below are real compiler output), the OpenAPI generator, the typed REST client, the problem-details server, the idempotency-key replay and a schema-based contract check. I did not run or read the docs of Pact, Schemathesis, oasdiff, @asteasolutions/zod-to-openapi, the Hono and Fastify packages or oRPC. I only checked that the npm packages exist, so my one-line descriptions of them are general background. I read the RFC 9457 and Idempotency-Key text from the IETF working-group repositories on GitHub (the IETF website was not reachable from my sandbox). Timings and sizes depend on my machine.
Words used in this lesson
- RPC (remote procedure call) means calling a function that runs on another computer. The call looks like a function call; HTTP is hidden underneath. tRPC is "TypeScript RPC".
- A procedure is one function exposed by a tRPC server. A query reads data. A mutation changes data. A router is a group of procedures.
- End-to-end type safety means the compiler checks the data shape from the database row to the screen, so a rename on the server breaks the client build instead of production.
- Type inference means TypeScript works out a type from the code, without you writing it. tRPC uses it to build client types straight from the server code.
- A schema is a description of allowed data. Zod is a TypeScript library that checks data at run time and also gives you the TypeScript type.
- OpenAPI is a standard file format (JSON or YAML) that describes an HTTP API: paths, methods, inputs, outputs and errors. It is the successor of "Swagger". Tools can read it to make docs, clients and tests.
- JSON Schema is a standard way to describe the shape of JSON. OpenAPI 3.1 uses JSON Schema (draft 2020-12) for its data models.
- Code-first means you write the code (and schemas) and generate the OpenAPI file. Design-first (or contract-first) means you write the OpenAPI file first and build the code from it.
- A contract is the agreement between caller and API: what you can send and what you get back.
- A breaking change is a change that makes a correct old client fail. A backward-compatible change keeps old clients working.
- RFC 9457 "problem details" is a standard JSON shape for HTTP errors, with the media type
application/problem+json. - An idempotency key is a unique value the client sends with a request, so the server can recognise a retry of the same request and not do the work twice.
Part 1. tRPC: types without a schema file
How it works
You write procedures on the server. You export only the type of the router. The client imports that type, and TypeScript works out every input and output. There is no code generation step. The tRPC docs describe it as "fully typesafe APIs without schemas or code generation". The docs also say that, underneath, it is plain HTTP, and you should think "functions", not "verbs and URLs".
// router.ts (server)import { initTRPC, TRPCError } from '@trpc/server';import { z } from 'zod';const t = initTRPC.create();const users = new Map([[1, { id: 1, name: 'Asha', email: 'asha@example.com' }]]);export const appRouter = t.router({ userById: t.procedure .input(z.object({ id: z.number().int().positive() })) .query(({ input }) => { const user = users.get(input.id); if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: `user ${input.id} not found` }); return user; }), createUser: t.procedure .input(z.object({ name: z.string().min(1), email: z.email() })) .mutation(({ input }) => { const user = { id: users.size + 1, ...input }; users.set(user.id, user); return user; }),});export type AppRouter = typeof appRouter; // only the TYPE crosses to the client// main.ts (client, run in the same file as the server for the demo)import { createHTTPServer } from '@trpc/server/adapters/standalone';import { createTRPCClient, httpBatchLink, TRPCClientError } from '@trpc/client';const server = createHTTPServer({ router: appRouter }).listen(0);const client = createTRPCClient<AppRouter>({ links: [httpBatchLink({ url })] });const u = await client.userById.query({ id: 1 }); // u is { id: number; name: string; email: string }const c = await client.createUser.mutate({ name: 'Ben', email: 'ben@example.com' });What it printed: typed result: { id: 1, name: 'Asha', email: 'asha@example.com' }, then created: { id: 2, name: 'Ben', ... }. The missing user (id: 99) threw a TRPCClientError with data.code = 'NOT_FOUND' and data.httpStatus = 404.
The compiler catches the mistakes
I wrote a file with three deliberate mistakes and ran tsc:
await client.userById.query({ id: '1' }); // wrong input typeconsole.log(a.nmae); // typo in an output fieldawait client.userByEmail.query({ email: 'a@b.c' }); // procedure does not existt1/bad.ts(4,41): error TS2322: Type 'string' is not assignable to type 'number'.t1/bad.ts(5,15): error TS2339: Property 'nmae' does not exist on type '{ id: number; name: string; email: string; }'.t1/bad.ts(6,14): error TS2339: Property 'userByEmail' does not exist on type 'TRPCClient<...>'.This is the whole selling point. If you rename email to emailAddress on the server, every client call that uses it fails to compile.
What is on the wire
I called the server with plain fetch to see the HTTP (it is ordinary HTTP, but it is tRPC's own format, not a REST design):
| What | What I saw |
|---|---|
| A query | GET /userById?input={"id":1} (the input is JSON in the query string) |
| A query result | 200 and {"result":{"data":{"id":1,"name":"Asha","email":"asha@example.com"}}} |
| A mutation | POST /createUser with a JSON body |
GET on a mutation | 405 with code METHOD_NOT_SUPPORTED |
| Bad input (empty name, bad email) | 400. The message is a JSON string of the Zod issues |
A thrown NOT_FOUND | 404 and {"error":{"message":"user 99 not found","code":-32004,"data":{"code":"NOT_FOUND","httpStatus":404,"path":"userById"}}} |
Two details I checked. In development the error data also had a stack field; with NODE_ENV=production it was gone. And httpBatchLink can combine several calls made at the same time into one HTTP request (from the docs; I did not measure it).
The error codes (NOT_FOUND, BAD_REQUEST, UNAUTHORIZED and so on) map to HTTP statuses, and getHTTPStatusCodeFromError gives you the number. The error body is tRPC's own format, so it is not RFC 9457. Anyone who calls it without the tRPC client must learn that format.
Where tRPC fits and where it hurts
| Good fit | Weak fit |
|---|---|
| One TypeScript team owns the client and the server (often a monorepo, or Next.js with a separate API) | Third parties, mobile apps in Swift or Kotlin, partners, anyone who is not using your TypeScript types |
| The client is deployed together with the server, or you can force a refresh | Old clients live for months (native apps) and you cannot change both sides at once |
| You want fast iteration with no schema file to maintain | You need generated docs, a public developer portal, SDKs in many languages |
| Internal tools, dashboards, a web app and its backend-for-frontend | You need HTTP caching by URL at a CDN, or webhooks that other systems call |
Other things to know:
- Coupling is the price. The client imports the server's type, so they must live in a shared TypeScript build. That is easy in a monorepo and awkward across repos.
- There is no built-in public contract. The "contract" is your TypeScript code. There is nothing a Python team can read. If you later need one, you can add a generated OpenAPI layer (Part 2).
- Version handling is manual. tRPC has no
/v1idea. You keep it safe by making additive changes only, or by deploying client and server together. - Validation is still your job. The types vanish at run time. The
.input(z.object(...))line is what protects you from bad data, and the TypeScript types come from it. Server-side checks of who may call the procedure (authorization) go in middleware or in the procedure, not in the type. - Subscriptions (live updates) are supported. The docs recommend Server-Sent Events over WebSockets when you are unsure, because it is easier to set up.
Part 2. REST with an OpenAPI document
What the document is
An OpenAPI document lists every path and method, with its parameters, request body, and each possible response. Here is a small one for two endpoints. I did not write it by hand: I built it from the same Zod schemas the server uses (next section).
{ "openapi": "3.1.0", "info": { "title": "Users API", "version": "1.0.0" }, "paths": { "/users/{id}": { "get": { "operationId": "getUser", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "The user", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } } }, "404": { "description": "Problem Details (RFC 9457)", "content": { "application/problem+json": { "schema": { "$ref": "#/components/schemas/Problem" } } } } } } }, "/users": { "post": { "operationId": "createUser", "parameters": [{ "name": "Idempotency-Key", "in": "header", "required": true, "schema": { "type": "string" } }], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateUser" } } } }, "responses": { "201": { "...": "User" }, "422": { "...": "Problem" } } } } }, "components": { "schemas": { "User": {}, "CreateUser": {}, "Problem": {} } }}OpenAPI 3.1 treats its schema objects as JSON Schema draft 2020-12 (the 3.1.1 spec says so). The spec repository's main branch now also holds a 3.2.0 text, but I did not test any tool against 3.2, so I used 3.1.
Zod to OpenAPI: the main approaches
You do not want two sources of truth (the Zod schema and a hand-written file that drifts). These are the common routes:
| Approach | How it works | Status in this lesson |
|---|---|---|
Zod 4 built-in z.toJSONSchema() | Turns a Zod schema into JSON Schema. You place those schemas in your own OpenAPI object | Ran it (below) |
trpc-to-openapi | Add .meta({ openapi: { method, path } }) to tRPC procedures, then call generateOpenApiDocument(router, ...). It can also serve those procedures as REST routes | Ran the generator |
@asteasolutions/zod-to-openapi (v9.1.0 on npm) | A registry where you register schemas and routes, then generate the document | Not tested |
Framework packages such as @hono/zod-openapi and fastify-type-provider-zod | Routes defined with Zod schemas produce the document and validate requests in one step | Not tested (they exist on npm) |
| Design-first with a hand-written YAML | The file is the source. Generate types/clients (and server stubs) from it | Generated client types: ran |
My Zod 4 route, in short:
const js = (s) => z.toJSONSchema(s, { target: 'draft-2020-12', io: 'input' });const doc = { openapi: '3.1.0', info: {...}, paths: {...}, components: { schemas: { User: js(User), CreateUser: js(CreateUser), Problem: js(Problem) } } };Things I saw in the output: z.int() became "type": "integer" with minimum and maximum set to the safe-integer limits (-9007199254740991 and 9007199254740991). z.email() became "format": "email" plus a long pattern. Optional fields were left out of required. Remove the $schema key that toJSONSchema adds if you embed the result.
With trpc-to-openapi, I added OpenApiMeta to initTRPC, put .meta({ openapi: { method: 'GET', path: '/users/{id}', tags: ['users'] } }) on a procedure, and the generator returned a 3.1.0 document with the path /users/{id} and the id as a path parameter. Note that REST needs real path parameters as strings, so I used z.coerce.number() for id. One surprise: the project README says protect defaults to false, but in version 3.3.0 the code has protect = true as the default, and my generated operation had a security entry. Set protect explicitly.
Generate a typed client from the document
npx openapi-typescript openapi.json -o schema.d.tsThe tool printed openapi.json -> schema.d.ts [91.1ms] and wrote types such as:
export interface paths { "/users/{id}": { get: operations["getUser"]; ... }; "/users": { post: operations["createUser"]; ... } }export interface components { schemas: { User: { id: number; name: string; /** Format: email */ email: string }; Problem: { type: string; title: string; status: number; detail?: string; instance?: string };} }openapi-fetch reads those types and gives you a small client (no code is generated, it is only types plus a thin fetch wrapper):
import createClient from 'openapi-fetch';import type { paths } from './schema.d.ts';const api = createClient<paths>({ baseUrl });const ok = await api.GET('/users/{id}', { params: { path: { id: 1 } } });const miss = await api.GET('/users/{id}', { params: { path: { id: 42 } } });console.log(miss.response.status, miss.error); // error is typed as ProblemReal output: GET 1 -> 200 { id: 1, name: 'Asha', ... } and for id 42 404 with { type: 'https://api.example.com/problems/user-not-found', title: 'User not found', status: 404, detail: 'No user with id 42.', instance: '/users/42' }. And the compiler errors for wrong use:
t2/bad.ts(4,15): error TS2345: Argument of type '"/user/{id}"' is not assignable to parameter of type '"/users/{id}"'.t2/bad.ts(5,50): error TS2322: Type 'string' is not assignable to type 'number'.t2/bad.ts(7,13): error TS18048: 'r.data' is possibly 'undefined'.The third error is the most useful: data is only defined on success, so the client makes you handle the failure case.
Typed does not mean true. openapi-typescript trusts the document. If the server stops following it, the client types are wrong and the compiler will not know. That is why you need a contract check (next part). tRPC has the same limit in a weaker form, since the types come from the real server code and cannot drift.
Part 3. Contract testing
A contract test checks that what the server really returns matches the contract. Two directions are common:
- Provider check (schema based). Call the real API and check each response against the schemas in the OpenAPI document. Tools such as Schemathesis can generate the calls from the document (not tested by me).
- Consumer-driven contracts. Each client writes down what it needs (its calls and the parts of the reply it uses). The server runs those files in its build. Pact is the well known tool (not tested by me;
@pact-foundation/pactis on npm). This helps when you have several clients and want to know which one a change would break.
I wrote the first kind with Ajv, in about twenty lines. It loads the document, runs the real server, and validates the body:
const ajv = new Ajv2020({ strict: false }); addFormats.default(ajv);ajv.addSchema({ $id: 'doc', components: doc.components });const validate = ajv.getSchema('doc#/components/schemas/User')!;validate(body) ? 'ok' : ajv.errorsText(validate.errors);GET /users/{id} id=1 -> 200: okGET /users/{id} id=42 -> 404: okdrifted body: data must have required property 'name'The "drifted body" is a response where someone renamed name to fullName and did not update the document. The check fails with a clear message. Put this in CI, so a mismatch fails the pull request, not a customer.
Also add a breaking-change diff between the old and new document in CI. Tools that compare two OpenAPI files exist (for example oasdiff; I did not run one), and the rules they apply are the ones in the next section.
Part 4. Versioning and breaking changes
Do not repeat the URL-versus-header debate here (it is in Backend, Lesson 02). What matters more is which changes are breaking. These rules are common practice, not a standard:
| Change | Breaking? | Why |
|---|---|---|
| Add a new optional response field | No | Clients must ignore fields they do not know |
| Add a new endpoint | No | Nothing old calls it |
| Add a new optional request field or query parameter | No | Old clients do not send it |
| Add a new required request field | Yes | Old requests become invalid |
| Remove or rename a field, or change its type | Yes | Old clients read it |
| Make an optional request field required | Yes | Same reason |
| Add a new value to an enum in a response | Maybe | A client with an exhaustive switch will fail. Document that clients must handle unknown values |
| Make a response field nullable | Yes (often) | Clients that assumed a value may crash |
| Tighten validation (shorter max length, stricter pattern) | Yes | Old valid requests are rejected |
Change a status code or error type | Yes | Clients branch on it |
| Change the meaning of a field with the same name and type | Yes, and hard to detect | A diff tool cannot see it |
Practical habits:
- Be a tolerant reader. Tell clients to ignore unknown response fields. Generated clients that are too strict (they reject unknown fields) turn safe changes into breaks.
- Expand, then contract. Add the new field, move clients over, measure usage, then remove the old field. The same idea is used for database migrations.
- Announce removal with headers. The
Deprecationheader (from an IETF HTTP working-group document I read; its example value is a date as@1688169599, a Unix time) says "this is deprecated since then". TheSunsetheader (RFC 8594) says when it will stop working. TheSunsetdate must not be earlier than theDeprecationdate. Also put it in the OpenAPI document withdeprecated: true. - Measure before you remove. Log which clients (by API key or
User-Agent) still call the old field. - A new major version is the last resort, because you must run both. It is the right tool when the resource model itself changes.
- With tRPC, the safe rule is the same additive rule, plus: because the client and server share a build, a bad change fails at compile time if both are in the same repo. Old deployed mobile or cached browser bundles can still call the old shape, so keep old procedures for a while.
Part 5. Error shapes: RFC 9457 problem details
Backend, Lesson 02 says "use one error format". RFC 9457 (it obsoletes RFC 7807) is the standard one. I read the text in its source repository. The key facts:
- Media type:
application/problem+json(and an XML twin,application/problem+xml). - Members:
type(a URI that names the kind of problem; if missing it meansabout:blank, "no extra meaning beyond the HTTP status"),title(short, should not change between occurrences),status,detail(this occurrence, for people),instance(a URI for this occurrence). - You can add extension members, such as
errorsfor field-by-field validation messages or atraceId. - Clients must use
typeas the identifier. They should not parsedetail; put machine-readable facts in extension members. - Clients should not automatically fetch the
typeURL, but if it is a real URL it should show human documentation. - The response may be sent as
application/problem+jsoneven if the client did not list it inAccept.
The server I ran sends this for a missing user (status 404, header Content-Type: application/problem+json):
{ "type": "https://api.example.com/problems/user-not-found", "title": "User not found", "status": 404, "detail": "No user with id 42.", "instance": "/users/42" }function problem(res, status, title, detail, instance) { res.writeHead(status, { 'content-type': 'application/problem+json' }); res.end(JSON.stringify({ type: `https://api.example.com/problems/${slug(title)}`, title, status, detail, instance }));}For a validation error with several fields I used 422 and put the issues in detail as text. A better design adds an extension member, for example "errors": [{ "pointer": "/email", "message": "Invalid email address" }], so no one has to parse text.
Do not put secrets or internals in detail. No stack traces, SQL, file paths or "user exists" hints for sign-in. The error body is public. Log the details on the server and send a traceId the user can quote.
How this compares: a tRPC server returns its own JSON-RPC-like shape (shown above). If you expose the same logic as REST, make the REST layer return problem details, and have a clear mapping from your internal error codes to type URIs.
Part 6. Idempotency keys
A network can fail after the server did the work. The client does not know, so it retries. For GET, PUT and DELETE a retry is safe by design (see Browser, Lesson 06). For POST (create an order, send money) a retry may do it twice. The fix is an Idempotency-Key header: a random value (a UUID is recommended) chosen by the client, the same on every retry of one action.
The IETF draft for this header (I read the working-group source; it is a draft, so check its status before you rely on its exact wording) says:
- The server defines and documents the key rules, including how long keys are kept.
- Missing key on an endpoint that needs one: reply
400. - Same key but a different request body: reply
422. - Same key while the first request is still running: reply
409. - Otherwise a repeat returns the stored result of the first request.
My demo server follows that. The client sent the same request twice with key k-123:
POST #1 201 { id: 2, name: 'Ben', ... }POST #2 (retry) 201 { id: 2, name: 'Ben', ... } replayed = truesame key, new body -> 422 Idempotency key reusedOnly one user was created. The core of the code:
const prev = seen.get(key);if (prev) { if (prev.bodyJson !== bodyJson) return problem(res, 422, 'Idempotency key reused', ...); return json(res, prev.status, prev.out, { 'idempotent-replayed': 'true' });}// ...do the work, then:seen.set(key, { bodyJson, status: 201, out: user });My demo is not safe under real concurrency. It checks the map, does the work, then stores the result. Two identical requests that arrive at the same moment could both pass the check. In production, reserve the key first with an atomic step (a unique index on (key) and INSERT ... ON CONFLICT DO NOTHING, or Redis SET NX), mark it "in progress", answer 409 to a second caller, and store the final response when done. Keep keys for a limited time (for example 24 hours) and scope them per user, so one customer cannot replay another's key.
Where this shows up: payment APIs and webhooks. See Full-Stack Q&A, Lesson 19 (queues, retries, webhooks) and Lesson 23 (payments).
Part 7. How to choose
Ask these questions in order:
- Who are the callers? Only your own TypeScript web app: tRPC (or plain functions and Server Actions) is great. Partners, other languages, mobile apps, or the public: REST with OpenAPI.
- Can you deploy client and server together? Yes: tRPC's tight coupling is fine. No (mobile apps, long-lived integrations): you need an explicit versioned contract.
- Do you need documentation and SDKs for outsiders? If yes, an OpenAPI document is the product, not an extra.
- Do clients need to choose their own fields across many related types? Consider GraphQL or Hasura (Lesson 09, Backend, Lesson 09).
- Do you need URL-based HTTP caching, webhooks or simple
curltesting? REST fits better.
A common, sensible mix: tRPC (or typed server functions) for your own web app, and a small REST + OpenAPI surface for the parts partners use. trpc-to-openapi can expose chosen procedures that way, but only add it if you will keep that public surface stable, since it becomes a promise you must keep.
| tRPC | REST + OpenAPI | GraphQL | |
|---|---|---|---|
| Contract lives in | TypeScript types | A written document | A schema |
| Non-TypeScript clients | Hard | Easy | Easy |
| Docs and SDK generation | Not built in | Strong ecosystem | Strong ecosystem |
| URL caching, CDN | Weak (batching, own format) | Natural | Weak by default |
| Versioning | Additive changes, deploy together | Explicit, many options | Additive, deprecate fields |
| Setup cost for a TS monorepo | Lowest | Medium | Medium to high |
Common mistakes
- Choosing tRPC for an API that outside teams will call, then writing docs by hand later.
- Treating generated client types as proof. They only match the document, not the running server, until a contract test checks it.
- Hand-writing the OpenAPI file next to the code, so the two drift.
- Calling a change "safe" because the compiler passes, when old mobile clients are still in use.
- Adding a required request field, making a field nullable or tightening validation, and calling it a minor version.
- Returning a custom error shape from every endpoint, or parsing
detailtext in the client. - Putting stack traces or internal ids in an error body.
- Idempotency keys stored after the work is done (race), kept forever, or shared across users.
- Retrying
POSTautomatically in the client without an idempotency key.
Interview questions
What is tRPC, and how does it give type safety without code generation? (Must know)
The server exports the type of its router. The client imports only that type, and TypeScript infers every procedure's input and output from the server code. There is no schema file and no generator. I ran it: a wrong input type, a misspelled output field and a missing procedure all failed in tsc. The Zod schema in .input() also validates at run time, because types disappear after compile.
When would you pick tRPC, and when not? (Must know)
I pick it when one TypeScript team owns the client and server and they deploy together, such as a Next.js app with its own backend. I do not pick it for a public or partner API, for non-TypeScript clients, or when old native app versions stay alive for months. There the contract must be written down and versioned, and that is OpenAPI.
What does an OpenAPI document give you? (Must know)
A language-neutral contract: every path, method, input, output and error. From it you get docs, generated client types, mock servers and tests. In my run, openapi-typescript made types from the file, and openapi-fetch used them so a wrong path or a wrong parameter type failed in the compiler.
How do you stop the OpenAPI file from drifting from the code? (Senior signal)
Two moves. First, have one source of truth: either generate the document from the code's schemas (Zod 4 has z.toJSONSchema; trpc-to-openapi and the framework packages generate it from routes), or write the document first and generate types and stubs from it. Second, add a contract test in CI that calls the running server and checks real responses against the document. In my check, a renamed field failed with "data must have required property 'name'".
Code-first or design-first? (Senior signal)
Code-first is faster for one team and cannot drift if you generate the file, but the API shape is whatever the code does, and reviewers may not read the generated diff. Design-first makes the contract a reviewed artifact before work starts, and lets front-end and back-end work in parallel, but you need discipline to keep the code matching it. For a public API I lean design-first (or code-first with a mandatory document diff in review). For an internal API, code-first.
What is a breaking change? Give examples that surprise people. (Must know)
A change that makes a correct old client fail. Obvious: remove or rename a field. Surprising ones: adding a required request field, making a response field nullable, tightening validation, adding a value to a response enum (an exhaustive switch in a client breaks), or changing an error type. The hardest is changing what a field means while its name and type stay the same, because no diff tool sees it.
How do you remove a field safely? (Senior signal)
Expand, then contract. Keep the old field, add the new one, mark the old one deprecated: true in the document, send Deprecation and Sunset headers on responses that still use it, and measure which clients still read it. When usage reaches zero (or the sunset date passes after proper notice), remove it. The Sunset date must not be earlier than the Deprecation date.
Do you version a tRPC API? (Senior signal)
Usually not with a version number. I make additive changes, keep old procedures for a while, and rely on the shared build to catch breaks at compile time. This only protects clients built from the same repository state. Any old deployed client, such as a cached browser bundle or an old mobile build, still calls the old shape. If a real public contract is needed, I expose a stable REST layer on top and version that.
What is RFC 9457 and why use it? (Must know)
It is the standard "problem details" error format, application/problem+json, with type, title, status, detail and instance, plus extension members. It obsoletes RFC 7807. Using it means every endpoint returns the same shape, clients can switch on type, and standard tools understand it. Clients should not parse detail; I add an extension such as errors for field-level data.
What should go in type, and what should not go in detail? (Trap)
type is a stable URI that names the kind of problem. It is the identifier clients use. detail is human text for this one occurrence, and it can change, so clients must not parse it. I never put stack traces, SQL, file paths or hints about whether an account exists in detail. I log those and send a trace id.
Explain idempotency keys. (Must know)
The client creates a random key per action and sends it in Idempotency-Key, the same on retries. The server stores the key with a hash of the request and the response. A repeat with the same body returns the stored response. A repeat with a different body is rejected (422 in the IETF draft). A repeat while the first is running gets 409. A missing key on an endpoint that requires one gets 400. I ran that flow: two identical POSTs created one user.
What is the weakness in a simple idempotency implementation? (Trap)
A check-then-act race. If I check the store, do the work, then save the result, two simultaneous requests can both pass the check. I reserve the key first with an atomic operation (a unique index and INSERT ... ON CONFLICT DO NOTHING, or Redis SET NX), mark it in progress, and save the response at the end. I also scope keys per user and expire them after a period I document.
Follow-up: "Which methods need a key?" PUT and DELETE are idempotent by definition, so retrying is safe. POST is not, so it is the one that needs a key. Some PATCH operations are safe and some are not, so I decide per endpoint.
What is contract testing? What are the two styles? (Must know)
It checks that the provider and the consumer agree. The provider style validates real responses against the OpenAPI schemas (I did this with Ajv in a short script). The consumer-driven style (Pact) lets each client publish what it uses, and the provider's build verifies it, so I know which client a change would break. The first catches drift; the second catches "a field we thought nobody used".
Your generated client type says data is always there, but production sometimes returns undefined. Why? (How would you debug)
Generated types come from the document, not from the running server. First I check the real response against the schema (a contract test would have caught this). Then I check whether an error response (the 404 or 500 shape) is being read as success, since openapi-fetch types data as possibly undefined and forces me to handle error. Last, I check proxies and gateways that return their own HTML error pages that are not in the document.
How do you expose a tRPC procedure as a public REST endpoint? (Senior signal)
With trpc-to-openapi: add its OpenApiMeta to initTRPC, put .meta({ openapi: { method, path } }) on chosen procedures, and call generateOpenApiDocument. Path and query values arrive as strings, so I use z.coerce for numbers. I set protect explicitly, because the README and the version 3.3.0 code disagree on its default. I only expose procedures I am ready to keep stable, since that endpoint is now a public promise.
REST, GraphQL or tRPC for a new product with a web app and a mobile app? (Senior signal)
It depends on who the callers are and how fast I can ship. If the mobile app is native and not TypeScript, tRPC loses most of its value, so I would pick REST with OpenAPI (clients generated in each language) or GraphQL if screens need very different shapes of data. If the mobile app is React Native in the same monorepo, tRPC is a good fit. I would also ask about public partners, caching needs, and how long old app versions stay installed.
Does tRPC use REST under the hood? (Trap)
It uses HTTP, but not REST design. A query is a GET with the input in the query string, a mutation is a POST, and the paths are procedure names, not resources. The errors have tRPC's own shape. The docs say to think in functions and ignore HTTP verbs. That is why it is not a good public API on its own.
How would you review a pull request that changes an API response? (Senior signal)
I check the document diff first. Is a field removed, renamed, retyped, made nullable, or is an enum changed? Is a request field newly required or validation tighter? Does an error type or status change? If it is breaking, I ask for the expand-then-contract plan, deprecation headers and a usage measurement. I also check that the contract test and the generated client types were updated, and that errors still use the problem-details shape without internal details.
Key takeaways
- tRPC: types flow from the server router to the client with no schema file. It is excellent when one TypeScript team owns both sides, and weak as a public API.
- OpenAPI: a written, language-neutral contract. Generate it from your Zod schemas (or write it first) so there is one source of truth, then generate client types from it.
- Generated types only match the document. Add a contract test in CI so the running server cannot drift unnoticed.
- Adding optional things is safe. Removing, renaming, retyping, making required, making nullable and tightening validation are breaking. Expand, measure, then contract, and announce with
DeprecationandSunset. - Use RFC 9457 problem details for REST errors. Use
typeas the identifier, add extension members for data, and keep internals out ofdetail. POSTretries need idempotency keys. Reserve the key atomically, scope it per user, and expire it.- Choose by caller: your own TypeScript app, tRPC; outsiders, REST + OpenAPI; flexible data shapes, GraphQL. Mixing them is normal.
What's next
Next, Security in depth for full-stack developers covers the attacks that hit APIs like these: SSRF, injection, unsafe input and weak password storage. For auth on these endpoints, see Lesson 22.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Documenting APIs: OpenAPI, Contracts and Testing · API Design
- REST API Design Best Practices: Resources, Versioning, Status Codes · Backend in Depth
Related series