Generate a TypeScript client from OpenAPI: openapi-typescript vs openapi-generator vs openapi-fetch
A TypeScript codebase that talks to an HTTP API should not hand-write request and response interfaces. It will not keep them in sync for a month. The question is which generator to use, and the answer depends on how much
A TypeScript codebase that talks to an HTTP API should not hand-write request and response interfaces. It will not keep them in sync for a month. The question is which generator to use, and the answer depends on how much runtime you want generated alongside the types. The ecosystem splits into three camps — types-only, lightweight typed fetchers, and full SDK generators — and teams routinely pick the heaviest option when the lightest would do. Here is the comparison on a real 3.2 spec with enums, polymorphic responses, file uploads, and SSE.
Camp 1: types-only with openapi-typescript
openapi-typescript turns the document into TypeScript types and nothing else. No runtime, no request functions, no dependencies.
npx openapi-typescript openapi.yaml -o src/api/schema.d.ts
You keep using fetch (or a thin wrapper), and apply the generated types with helper types:
import type { paths, components } from "./api/schema";
type Project = components["schemas"]["Project"];
type CreateProjectBody =
paths["/v1/projects"]["post"]["requestBody"]["content"]["application/json"];
async function createProject(body: CreateProjectBody): Promise<Project> {
const res = await fetch("https://api.example.com/v1/projects", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`create failed: ${res.status}`);
return (await res.json()).data as Project;
}
Strengths: zero runtime weight, full control over fetch behavior, middleware, retries, and auth; the generated file is types only, so it cannot contain a broken dependency; it tracks 3.1/3.2 JSON Schema features fastest because it only has to express them as types. Weaknesses: you write the request plumbing, including path parameter interpolation, query serialization, multipart, and per-status error handling; enums come out as string unions (fine) but there is no runtime enum object; nothing validates at runtime.
Camp 2: typed fetchers — openapi-fetch and similar
openapi-fetch (from the same maintainer as openapi-typescript) adds a minimal typed fetch wrapper over the types-only output:
import createClient from "openapi-fetch";
import type { paths } from "./api/schema";
const client = createClient<paths>({ baseUrl: "https://api.example.com" });
const { data, error, response } = await client.POST("/v1/projects", {
body: { name: "Apollo", plan_code: "TEAM" },
});
if (error) throw new Error(error.detail);
console.log(data.data.id);
The method and path are jointly typed, the request body is checked against that operation, and data/error narrow by documented status codes. Runtime is a few kilobytes and the API stays close to fetch. This is the sweet spot for most application codebases: contract-checked calls without an SDK's opinions. Weaknesses: advanced needs (global retries, complex auth refresh, multipart progress, streaming) still sit on you or on middleware; the ergonomics assume a conventional REST shape.
Camp 3: full SDK generators — openapi-generator and friends
OpenAPI Generator (Java toolchain, npm wrapper) and its TypeScript targets (typescript-axios, typescript-fetch, and the newer typescript client) emit a complete SDK: service classes, models, interceptors, retries. The strengths matter for some organizations:
- A distributable client for external customers with a stable surface and documentation.
- Batteries-included auth flows, retries, and (in some generators) WebSocket support.
- Consistent clients across languages generated from the same spec.
The costs are why application teams often regret it: thousands of lines of generated code in the repo (or a heavy internal package), generator-version churn producing enormous diffs, templates that lag OpenAPI 3.1/3.2 features, and an abstraction layer that fights you when the API does something the template did not anticipate. Enums become runtime objects you must learn the naming of; polymorphic schemas generate verbose inheritance hierarchies.
The comparison on the things that actually hurt
| Concern | openapi-typescript | openapi-fetch | Full generator |
|---|---|---|---|
| Runtime size | Zero | ~few KB | Large |
| Request/response type checking | Manual wiring | Built in | Built in |
| Per-status error narrowing | Manual | Native | Varies by template |
| OpenAPI 3.1/3.2 tracking | Fast | Fast | Often lags |
| Multipart / file upload | You implement | Supported | Supported |
| SSE / streaming | You implement | You extend | Limited/template-dependent |
| Retries / interceptors | Yours | Middleware | Built in |
| Generated-code churn | One .d.ts | One .d.ts | Large diffs |
| Best for | Custom stacks | App frontends/Node services | External multi-language SDKs |
Wiring generation into CI
Whichever you choose, the generated client is a build artifact, and the discipline is the same:
-
Generate in CI, not by hand. A
typecheckjob regenerates from the pinned spec and fails if the output differs from what is committed (or generate at build time and keep types out of the repo entirely). - Pin the spec by version. Consume a released spec artifact (registry, git tag, or hosted URL with a version header), not someone's working branch.
- Fail the build on breaking changes for consumers. Pair generation with an OpenAPI diff; a removed enum value breaks the union and should break the consuming build before release.
- Validate at the boundary. Types do not survive runtime: the server can still return an undocumented field or null. For trusted internal APIs that is acceptable; for external input, validate responses with a schema validator (or a generated runtime validator) at the edge.
- Keep one wrapper module. Even with openapi-fetch, instantiate the client once so base URL, auth headers, and error normalization have a single home.
Enums, nulls, and the 3.2 details that matter
- Enums described as
enum: [x, y]become string unions in types-only and fetch camps; full generators emit runtime objects. Unions are usually what modern TypeScript wants; do not let a generator's enum naming drive your domain model. -
type: [string, "null"]maps tostring | null; older generators built for 3.0-eranullablestill emit?-optional instead of nullable. Check the output on one nullable field before adopting. - Polymorphism (
discriminator,oneOf) is where templates show their age; generate one discriminated union response and inspect it — a hierarchy of base classes is a warning sign. - SSE endpoints are not REST calls; the client for them is an EventSource/fetch-stream wrapper keyed off the documented event schema, not a generated service method.
Practical default
For a React app or a Node service consuming one or two internal APIs: openapi-typescript + openapi-fetch, generated in CI from a versioned spec, with a single client module and explicit handling of the documented problem+json errors. Reach for the full generator when you are shipping an SDK to external customers in multiple languages and need the same conventions everywhere. Types-only alone is the right call when your transport is non-standard (custom streaming, signed requests) and any abstraction would be in the way.
In a spec-driven workspace the document these generators consume is the same file used for design, mocks, and tests, so the generated TypeScript tracks the contract the team actually exercised — and the MCP server exposes the same operations to coding agents. The demo shows the document-to-tools loop.
What to read next: detect breaking API changes in CI pairs with client generation, and organize a large OpenAPI spec with $ref covers the source structure that makes generated types clean.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.