A backend engineer renames user_id to userId in a response body. The OpenAPI spec changes. The PR passes CI. Three weeks later, a frontend feature silently renders undefined in production, and someone spends a day bisecting a deploy that had nothing to do with it. This is not a rare failure mode — it's the default one, because the contract between your services lives in a YAML file that nothing actually enforces.
The fix isn't discipline. It's making the compiler read the spec. If openapi-typescript generates a .d.ts from your spec on every build, a renamed field becomes a TypeScript error on the exact line that consumes it, in the PR that caused it, before merge. That's the entire pitch. The rest of this article is how to wire it up without creating a build system that everyone hates.
The shape of the problem
You have three artifacts that drift independently:
- The actual HTTP responses your server sends.
- The OpenAPI spec that's supposed to describe them.
- The TypeScript types your frontend believes.
Most teams keep (1) and (2) roughly in sync by convention and let (3) be hand-written. Hand-written types are the failure point — they're written once, from a spec that was accurate on a Tuesday, and never revisited. The compiler can't help you because it has no idea what the truth is.
There are two ways to close the gap: generate types from the spec (spec is the source of truth), or generate the spec from the code (code is the source of truth). Both work. This article covers the first, because it's the one that scales to polyglot backends and lets you diff the contract in code review.
Generating types with openapi-typescript
openapi-typescript emits pure .d.ts — no runtime, no generated client class, no dependency in your bundle. That's the key design choice: you get types, you bring your own fetch wrapper.
npm install -D openapi-typescript
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.d.ts
The output looks like this (abridged):
export interface paths {
"/users/{id}": {
get: operations["getUser"];
};
}
export interface components {
schemas: {
User: {
id: string;
email: string;
created_at: string; // ISO 8601
};
};
}
export interface operations {
getUser: {
parameters: {
path: { id: string };
};
responses: {
200: {
content: {
"application/json": components["schemas"]["User"];
};
};
404: {
content: {
"application/json": { message: string };
};
};
};
};
}
Now you write a thin typed client. The why comments matter here — this is the code that makes the generated types actually usable:
import createClient from "openapi-fetch";
import type { paths } from "./schema";
// openapi-fetch is the runtime companion to openapi-typescript.
// It reads the generated `paths` type to constrain `client.GET` etc.
const client = createClient<paths>({ baseUrl: process.env.API_URL });
async function getUser(id: string) {
const { data, error, response } = await client.GET("/users/{id}", {
params: { path: { id } },
});
// openapi-fetch narrows `data` vs `error` off the status code.
// If the spec says 404 returns {message: string}, `error` is typed
// as that shape here — no `as` cast, no manual discriminated union.
if (error) {
throw new Error(`getUser failed: ${response.status} ${error.message}`);
}
return data; // typed as components["schemas"]["User"]
}
The important line is client.GET("/users/{id}", ...). If someone deletes that path from the spec, this call fails to compile. If they rename id to userId in the path params, this fails to compile. If they change the 200 response from User to UserV2 missing the email field, every consumer of data.email fails to compile. That's the whole value proposition, and it costs you one dev dependency.
Catching breaking changes in CI
Generation alone doesn't stop drift — you need to fail the build when the spec changes incompatibly. oasdiff does structural diffing between two specs:
# In CI, compare the spec on this branch against main.
# --fail-on ERR exits non-zero for breaking changes only.
oasdiff breaking \
--base origin/main:openapi.yaml \
--revision ./openapi.yaml \
--fail-on ERR
Run that as a required check. Now a PR that removes a required field, narrows an enum, or makes an optional param required gets blocked at review time, not at runtime. oasdiff classifies changes as ERR (breaking), WARN, or INFO — tune --fail-on to your tolerance. The oasdiff docs list the full rule set; the enum-narrowing rule alone catches a class of bug that's nearly impossible to catch in test suites.
The second CI check regenerates types and fails if the committed .d.ts differs from what the current spec produces:
npx openapi-typescript ./openapi.yaml -o ./src/api/schema.d.ts
# --exit-code makes git diff non-zero if anything changed.
git diff --exit-code ./src/api/schema.d.ts || {
echo "schema.d.ts is stale. Run 'npm run codegen' and commit."
exit 1
}
This forces the generated file into git. Some teams hate that — it's a large diff on every spec change. The alternative (generate in a prebuild step, gitignore it) means a broken backend spec breaks your build with no obvious cause. Committing it keeps the contract change visible in the PR. Pick your poison.
Where this breaks down
The spec lies. If your server doesn't actually validate against the OpenAPI spec at runtime, the spec is aspirational and your types are fiction. The only durable fix is to generate the spec from your server code (FastAPI, tRPC, Hono, zod-to-openapi) or validate responses against it in tests. Type-checking against a wrong spec is worse than no type-checking, because it gives false confidence. Use express-openapi-validator or your framework's equivalent to enforce the contract at runtime, ideally in staging.
Large specs produce large types. A 5,000-line spec yields a .d.ts that TypeScript struggles to check. Generation takes seconds; tsc on a project that imports the whole schema can take a minute. Mitigate by generating per-tag or per-service slices, or by using --immutable-types to avoid deeply nested mapped types. If your spec is huge, budget for it.
anyOf/oneOf don't map cleanly. OpenAPI's composition keywords become TypeScript unions, which is usually fine, but allOf inheritance and additionalProperties interact badly with the type system. You'll occasionally get Record<string, unknown> where you wanted a concrete shape. The generated types are honest about ambiguity; your code has to narrow.
Discriminated unions require discriminator. If your spec uses oneOf without a discriminator field, you get a union with no discriminant, and every consumer needs a manual type guard. Fix the spec, not the client.
This is the wrong choice when the API is internal and co-deployed. If your frontend and backend ship from the same monorepo and the same commit, you don't need OpenAPI as an intermediary — use tRPC or a shared Zod schema. OpenAPI earns its keep when the boundary is real: separate teams, separate deploy cadences, third-party consumers, or a polyglot backend where the spec is the only lingua franca.
What it actually buys you
The measurable win is not "we have types." It's that a class of bug — the silent undefined from a renamed field — moves from production to CI. That's worth a dev dependency, a 30-line CI job, and a generated file in git. It is not worth fighting a spec that nobody maintains. Fix the spec pipeline first; the codegen is the easy part.
Top comments (0)