ehsan.blog
~/blog/api-client-part-11-codegen-cli — zsh
cat api-client-part-11-codegen-cli.md

Codegen: turning your OpenAPI spec into types you actually trust

·5 min read

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

plaintext
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

bash
npx api-client generate --spec ./openapi.yaml --out ./src/generated

This 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.

plaintext
src/generated/
├── types.ts         ← every schema as a TS type
├── descriptors.ts    ← module/method metadata for the query integration
└── paths.ts          ← typed path constants

Problem 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

bash
npx api-client watch --spec ./openapi.yaml --out ./src/generated

Regenerates 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

bash
npx api-client validate --spec ./openapi.yaml

Problem: 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

bash
npx api-client diff --old ./openapi-v1.yaml --new ./openapi-v2.yaml
plaintext
Breaking 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.
  • generate produces types and descriptors from your spec; watch does it continuously in local dev.
  • validate catches a malformed spec before generation; run it in CI on every spec change.
  • diff categorizes 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
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.