A frontend usually does not fail in production because TypeScript was missing. It fails because TypeScript was looking at the wrong source of truth. The backend changed user_name to displayName, removed a nullable field, or changed a response shape behind a feature flag, while the frontend still compiled successfully against a stale local interface.
API typing between backend and frontend is not only a developer experience problem. It is a delivery safety problem. The real question is not “Which tool gives better types?” The better question is: where does the contract live, how is it validated, and what stops an incompatible change before deployment?
The real failure: local types pretending to be contracts
A common frontend pattern looks harmless:
type UserProfile = {
id: string;
displayName: string;
avatarUrl?: string;
};
const response = await fetch("/api/me");
const user: UserProfile = await response.json();
renderUser(user.displayName);This gives the frontend team autocomplete and compile-time checks, but it does not prove that /api/me returns this shape. The type is local documentation. It is not connected to the backend implementation, not checked in CI against the server response, and not validated at runtime.
The production failure usually happens like this:
Backend changes a field name or nullability.
Backend tests still pass because the server behavior is valid from its side.
Frontend TypeScript still compiles because its interface did not change.
The browser receives a different shape at runtime.
The UI crashes, renders empty data, or sends incorrect data further downstream.
A type that is not generated, validated, or tested against the producer is only a promise. A contract needs enforcement.
Four approaches to API typing
There are several ways to connect backend and frontend types. None is universally correct. The right choice depends on stack alignment, team boundaries, deployment model, and how much runtime validation you need.
Approach | Contract source | Runtime validation | Backend and frontend coupling | CI enforcement | Operational fit |
|---|---|---|---|---|---|
Manual TypeScript interfaces | Frontend code | ✗ unless added separately | Low | Weak by default | Small apps, prototypes, stable APIs |
OpenAPI | API schema | Optional, depending on implementation | Low to Medium | Strong when schema generation and contract tests exist | Multi-team systems, public APIs, mixed stacks |
Zod schemas | Shared or boundary schema | ✓ | Medium | Strong when schemas are shared or tested | TypeScript-heavy apps, validation-first flows |
tRPC | Backend procedure types | Usually input validation, response inferred | High | Strong inside one TypeScript codebase | Full-stack TypeScript, internal APIs |
The decision is less about syntax and more about ownership. A contract that nobody owns becomes stale. A generated type that nobody verifies can still drift. A runtime schema that is not used at the actual boundary becomes another layer of documentation.
Manual interfaces: acceptable only with strong discipline
Manual interfaces are the oldest and simplest option. They are also the easiest to misuse.
interface InvoiceDto {
id: string;
totalCents: number;
status: "draft" | "paid" | "void";
}
async function getInvoice(id: string): Promise<InvoiceDto> {
const res = await fetch(`/api/invoices/${id}`);
return res.json();
}This is fine when the API is tiny, the same developer owns both sides, or the interface is temporary. It becomes risky when:
backend and frontend deploy independently,
multiple clients consume the API,
response fields are renamed without migration,
nullability changes are common,
frontend code relies on nested fields without runtime guards.
Manual interfaces can still work if paired with contract tests or response fixtures generated from backend tests. Without that, they create a false sense of safety.
A safer version adds runtime validation at the boundary:
import { z } from "zod";
const InvoiceSchema = z.object({
id: z.string(),
totalCents: z.number().int(),
status: z.enum(["draft", "paid", "void"]),
});
type InvoiceDto = z.infer<typeof InvoiceSchema>;
async function getInvoice(id: string): Promise<InvoiceDto> {
const res = await fetch(`/api/invoices/${id}`);
return InvoiceSchema.parse(await res.json());
}Now the frontend fails at the API boundary instead of failing later inside rendering logic. That is still a failure, but it is localized, observable, and easier to debug.
OpenAPI: strong contract for mixed stacks and external clients
OpenAPI works well when the API is a product boundary, not just an implementation detail. It is especially useful when backend and frontend are in different languages, when mobile clients consume the same API, or when the API must be documented for other teams.
A useful OpenAPI workflow is not “write a YAML file once.” It is a pipeline:
Backend defines or generates the OpenAPI schema.
CI validates the schema.
Frontend generates types and client code from the schema.
Contract tests verify that real responses match the schema.
Breaking changes are reviewed before deployment.
A simplified schema fragment might look like this:
paths:
/api/users/me:
get:
responses:
"200":
description: Current user profile
content:
application/json:
schema:
$ref: "#/components/schemas/UserProfile"
components:
schemas:
UserProfile:
type: object
required:
- id
- displayName
properties:
id:
type: string
displayName:
type: string
avatarUrl:
type: string
nullable: trueFrom this schema, the frontend can generate a TypeScript type instead of inventing one locally. The important part is not only generation. The important part is that the schema becomes part of release control.
OpenAPI’s trade-off is process overhead. Schema quality matters. Generated clients can become noisy. Teams need naming conventions, compatibility rules, and a clear decision on schema-first versus code-first. For larger systems, that overhead is often cheaper than debugging silent contract drift.
Zod: practical validation for TypeScript boundaries
Zod is useful when the team wants the type and the runtime validator to come from the same definition. This is a strong fit for TypeScript services, frontend validation, form handling, and API clients that need to reject invalid payloads explicitly.
The key advantage is that z.infer gives compile-time types from runtime schemas. That removes one common source of mismatch.
const UpdateProfileInput = z.object({
displayName: z.string().min(1).max(80),
avatarUrl: z.string().url().nullable(),
});
type UpdateProfileInput = z.infer<typeof UpdateProfileInput>;For API responses, Zod helps catch backend drift during development, testing, and production. But it should be used intentionally. Parsing every large response on a hot path has CPU cost. For most business APIs this is acceptable, but for high-throughput or large-payload systems, validation scope should be designed carefully.
Zod is strongest when:
the frontend and backend are both TypeScript,
runtime validation is required,
schemas are shared in a controlled package,
API boundaries are internal but still independently deployable.
It is weaker when the backend is not TypeScript, when many non-TypeScript clients exist, or when a formal API description is required for platform governance.
tRPC: high type safety inside a TypeScript application boundary
tRPC is a strong option when backend and frontend are both TypeScript and the API is internal to the application. It removes much of the manual contract work by inferring client types from server procedures.
A typical pattern looks like this:
const userRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
return {
id: ctx.user.id,
displayName: ctx.user.displayName,
avatarUrl: ctx.user.avatarUrl,
};
}),
updateProfile: protectedProcedure
.input(UpdateProfileInput)
.mutation(async ({ input, ctx }) => {
return updateUser(ctx.user.id, input);
}),
});The frontend client can then call me and updateProfile with inferred types. If the backend procedure changes, the frontend type surface changes with it.
This is productive, but it increases coupling. tRPC fits best when frontend and backend are developed as one product, often in one repository or with tightly synchronized packages. It is less suitable when the API must serve mobile apps, external consumers, partner integrations, or teams using different languages.
The production question is simple: do you want the API to be an independent contract, or do you want it to be a typed internal function boundary? tRPC is closer to the second model.
How to prevent the “backend changed a field” incident
A robust API typing strategy has three layers: compile-time types, runtime boundary checks, and release-time enforcement.
1. Make the producer responsible for the contract
The backend should not be allowed to change response shape casually. Whether the source is OpenAPI, Zod, or tRPC procedures, the producer owns the contract.
For REST APIs, that usually means the backend publishes an OpenAPI schema. For TypeScript internal APIs, it may mean shared Zod schemas or tRPC router types. For manual interfaces, it means the backend must provide fixtures or contract tests that the frontend can trust.
2. Generate or infer types, do not copy them
The dangerous pattern is copying a backend DTO into a frontend interface by hand. Prefer generated or inferred types:
OpenAPI schema to TypeScript types
Zod schema to
z.infertRPC router to typed client
backend test fixtures to frontend contract tests
Manual copying should be treated as a temporary escape hatch, not a default architecture.
3. Validate at the boundary where failures are cheaper
Runtime validation is not always required everywhere, but it is valuable at system boundaries. Validate incoming data from APIs, queues, webhooks, and third-party services before the rest of the application trusts it.
In frontend code, this means parsing API responses before passing them into UI state. In backend code, it means validating request bodies before business logic. In CI, it means checking that documented response schemas match real handlers.
4. Treat breaking API changes like migrations
Renaming a field is not a harmless refactor if clients depend on it. A safer rollout looks like this:
Add the new field while keeping the old one.
Update clients to read the new field.
Monitor usage of the old field if possible.
Remove the old field only after dependent clients have moved.
Run contract checks before release.
This is the same mindset teams already use for database migrations. API fields deserve the same discipline.
What to adopt first
For most teams, the practical path is incremental.
If you have a REST API and multiple clients, start with OpenAPI and generated frontend types. Add contract tests for the endpoints most likely to break production flows.
If you have a TypeScript-only product and the API is internal, consider Zod schemas at request and response boundaries. If the backend and frontend are tightly integrated, tRPC can reduce boilerplate and make contract drift harder.
If you currently rely on manual interfaces, do not rewrite everything immediately. Start with the endpoints that affect authentication, billing, checkout, permissions, dashboards, or other high-impact flows. Add validation and generated types there first.
For engineers who work heavily with TypeScript API boundaries, schema inference, and frontend-backend contracts, the Senior TypeScript Developer certification is the most relevant DevCerts page to review.
Conclusion
API typing is not about choosing the most fashionable tool. It is about making incorrect contracts difficult to ship.
Manual interfaces are simple but fragile unless backed by tests. OpenAPI gives a durable contract for REST APIs and mixed stacks. Zod gives TypeScript teams a practical way to align runtime validation with static types. tRPC gives strong inference when the backend and frontend are part of the same TypeScript system.
The safest teams do not rely on memory, screenshots, Slack messages, or copied interfaces. They make the API contract explicit, generate or infer client types from it, validate risky boundaries, and run checks before deployment. That is how you avoid the familiar incident where the backend changed one field and the frontend found out in production.