Codegen: turning your OpenAPI spec into types you actually trust
Part two mentioned two OpenAPI modes: runtime, the zero-setup default, and
codegen, which trades a build step for real compile-time type safety. This
post is codegen mode in full — the four CLI commands, what they generate,
and why I eventually move every project past the runtime-only starting
point.
Runtime mode vs codegen mode, honestly compared
Runtime mode (openapi: { mode: 'runtime' }) fetches your spec at
runtime, purely for optional response validation — no build step, no
generated files, works immediately. Good for day one of a project, or a
prototype where the API is still shifting weekly.
Codegen mode generates actual TypeScript types and a module descriptor map from your spec, ahead of time, as a build step. The payoff: your editor autocompletes every path, every param, every response shape, and a breaking API change becomes a compile error instead of a runtime surprise three weeks later. The cost: you need a codegen step in your workflow, and generated files need regenerating when the spec changes.
I use runtime mode for the first couple weeks of a project, then switch to codegen once the API shape stabilizes enough that “regenerate on spec change” isn’t happening every hour.
The four commands
generate → spec.yaml → types.ts + descriptors.ts (one-time or CI)
watch → spec.yaml → regenerate on every save (local dev)
validate → spec.yaml → pass/fail, is this spec well-formed?
diff → old spec vs new spec → what actually changed?generate — the core command
npx api-client generate --spec ./openapi.yaml --out ./src/generatedThis reads your spec and writes out generated files — types for every
schema, and a descriptor map used by the TanStack Query integration
(covered in a later post) to know what endpoints exist without you manually
declaring useQuery keys for each one.
src/generated/
├── types.ts ← every schema as a TS type
├── descriptors.ts ← module/method metadata for the query integration
└── paths.ts ← typed path constantsProblem this solves: hand-writing types that mirror your backend’s response shapes means they drift the moment the backend changes and nobody remembers to update the frontend types. Generated types come from the spec, so drift becomes a regeneration away from being caught, not a bug report away.
watch — for local development
npx api-client watch --spec ./openapi.yaml --out ./src/generatedRegenerates automatically on save. I run this in a terminal tab alongside my dev server, so if I’m iterating on the backend spec and frontend at the same time, my frontend types update within a second of saving the spec file — no manual regenerate-and-restart cycle breaking my flow.
validate — catching a broken spec before it breaks generation
npx api-client validate --spec ./openapi.yamlProblem: a malformed OpenAPI spec — a missing required field, an invalid
$ref, a schema that references something that doesn’t exist — can produce
generated code that looks fine but is subtly wrong, or fails generation with
an error message that doesn’t point at the actual problem in the spec.
Solution: validate checks the spec itself before you try to generate
anything from it. I run this in CI, on every PR that touches the spec file,
so a broken spec fails fast with a clear message instead of surfacing as a
confusing codegen failure — or worse, generating silently-wrong types that
only get noticed once they’ve already shipped.
diff — what actually changed between two versions
npx api-client diff --old ./openapi-v1.yaml --new ./openapi-v2.yamlBreaking changes:
- DELETE /users/{id}/archive removed
- POST /invoices: `dueDate` is now required (was optional)
Non-breaking changes:
+ GET /users: new optional query param `sortBy`
+ Invoice schema: new optional field `notes`Problem this solves: “did this spec change break anything” used to mean
manually re-reading the whole spec and guessing, or finding out from a
runtime error after deploy. diff categorizes changes as breaking or
non-breaking automatically — a newly-required field or a removed endpoint is
breaking; a new optional field or param isn’t.
How I actually use this: in CI, on any PR that updates the vendored
spec file, running diff against the previous committed version and failing
the build if it reports breaking changes without an explicit
acknowledgment flag. It’s caught a genuinely breaking backend change before
it shipped to the frontend, more than once — the kind of thing that
otherwise surfaces as a production error report instead of a CI failure.
Drift detection — the part that runs continuously, not just in CI
Codegen alone catches drift at generation time. But specs can drift from the actual live backend too — someone changes a controller without updating the spec file, and now your generated types describe an API that no longer exists. That’s a separate mechanism, covered fully in the next post on runtime schema validation — codegen and drift detection are related but distinct concerns, and conflating them is a mistake I made in my head for a while before actually reading through how they’re implemented.
What sticks from this post
- Runtime mode for early-stage projects with a shifting API; codegen mode once the shape stabilizes and you want compile-time safety.
generateproduces types and descriptors from your spec;watchdoes it continuously in local dev.validatecatches a malformed spec before generation; run it in CI on every spec change.diffcategorizes spec changes as breaking or non-breaking — genuinely useful as a CI gate, not just a nice-to-have report.
Next: runtime schema validation and drift detection — what happens when your generated types and your live backend quietly stop agreeing with each other.
- developerehsan