End-to-End Type Safety with openapi-typescript

Photo by Dsimic via Wikimedia Commons (CC BY-SA 4.0)
openapi-typescript is a build-time tool that reads an OpenAPI spec and generates a TypeScript types file with zero runtime code. openapi-fetch is the runtime client, about six kilobytes, that consumes those generated types to give you a fully typed wrapper around native fetch. You use them together: one produces the types, the other enforces them at every call site.
No. openapi-fetch is roughly six kilobytes and is a thin wrapper over the native fetch API with effectively zero runtime overhead. The types produced by openapi-typescript are compile-time only and ship nothing to the browser, so the total runtime cost is just the small client itself.
No. Generated types describe the contract in the spec, not the actual bytes the server sends. If the server returns malformed data or the spec is wrong, TypeScript still trusts the types. For untrusted or critical endpoints, validate responses at runtime with a library such as Zod in addition to the generated types.
Regenerate the types in CI and fail the build if the committed file differs from the freshly generated one. A git diff with a non-zero exit on any change turns every silent backend contract change into a blocking pull-request failure, which is what keeps the frontend and backend genuinely in sync.
Yes. openapi-typescript accepts a URL, not just a local file, so you can point it at the JSON document served by @nestjs/swagger, for example at /api-json. This removes any manual export step and lets CI regenerate straight from the live spec the backend already publishes.

Photo by Dsimic via Wikimedia Commons (CC BY-SA 4.0)
Key Takeaway
Point openapi-typescript at your OpenAPI spec to generate a types file, then feed those types to openapi-fetch's createClient for a fully typed HTTP client. Every path, query param, request body, and response is checked by the compiler, so a backend change that breaks the contract fails your build instead of production.
Every fullstack TypeScript team hits the same wall eventually: the backend renames a field, ships it, and the frontend keeps sending the old shape because nobody updated the hand-written interface. The compiler is green, the tests pass, and the bug only surfaces when a real user loads the page. I have chased this class of failure more times than I want to admit, and the fix is not more discipline. It is deleting the hand-written types entirely and generating them from the one artifact both sides already agree on: the OpenAPI spec.
This post walks through the pairing I reach for on every Next.js frontend that talks to a NestJS backend: openapi-typescript to turn the spec into a types file, and openapi-fetch to wrap those types in a tiny, runtime-free client. The result is end-to-end type safety with no generics to write and no codegen framework to babysit.
A hand-maintained TypeScript interface for an API response is a copy. The moment you write it, it starts diverging from the source of truth on the server. There is no mechanism forcing the two to stay equal, so drift is not a risk, it is the default state over time. The compiler cannot help you here because it is validating your code against your own stale copy, not against reality.
Generated types flip the relationship. Instead of a copy you maintain, you have a projection of the spec that you regenerate. When the backend changes the contract and republishes the spec, regenerating produces a different types file, and every call site that no longer matches turns red. That red is the whole point: the mismatch moves from runtime, where a user finds it, to compile time, where you find it.
| Aspect | Hand-written interfaces | Generated from OpenAPI |
|---|---|---|
| Source of truth | A copy in the frontend repo | The spec the backend publishes |
| Drift over time | Guaranteed, silently | Caught on regenerate |
| Breaking change surfaces | At runtime, in production | At compile time, in CI |
| Maintenance cost | Manual edits per change | One regenerate command |
openapi-typescript is a build-time only tool. It reads an OpenAPI 3.x document, from a local file or a URL, and emits a single .d.ts file. It produces zero runtime code, so nothing it generates ships in your bundle. As of writing, the current line is version 7.x, and it expects TypeScript 5.0 or newer for the best results.
# Install both packages
npm i -D openapi-typescript typescript
npm i openapi-fetch
# Generate types from a local spec file
npx openapi-typescript ./openapi.yaml -o ./src/lib/api/schema.d.ts
# Or straight from a running backend that serves its spec
npx openapi-typescript http://localhost:3000/api-json -o ./src/lib/api/schema.d.tsThe emitted file exports a paths interface describing every route, plus components for the shared schemas. You never write these by hand and you rarely read them directly. They exist to feed the client in the next step. If your backend is NestJS, the @nestjs/swagger module can serve exactly the JSON document that the second command above consumes, which closes the loop without any manual export step.
openapi-fetch is the runtime half, and it is deliberately tiny, roughly six kilobytes with effectively zero overhead over the native fetch it wraps. You call createClient with the generated paths type as a type argument, and from that point on the client knows every route your API exposes. There are no per-call generics to write and no manual typing. The path string itself drives inference for params, body, and response.
// src/lib/api/client.ts
import createClient from "openapi-fetch";
import type { paths } from "./schema";
export const api = createClient<paths>({
baseUrl: "https://api.example.com/v1/",
});
// A GET with a path param and a query param — all typed from the spec
const { data, error } = await api.GET("/blogposts/{post_id}", {
params: {
path: { post_id: "my-post" },
query: { version: 2 },
},
});
// A POST whose body is checked field-by-field
await api.POST("/blogposts", {
body: {
title: "My New Post",
// omitting a required field, or using the wrong type, fails tsc
},
});Autocomplete the first argument and you get the list of real routes. Get the path param name wrong, forget a required query field, or send a number where the spec wants a string, and the compiler rejects it before the code runs. This is the payoff of inference over generics: the ergonomics stay identical to plain fetch, but the guarantees are total.
Every call returns an object with data, error, and response. The data property is populated only on a 2xx status; error holds the typed error body for 4xx and 5xx responses, shaped exactly as your spec declares them; and response is the raw Response for headers and status. Because data and error form a discriminated union, a truthiness check narrows the type, and the compiler forces you to handle the failure path before you can touch the success payload.
const { data, error } = await api.GET("/blogposts/{post_id}", {
params: { path: { post_id: id } },
});
if (error) {
// error is the typed error schema here — data is undefined
console.error(error);
return;
}
// past the guard, data is defined and fully typed
return data.title;Reach for middleware instead of re-writing headers at every call site. openapi-fetch supports an onRequest / onResponse middleware hook, which is the clean place to attach auth tokens, inject a request id, or centralize logging. It keeps the call sites focused on the data and the auth concern in exactly one file.
Generation only protects you if it actually runs. The failure mode I guard against is a stale schema.d.ts committed months ago while the API moved on. My rule on the VPS-deployed services I run is simple: the spec is regenerated in CI, and if the regenerated file differs from what is committed, the build fails. That single check converts every silent backend change into a loud, blocking signal on the pull request.
# package.json scripts
# "gen:api": "openapi-typescript http://localhost:3000/api-json -o ./src/lib/api/schema.d.ts"
# "check:types": "tsc --noEmit"
# CI step: regenerate, then fail if the committed file drifted
npm run gen:api
git diff --exit-code src/lib/api/schema.d.ts \
|| (echo "API types are stale — run npm run gen:api and commit" && exit 1)
# Then the usual gate
npm run check:typesGenerated types describe the contract, not the runtime payload. If the server sends malformed JSON or the spec lies, TypeScript still trusts the types and you get an object that claims to be valid but is not. For untrusted or critical boundaries, validate responses at runtime with a schema library such as Zod. Generated types stop drift at compile time; they do not verify what actually arrives over the wire.
If you own or can obtain an accurate OpenAPI spec, this is close to the lowest-effort, highest-return setup available in the TypeScript ecosystem. It shines when you want plain fetch semantics with no framework lock-in and a bundle you can measure in kilobytes. If instead you want generated TanStack Query hooks or a fuller SDK with methods per operation, a heavier generator like Orval is a better fit. But for the common case, a frontend calling a documented backend, generated types plus a thin typed client removes an entire category of bug for the cost of one CI step.