Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

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:

  1. Generate in CI, not by hand. A typecheck job 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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 to string | null; older generators built for 3.0-era nullable still 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.