APIs & developer platforms / Tutorial
Generate Clients from OpenAPI and Stop Hand-Writing Fetch Wrappers
Turn your OpenAPI document into typed clients: pick a generator, wire it into CI so drift fails the build, and handle auth, errors, and versioning without hand-written wrappers.

In this tutorial you will take an OpenAPI document you already have and turn it into a typed TypeScript client that stays correct as the API changes: types generated from schemas, request functions generated from operations, a CI check that fails when the committed client drifts from the spec, and a deliberate answer for the parts generation does not solve — authentication, retries, and error mapping. The examples use openapi-typescript and openapi-fetch, with notes on where the heavier generators earn their complexity.
Why hand-written wrappers rot
Every team with a fetch wrapper has lived the same sequence. The wrapper starts as an honest afternoon of work. Then the API adds a required field, and the wrapper does not notice. A query parameter gets renamed, and the wrapper happily sends the old one, which the server ignores, so the client silently gets unfiltered results. An endpoint starts returning a union of two response shapes, and the wrapper’s single return type becomes a lie that TypeScript cannot catch because the lie lives at the network boundary.
The failure mode is always the same: two sources of truth. The server implements one contract; the wrapper encodes what someone believed the contract was on the day they wrote it. Code generation removes the second source of truth. The OpenAPI document becomes the contract, and the client is a build artefact derived from it — the same relationship your compiled output has to your source code.
The moving parts
There are three layers, and conflating them is where most adoption attempts get stuck:
- Types from schemas. Component schemas become TypeScript interfaces and unions. This layer is nearly free of controversy: the output is plain types with no runtime code.
- A typed client over those types. A thin runtime that maps operation IDs and paths to requests, checking parameters and responses against the generated types.
- Full SDK generation. A generator emits a complete client library — methods, models, serialisation, sometimes pagination and retry helpers — in one or more languages.
Layers 1 and 2 give you type safety with a runtime you can read in one sitting. Layer 3 gives you publishable SDKs for consumers you do not control. Most internal APIs need layers 1 and 2. Public APIs with external developers in several languages need layer 3.
Generating types and a client
Given an openapi.yaml at the root of your API repo, generation is two commands:
# Emit TypeScript types for every schema and operation
npx openapi-typescript ./openapi.yaml -o ./src/client/schema.d.ts
# The generated file is a build artefact: commit it, do not edit it.Consuming it with openapi-fetch looks like ordinary fetch code, except every path, parameter, and response is checked:
import createClient from "openapi-fetch";
import type { paths } from "./schema";
export const api = createClient<paths>({
baseUrl: process.env.API_BASE_URL,
});
// Path params, query params, and the request body are all typed
// against the document. Rename a field in the spec, regenerate,
// and every call site that breaks becomes a compile error.
const { data, error } = await api.GET("/projects/{projectId}/keys", {
params: {
path: { projectId: "prj_123" },
query: { status: "active" },
},
});
if (error) {
// `error` is typed as the union of the operation's error responses,
// so a 422 validation body and a 404 body have different shapes here
// and the compiler makes you handle the one you actually got.
}Study the error branch, because it is the part people underestimate. In a hand-written wrapper, errors are unknown or a generic interface, so client code treats a validation failure and a rate limit as the same event. In the generated client, the error type is the union of the responses the operation declares. If your document declares problem-detail bodies for 4xx responses, the client code sees those fields. Generation rewards a well-specified document and exposes a sloppy one — which is a feature, but budget for the spec clean-up it will demand.
Choosing a generator
| Approach | Emits | Runtime weight | Best for | Watch out for |
|---|---|---|---|---|
openapi-typescript + openapi-fetch | Types plus a thin typed fetch wrapper | Minimal; one small dependency | TypeScript frontends and Node services calling your own API | No help for non-TypeScript consumers |
| Orval | Types, client functions, and hooks for React Query and similar | Moderate; generated code per operation | Frontend teams that live in a data-fetching framework | Generated volume is large; review the config before committing the output |
Hey API (@hey-api/openapi-ts) | Types and pluggable client SDKs | Configurable | Teams that want generated SDK shape without a framework lock-in | Plugin ecosystem choices affect output style |
| OpenAPI Generator | Full SDKs for many languages | A complete generated library per language | Public APIs shipping official SDKs in several languages | Heavy output, Java toolchain, and customisation via templates gets deep quickly |
| Stainless / Speakeasy (hosted) | Polished SDKs, docs artefacts, publishing pipeline | Their toolchain, your spec | Companies treating SDKs as a product surface | Commercial service; you are adopting a vendor, not a library |
The decision rule: generate the least that removes your drift problem. If your consumers are your own TypeScript codebases, the first row is enough and the others are cost without benefit. If you publish SDKs, the calculus inverts — SDK quality is a product feature, and the hosted generators exist because doing it well in-house is a real job.
Making drift fail the build
Generation that depends on someone remembering to run a command will drift. Put it in CI:
name: api-client
on: [pull_request]
jobs:
check-client:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Regenerate client from the spec
run: npm run generate:client
- name: Fail if the committed client is stale
run: git diff --exit-code -- src/client
- name: Typecheck consumers against the fresh client
run: npm run typecheckThis turns an API change into an explicit, reviewable event. The pull request that changes the spec shows the regenerated client in the same diff, and any consumer call site the change breaks fails typecheck in the same run. Compare that with the alternative: the change merges silently and surfaces weeks later as a runtime bug in someone else’s deploy.
The parts generation does not solve
Be honest about the boundary, because pretending generation handles these is how teams end up fighting their own client:
- Authentication. Token acquisition, refresh, and rotation are application concerns. Generated clients accept middleware or interceptors; write one auth middleware well and reuse it, rather than expecting the generator to know your identity provider.
- Retries. A generator may offer a retry hook, but retry policy — which statuses, which operations are safe to retry, how idempotency keys are attached — is yours. Blindly retrying a non-idempotent POST is a bug no generator can absolve you of. For mutating operations, send an idempotency key and retry only with it.
- Pagination. Collection endpoints differ too much across APIs for generation to abstract cleanly. Write one small pagination helper per style your API uses (cursor, offset) against the generated types.
- Error semantics. The client gives you typed error bodies. Deciding that a specific problem type means “show the upgrade screen” is product logic. Keep a single mapping module between generated errors and UI behaviour.
The pattern is consistent: generation owns the contract-shaped code; you own the judgement-shaped code. The win is that the judgement code is a fraction of what you were maintaining, and it no longer changes every time a field is added.
Versioning and breaking changes
Generation changes the breaking-change conversation for the better, because breaks become compiler errors in consumers rather than production incidents. It does not remove the need for judgement. Adding an optional field regenerates cleanly. Renaming a field, narrowing a type, or making an optional field required will fail typecheck in every consumer — which is exactly what you want internally, and exactly why external consumers need versioned documents: publish the client per API version, generate from the versioned document, and let consumers upgrade on their schedule. Add a spec-linting step (a breaking-change detector run against the previous published document) so accidental breaks are flagged before release rather than discovered by a consumer’s CI.
Keeping the document honest
Since the client is derived from the document, the document’s quality ceiling becomes your client’s quality ceiling, and a few specification habits decide which side of that ceiling you land on. Give every operation a stable operationId; generators use it to name client methods, and an auto-derived name that changes when you rephrase a summary will churn your client API on every regeneration. Describe error responses with the same care as success responses — an operation whose 422 body is unspecified generates a client whose validation errors are unknown, throwing away the main benefit of the exercise. Reuse component schemas instead of inlining near-duplicates, because three slightly different User shapes in the document become three incompatible types in the client. And name things for the consumer, not the database: schema names are your client library’s public vocabulary now.
None of this is generator-specific, which is the point. The document has quietly become a public interface with a compiler attached, and it deserves the review discipline you would give any other interface: breaking-change checks against the published version, a changelog, and a named owner.
Verdict
Stop hand-writing fetch wrappers for any API you also specify. Generate types and a thin client from the OpenAPI document, commit the output, and fail CI on drift. Reserve full SDK generators for APIs whose consumers are external and multilingual. Keep auth, retries, pagination, and error semantics in your own small modules — that split is not a gap in the approach, it is the approach: the mechanical 90 percent becomes derived and self-updating, and the code your team actually maintains is the code that contains real decisions.


