Skip to main content

/writing

Building Type-Safe APIs with OpenAPI

· 4 min read

The dashboard I worked on already had OpenAPI specs, but nothing was generated from them. Every API change meant somebody updated TypeScript types by hand, and integration bugs kept reaching production.

When several teams ship into the same dashboard against the same upstream APIs, that integration layer is its own product. It has consumers and a public surface. So the real question was whether to invest in it, or let each team keep negotiating API consumption on its own.

We went all in on OpenAPI as the single source of truth: types, clients and validators, all generated from the spec. The frontend stopped defining its own API types.

Three other pieces sit alongside this. Contract-first mocks ship the shape before the implementation. A proxy layer puts two upstream APIs behind one surface. A CI pipeline regenerates the code and flags breaking changes. This post is about the generation decision itself.

The pipeline

A codegen step runs on every spec change and emits TypeScript types, thin client functions, and request and response validators. The first version used openapi-typescript for types with custom templates for the rest. As the validators grew, the pipeline moved to a tool that handles types and Zod schemas in one pass. That migration is its own story.

CI watches the spec. When it changes upstream, regeneration opens a pull request, so reviewers see exactly what moved in the contract.

Alternatives considered

This bet meant rejecting three other approaches.

Hand-written types, per consumer. The status quo. Every team maintained types for the endpoints it called, and those types drifted from reality without anyone noticing. That is what prompted the work. The cost was paid every release rather than once.

The spec as documentation only. Keep the spec as the agreed contract on paper and keep hand-writing everything that consumes it. The spec never gains teeth. Drift shows up at runtime instead of at build time, and the spec stays a document rather than something the code depends on.

Generate the types and nothing else. A narrower bet: generate types, hand-write clients, validators and request shapes. It leaves the spec out of the parts that matter most at runtime, where validation and errors live, and it still leaves two sources of truth to keep in step.

The full pipeline costs more up front and pays back the moment the spec changes. Every alternative has a case where the spec drifts from reality and nothing pulls it back.

What changed

Integration errors dropped to near zero. Code reviews stopped including manual type-versus-API checks. Refactoring API code got safer, because the compiler caught contract violations that used to surface in production.

The biggest practical win was confidence. Engineers stopped guessing whether the types matched the response.

What it grew into

The generated surface is now the whole API layer of the dashboard: thousands of validators and types, feeding a hundred query-option modules, consumed by every route in the application. Twenty-odd people have shipped into that dashboard since, and none of them has written an API type by hand.

That is the part that made it worth the up-front cost. Not that the types are correct, but that writing one yourself stopped being an option anybody reaches for.

What I learned

Codegen is not a silver bullet. The generated code has to be readable, because engineers route around output they cannot follow. I spent real time tuning the templates until the client read like something a person had written.

The other lesson is to treat the spec as a first-class artifact. If the spec is wrong, the generated code is wrong too. Validating it in CI fails the build before a bad spec reaches anyone.