End-to-end type safety with TypeScript: from API to UI
How to carry your type contract from the backend API to the frontend interface without breaking it — and the practical approaches to get there.
TypeScript is typically something you add to the frontend. The backend lives elsewhere — PHP, Go, Node — with its own type system. The frontend is developed in TypeScript, but API responses get typed as any, because redefining the structures the backend returns feels like too much work.
The result: both sides have a type system, but there is no bridge between them. When the backend model changes, the frontend compiles, runs, and then blows up at runtime. The type system is not helping you there.
End-to-end type safety is about building that bridge.
The root of the problem: the API boundary
When the frontend makes an API call, the result is usually met with any or a weak type. Like this:
// Weak type — TypeScript stays silent if the API changes
const response = await fetch('/api/orders');
const data: any = await response.json();
console.log(data.items); // runtime error if items doesn't exist
Even if you define the type by hand, the problem persists:
interface Order {
id: number;
status: string;
total: number;
}
const response = await fetch('/api/orders');
const data: Order[] = await response.json();
This is just an assertion. TypeScript believes it at compile time — but if the API actually returns a different shape, you will not get a compile error.
Shared type packages
In a monorepo or a tightly coupled project structure, the cleanest solution is to share type definitions. The types live in a separate package, and both the backend and frontend import from it.
For a Node.js backend this works naturally. When you are using PHP or another language, you either need to auto-generate types from an API contract or maintain manual synchronization.
// packages/types/src/order.ts
export interface Order {
id: number;
status: 'pending' | 'completed' | 'cancelled';
items: OrderItem[];
total: number;
createdAt: string;
}
export interface OrderItem {
productId: number;
name: string;
quantity: number;
unitPrice: number;
}
Both the API layer and the UI layer consume this package. When a field changes, every usage on both sides produces a compile error.
Generating types from OpenAPI
When the backend is written in PHP or Go, a shared package does not work directly. But if you have an OpenAPI specification, you can generate TypeScript types from it.
Tools like openapi-typescript read the spec file and produce TypeScript definitions:
npx openapi-typescript ./openapi.yaml --output ./src/types/api.ts
You never edit the generated file by hand — you regenerate it with every release. This approach can be plugged into your build pipeline so that when the backend contract changes, the frontend types update automatically.
Runtime validation with Zod
TypeScript types operate at compile time; they give you no guarantee at runtime. To actually validate data coming from an API, you need runtime validation.
Zod is the widely used library for this. You define a schema, parse the data against it, and malformed data throws an exception:
import { z } from 'zod';
const OrderSchema = z.object({
id: z.number(),
status: z.enum(['pending', 'completed', 'cancelled']),
total: z.number().positive(),
createdAt: z.string(),
});
type Order = z.infer<typeof OrderSchema>;
async function fetchOrder(id: number): Promise<Order> {
const response = await fetch(`/api/orders/${id}`);
const raw = await response.json();
return OrderSchema.parse(raw); // throws ZodError if invalid
}
z.infer<typeof OrderSchema> derives the type automatically — you do not need to write a separate interface. The schema and the type are always in sync.
What happens when the cross-layer type contract breaks
Once this setup is in place, when the type contract breaks you get a compile error, a test failure, or a parse error — one of these fires before it ever reaches the user.
If the backend starts returning total as a string instead of a number, Zod throws at parse time and that error gets logged. With a hand-written interface Order, the same situation blows up at runtime.
The difference: in the first scenario the developer sees something is broken — and why. In the second, the user does.
How much to invest
End-to-end type safety is a spectrum. Wiring everything up completely is a large investment; wiring up nothing at all is a serious maintenance burden.
A middle ground is sufficient for most projects: Zod validation for critical API endpoints, type generation if you have an OpenAPI schema, and a separate definition file for shared types. Applied together, these three eliminate the vast majority of surprises at the API boundary.
Everything does not need to be perfectly connected. Knowing where the contract lives, knowing where it can break, and adding validation at the critical points — that is a good enough starting point.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.