Keeping frontend and backend from ever drifting apart
Statically typed frontends often trust handwritten interfaces blindly while the API underneath keeps evolving. TypeScript types generated from a single OpenAPI specification, combined with runtime validation through zod, close exactly that gap and keep backend and headless frontend in sync for good across Magento and PWA projects.
Table of Contents
- 1. Why "the frontend just trusts the interface" is a silent risk
- 2. One single source of truth: shared types instead of duplicate maintenance
- 3. Generating types from the OpenAPI specification
- 4. The typed fetch wrapper as the boundary between API and UI
- 5. Runtime validation with zod: the safety net against type drift
- 6. Codegen in the CI pipeline: keeping types current on every schema change
- 7. Versioning strategies for breaking changes
- 8. Contract testing: verifying the contract between backend and frontend
- 9. Compile-time types versus runtime validation compared side by side
- 10. Summary
- 11. FAQ
1. Why "the frontend just trusts the interface" is a silent risk
In most headless Magento and PWA projects, a developer writes a TypeScript interface for an API response once and then trusts it for years afterward. The problem: TypeScript only checks whether your own code is internally consistent, never whether a server's actual response at runtime matches the claimed type. If a field on the Magento GraphQL or REST layer changes, say an optional attribute suddenly returns null instead of an empty string, the compiler notices nothing. The frontend keeps building green until a user sees a broken page.
This exact divergence is called type drift: the static type in the frontend and the actual shape of the data at runtime gradually move apart without a build ever failing. In classic server-rendered Magento themes this rarely surfaces, because PHP and templates are tightly coupled. But as soon as a separate headless frontend talks to an API, a real trust boundary forms between two independently deployable systems, and at exactly that boundary a handwritten assumption is no longer enough.
2. One single source of truth: shared types instead of duplicate maintenance
The sustainable fix for type drift is not more discipline, it's a single source of truth from which all types are derived. Instead of a PHP developer defining a DTO in the backend while a frontend developer independently rebuilds a matching interface, the data shape gets described exactly once, usually as an OpenAPI or JSON Schema specification living right next to the API layer. Tools like openapi-typescript generate TypeScript types from that specification automatically, reflecting exactly what the API promises to return under contract.
In a monorepo this principle can be pushed further: a dedicated shared-types package gets imported by both the API layer's TypeScript client and the headless frontend, so a single build step keeps both sides in sync. Across multiple repositories, a published npm package with generated types fills the same role. In either case, the same principle applies: types are never handwritten twice, they're always derived from the same machine-readable description.
3. Generating types from the OpenAPI specification
openapi-typescript reads an OpenAPI 3 specification and generates pure TypeScript type definitions with no runtime code, which keeps the tool lightweight and well suited to CI environments. Every endpoint, every schema object, and every response code gets translated into a nested paths interface, from which individual domain types can be extracted using helper generics like components["schemas"]["Product"]. The command runs locally, in a pre-commit hook, or as its own npm script entry, and needs no running server instance as long as the specification lives as a YAML or JSON file in the repository.
For a Magento-adjacent API layer, that means concretely: a PHP backend that documents its REST or GraphQL endpoints via an OpenAPI file, whether generated from attributes or maintained by hand, automatically provides the foundation for exactly matching frontend types. What matters is treating the specification as a living artifact that gets updated with every endpoint change, not as documentation written once and stale after the first release.
{
"openapi": "3.0.3",
"info": { "title": "Mironsoft Commerce API", "version": "1.2.0" },
"paths": {
"/products/{sku}": {
"get": {
"operationId": "getProductBySku",
"parameters": [
{ "name": "sku", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "Product found",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Product" }
}
}
},
"404": { "description": "Product not found" }
}
}
}
},
"components": {
"schemas": {
"Product": {
"type": "object",
"required": ["sku", "name", "price", "inStock"],
"properties": {
"sku": { "type": "string" },
"name": { "type": "string" },
"price": { "type": "number" },
"inStock": { "type": "boolean" },
"description": { "type": "string", "nullable": true }
}
}
}
}
}
4. The typed fetch wrapper as the boundary between API and UI
A typed fetch wrapper bundles every network call behind a single function that uses the generated OpenAPI types as generic parameters, tying path, method, and response type together in one call. Instead of calling fetch separately in every component and asserting the result with as ProductResponse, which TypeScript accepts unchecked, the wrapper centralizes base URL, headers, error handling, and type inference in one place. Libraries like openapi-fetch build exactly on this idea and derive the return type directly from the path and HTTP method, with no manual type annotation needed at the call site.
The decisive advantage over a classic, handwritten API client: if a response field changes in the OpenAPI specification, the TypeScript compiler fails at every place still using the old field, as soon as the types have been regenerated. That turns a silent runtime surprise into a visible build error, long before the code reaches production. For a headless Magento frontend, that means breaking changes in the product catalog endpoint no longer surface first with a customer, but at the next local typecheck.
// lib/api-client.ts - typed fetch wrapper built on generated OpenAPI types
import type { paths } from "../generated/api";
type ProductResponse =
paths["/products/{sku}"]["get"]["responses"]["200"]["content"]["application/json"];
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, init);
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
// Type-only assertion: still needs a runtime check, see next section
return (await response.json()) as T;
}
export async function getProductBySku(sku: string): Promise<ProductResponse> {
return apiFetch<ProductResponse>(`/products/${encodeURIComponent(sku)}`);
}
5. Runtime validation with zod: the safety net against type drift
As useful as generated types are, they only solve half the job: TypeScript types exist purely at compile time and get stripped away entirely during the build, so they can never prevent a server from actually returning something different at runtime than the specification promises. That's exactly where a library like zod steps in: it defines schemas that both infer a TypeScript type and perform an actual runtime check of the incoming data, complete with a meaningful error message on mismatch.
The pattern in practice: the typed fetch wrapper calls schema.parse(response), or the non-throwing schema.safeParse(response), after every response, before the data ever reaches a UI component. If the actual API response deviates from the expected shape, say a field is missing or a backend deployment skipped a migration, validation fails immediately and in a controlled way, instead of undefined surfacing deep in component logic as a cryptic rendering error. For critical endpoints like checkout or price calculation, this safety net is not optional, it's mandatory.
// lib/schemas/product.ts - zod schema mirrors the OpenAPI "Product" component
import { z } from "zod";
export const productSchema = z.object({
sku: z.string(),
name: z.string(),
price: z.number(),
inStock: z.boolean(),
description: z.string().nullable().optional(),
});
// Type inferred from the schema, used by the rest of the app
export type Product = z.infer<typeof productSchema>;
export async function getProductBySku(sku: string): Promise<Product> {
const raw = await apiFetch<unknown>(`/products/${encodeURIComponent(sku)}`);
// Runtime check at the API boundary: throws with a readable error on drift
const result = productSchema.safeParse(raw);
if (!result.success) {
throw new TypeDriftError("Product", result.error.issues);
}
return result.data;
}
6. Codegen in the CI pipeline: keeping types current on every schema change
Manually generated types rot just like manually maintained interfaces, only with a delay: someone has to remember to rerun the codegen command after a backend change. The reliable path is wiring type generation firmly into the CI pipeline, either as a step that automatically generates and commits the latest types on every merge into the API branch, or as a check step that compares the generated output against the checked-in version and fails the build on any mismatch.
The latter pattern, a so-called diff check, effectively prevents anyone from changing the OpenAPI specification without regenerating and checking in the resulting types. In a monorepo with a separate shared-types package, this step can be tied directly to the specification file: when openapi.yaml changes, codegen runs automatically, and the frontend's subsequent typecheck immediately surfaces which components are affected by the change. That shrinks the reaction time to a breaking change from weeks to minutes.
#!/usr/bin/env bash
# ci/check-api-types.sh - Fail the build if generated types are out of sync
set -euo pipefail
SPEC_FILE="api/openapi.yaml"
OUTPUT_FILE="packages/shared-types/src/generated/api.ts"
# Generate fresh types from the current OpenAPI spec
npx openapi-typescript "$SPEC_FILE" --output "$OUTPUT_FILE.new"
# Compare against the checked-in version
if ! diff -q "$OUTPUT_FILE" "$OUTPUT_FILE.new" > /dev/null; then
echo "[ERROR] Generated API types are outdated. Run 'npm run generate:types' and commit." >&2
rm -f "$OUTPUT_FILE.new"
exit 1
fi
rm -f "$OUTPUT_FILE.new"
echo "[OK] API types are in sync with $SPEC_FILE"
7. Versioning strategies for breaking changes
Not every API change should be passed through automatically, even when codegen and runtime validation are technically ready for it. Additive changes like a new optional field are uncritical and can ship without coordination. Breaking changes, such as a renamed required field or a changed data type, need a deliberate versioning strategy instead, so existing frontend deployments don't suddenly break while a new release is still pending.
URL-based versions like /api/v2/products alongside the existing /api/v1/products route have proven effective, as long as both versions are supported in parallel, together with clearly communicated deprecation windows using sunset headers that signal a firm expiry date to the frontend team. The generated types reflect this versioning directly when the OpenAPI specification is maintained separately per version: components["schemas"] from openapi-v1.yaml and openapi-v2.yaml deliberately produce different, incompatible TypeScript types, so an accidental version mix-up becomes visible immediately as a type error, instead of silently delivering wrong fields at runtime.
8. Contract testing: verifying the contract between backend and frontend
Generated types and runtime validation protect the frontend, but they say nothing about whether the backend actually honors its own contract. Contract testing closes that final gap by automatically checking real or recorded API responses against the zod schema or the OpenAPI specification, as its own test run in the CI pipeline, independent of manual testing in the browser. Such a test fails the moment the backend returns a response that deviates from the documented shape, whether deliberately or through a bug.
In practice, a simple integration test is often enough: it calls a real endpoint against a staging environment and pipes the response through the same zod schema the fetch wrapper uses at runtime, guaranteeing that test and production code run through the exact same check. For more complex scenarios with multiple consumers of the same API, consumer-driven contract tools like Pact offer a structured alternative, where each frontend team records its expectations as a contract and the backend automatically verifies that contract before every release.
// tests/contract/product.contract.test.ts - verifies the real API against the shared schema
import { describe, it, expect } from "vitest";
import { productSchema } from "../../lib/schemas/product";
describe("Product API contract", () => {
it("matches the schema shared with the frontend", async () => {
const response = await fetch(`${STAGING_API_URL}/products/MS-1234`);
const body = await response.json();
const result = productSchema.safeParse(body);
// Fails the CI build if the backend response no longer matches the contract
expect(result.success, JSON.stringify(result.success ? null : result.error.issues)).toBe(true);
});
});
9. Compile-time types versus runtime validation compared side by side
Compile-time types and runtime validation solve different parts of the same problem and should never be treated as substitutes for one another. The table below shows exactly which guarantee each mechanism actually provides.
| Task | Without runtime validation (risk) | With codegen + zod (recommended) | Effect |
|---|---|---|---|
| Maintaining types for API responses | Rebuilding an interface by hand in the frontend | Generating types from OpenAPI (openapi-typescript) | No duplicate maintenance, one contract |
| Checking the response at runtime | No check, data asserted as typed unchecked | zod.safeParse() at the API boundary | Type drift becomes visible immediately |
| Keeping types current | Regenerating manually after backend changes | Codegen in CI on every schema change | No stale type left in the codebase |
| Rolling out a breaking change | Changing the same endpoint silently | Versioned route (/v2/) with a deprecation window | Existing clients don't break |
| Trust between backend and frontend | Frontend trusts the documentation blindly | Contract test checks a real response against the schema | Deviations surface in CI, not with the customer |
In practice, these measures reinforce each other: generated types catch most mistakes while the code is being written, zod catches the rest at runtime, and contract tests make sure backend and frontend actually honor the same contract, not just on paper.
Mironsoft
TypeScript architecture, API design, and headless integrations for Magento stores
Ready to build type safety from API to UI?
We set up OpenAPI-based codegen pipelines, connect them with zod runtime validation, and make sure your headless frontend never silently drifts away from the real API again.
OpenAPI codegen setup
Generate types automatically from your API specification, enforced in CI
Runtime validation
zod schemas at critical API boundaries like checkout and price calculation
Contract testing
Automated contracts between backend and headless frontend
10. Summary
End-to-end type safety from API to UI addresses a problem TypeScript alone cannot solve: static types end at the compiler, while real data only arrives at runtime. Types generated from an OpenAPI specification replace handwritten interfaces with a single, machine-readable contract, consistently used by a typed fetch wrapper. zod closes the remaining gap by checking that exact contract at runtime instead of trusting it blindly.
The decisive lever isn't a single tool, it's how they work together: codegen in the CI pipeline keeps types current, versioning strategies protect existing clients from breaking changes, and contract tests make sure backend and frontend actually honor the same contract. Combining these four building blocks turns type drift from a silent production risk into a visible, early-caught build or test failure.
End-to-End Type Safety From API to UI - The Essentials at a Glance
One source of truth
OpenAPI specification as the sole origin for generated TypeScript types, no manually rebuilt interfaces.
Runtime validation
zod checks every API response at the boundary to the UI and catches type drift the compiler can't see.
CI codegen
Types are automatically regenerated on every schema change, the build fails on any mismatch.
Versioning & contract tests
Roll out breaking changes through versioned routes, verify contracts automatically against real responses.