Generate idiomatic TypeScript clients from OpenAPI: complete guide (September 2026)

15 min read

Turning an OpenAPI document into compiling TypeScript is not hard, and roughly a dozen tools do it well enough to demo. Turning one into a client a developer would choose over fetch is a different exercise, because "idiomatic TypeScript" is not a style preference — it is a set of concrete decisions about casing, union modeling, optionality, error shape, module format, and runtime dependencies, each of which the generator makes on your behalf whether or not you review it. The argument of this guide is that the quality of an OpenAPI-generated TypeScript client is determined almost entirely by those decisions rather than by the generator's feature list, so the useful way to evaluate one is to read the code it produces against that checklist and confirm each default is the one you would have picked.

TLDR:

  • Idiomatic means the output passes the consuming team's type checker and linter unmodified: discriminated unions that narrow, optional properties that reflect the schema, errors typed per status code, and no any.
  • The serialization boundary is the first real decision. Transforming snake_case wire fields to camelCase requires a runtime layer; skipping that layer means the types mirror the wire exactly and the bundle stays smaller.
  • OpenAPI oneOf with a discriminator is what makes TypeScript narrowing work in a switch. Many generators drop the discriminator and emit a union that never narrows.
  • The dependency budget is a hard constraint for browser and edge targets. A validation library in the SDK is bundle weight every consumer pays for.
  • Node has shipped a global fetch since 18 and stabilized it in 21, which removes the historical reason to bundle an HTTP client at all.
  • Fern's TypeScript generator exposes each of these as an explicit option in generators.ymlnoSerdeLayer, useBrandedStringAliases, neverThrowErrors, outputEsm, noOptionalProperties — rather than deciding for you.

What makes a generated TypeScript client idiomatic

The test is not whether the code looks hand-written. It is whether a developer integrating it hits friction that a hand-written client would not have produced. Concretely:

  • Types narrow. A union response can be discriminated in a switch without a cast or a type guard the consumer has to write.
  • Optionality matches the schema. A field that is absent, a field that is null, and a field set to a value are three distinct states, and the type system distinguishes them where the API does.
  • Errors are typed by status. A 404 and a 422 are not both Error, and the response body of each is reachable without parsing error.message.
  • Nothing is any. unknown is the correct emission for genuinely unknown data, and it forces the consumer to narrow.
  • Imports are tree-shakeable. Importing one method should not pull the whole client into the bundle.
  • The client works where the consumer runs it. Node, browsers, and edge runtimes have different constraints, and an SDK that assumes Node built-ins fails in a Cloudflare Worker.
  • Method names come from the API, not the URL. client.users.list() rather than getApiV2UsersGet().

Prepare the OpenAPI document before generating anything

That last item is decided upstream of the generator: method names, namespaces, and type names are all derived from the specification. A document written for documentation purposes usually produces bad TypeScript, and no generator flag repairs it.

  • Set operationId on every operation. Without it, generators synthesize names from the HTTP method and path, which is where getApiV2UsersUserIdGet comes from. With it, the method name is a decision rather than a derivation.
  • Tag consistently. Tags become client namespaces in most generators. Untagged operations land in a root namespace, and inconsistent tags produce a client whose shape has no relationship to the API's mental model.
  • Use oneOf with a discriminator for polymorphic responses. This is the single highest-leverage change for TypeScript output, covered in detail below.
  • Distinguish required from nullable. In OpenAPI 3.1 a nullable field is type: ["string", "null"], and it is not the same as omitting the field from required. Collapsing the two produces string | null | undefined everywhere and a client that tells developers nothing.
  • Model formats explicitly. format: date-time, format: uri, and format: binary are what let a generator emit Date instead of string. Getting the OpenAPI data types and formats right pays off in every language, and most visibly in TypeScript.
  • Add examples. They flow into generated documentation and snippets, and they are how reviewers catch a schema that is technically valid and semantically wrong.

Treat the document as source code from this point on: lint it in CI, review changes to it, and version it. The same preparation is what makes SDKs in other languages usable, so the effort is not TypeScript-specific.

Choose the generator for the job

The TypeScript ecosystem has more OpenAPI client generators than any other language, and they solve genuinely different problems. Picking by popularity rather than by output shape is the common mistake.

ToolWhat it producesBest fit
openapi-typescriptTypes only, zero runtime code; the typed client comes from the companion openapi-fetch runtime (about 6 kB minified)Internal apps that want type safety over fetch without a generated client surface
Hey APIA generated client with a plugin architecture, successor to openapi-typescript-codegenApplication-side clients where per-operation functions and framework plugins matter
OrvalTanStack Query hooks, Axios or fetch clientsReact applications consuming an internal API
KubbConfigurable plugin pipeline over types, clients, mocksTeams that want to control exactly what is emitted
OpenAPI Generator (typescript-fetch, typescript-axios)Class-based clients from Mustache templatesOrganizations already standardized on it across many languages
KiotaFluent request-builder clientsMicrosoft-ecosystem teams wanting one client style across languages
SpeakeasyPublished SDKs with runtime validationTeams that want Zod-based validation and accept the runtime dependency
FernPublished SDKs with per-option control over casing, unions, errors, and module formatTeams distributing a public TypeScript SDK on npm

The dividing line is whether the client is an application-side convenience or a published product, which is also the axis a comparison of OpenAPI Generator against a commercial toolchain turns on. An internal React app can use hooks generated fresh on every spec change and never think about it. A public SDK on npm has consumers with their own bundlers, their own type checkers, and their own upgrade schedules, which makes every default in the list below a support commitment.

Decide the serialization boundary

The first structural decision is whether the client transforms wire data at all. An API that returns snake_case JSON can produce either of two TypeScript clients, and both are defensible.

With a serialization layer, the generator emits serializer code alongside the types. The Fern TypeScript generator's serde layer is the reference implementation of this approach, and it buys three things: property names become camelCase even when the server expects snake_case, requests and responses are validated client-side at runtime, and types can stray from the wire representation to support things JSON cannot express, such as Date and Set.

Without onenoSerdeLayer: true in generators.yml — no serialization code is generated at all. The client uses JSON.parse() and JSON.stringify() directly, property names match the API exactly, and the bundle is smaller by whatever the serializer would have weighed.

The tradeoff is not primarily about style. Runtime validation is a forward-compatibility decision: a validating client that rejects an unrecognized response field breaks the moment the API adds one, unless the generator allows extra fields explicitly. Fern exposes both escape hatches for this, allowExtraFields to permit properties not in the schema and skipResponseValidation to keep the transformations while dropping the checks. An API whose fields differ only by casing cannot use the transformation at all, because the mapping is not reversible.

For a published SDK, the defensible defaults are: transform casing if the API's wire format is not already camelCase, validate requests, and be permissive about unrecognized response fields. That combination gives developers idiomatic types without turning every additive API change into a client-side exception.

Model unions, enums, and optionality the way TypeScript wants

This is where generated clients most often stop being idiomatic, and where the fix is usually in the specification rather than the generator.

Discriminated unions. TypeScript narrows a union automatically when a shared property is typed as a string literal. That only happens if the generator knows which property discriminates, which means the OpenAPI document needs oneOf plus a discriminator object rather than a bare oneOf. Several widely used generators drop the discriminator and emit either an intersection of every variant's properties or a union that never narrows, which pushes a hand-written type guard onto every consumer. Verify this in the output rather than assuming it:

// What a generator should emit for oneOf + discriminator
type Event =
  | { type: "created"; resourceId: string }
  | { type: "deleted"; resourceId: string; deletedAt: string };
 
// Consumer code that should compile with no cast
switch (event.type) {
  case "deleted":
    console.log(event.deletedAt); // narrowed
    break;
}

Enums. A closed string-literal union is the idiomatic TypeScript emission and also the one that breaks when the API adds a value: an exhaustive switch in a consumer's code stops compiling on upgrade, and older clients fail to typecheck against newer responses. Open enums, modeled as "a" | "b" | (string & {}), keep autocomplete while accepting unrecognized values, and they convert a whole class of major version bumps into minor ones. Which behavior you want depends on how often the API adds enum values, and it is worth deciding deliberately rather than inheriting.

Branded types. Two string aliases are interchangeable to the type checker, so an OrganizationId can be passed where a UserId is expected. Branding makes them distinct. Fern's useBrandedStringAliases option generates them:

export type MyString = string & { __MyString: void };
export const MyString = (value: string): MyString => value as MyString;

The cost is construction friction: every literal has to go through the constructor. It is worth it for identifier types in an API where mixing them is a real failure mode, and overhead everywhere else.

Optionality. By default most generators translate an optional schema property to an optional TypeScript property (name?: string). Fern's noOptionalProperties option emits age: number | undefined instead of age?: number, so the property is always present in the type and consumers cannot forget it exists. Pair whichever you choose with TypeScript's exactOptionalPropertyTypes on the consuming side if the distinction between "absent" and "explicitly undefined" matters to the API, which it does for any endpoint supporting PATCH semantics.

Unknown data. Fern emits unknown for genuinely unknowable payloads and offers treatUnknownAsAny to widen it. Prefer unknown in a published SDK. any removes the type error at the point of generation and relocates it to the consumer's runtime.

Errors: thrown exceptions or a result type

The generated client has to decide what happens on a non-2xx response, and both available answers are idiomatic in different codebases.

Throwing is the ecosystem default and reads naturally with async/await. It is only useful if the thrown value is typed per status code, so a consumer can catch and discriminate on the error class rather than string-matching a message. A generator that throws a single ApiError with a statusCode number is technically throwing and practically untyped.

The alternative is returning a result object. Fern's neverThrowErrors option does this, wrapping every response so the consumer branches instead of catching:

const response = await client.callEndpoint(...);
if (response.ok) {
  console.log(response.body);
} else {
  console.error(response.error);
}

This is the better fit for codebases that treat exceptions as exceptional and for edge functions where an uncaught throw is a 500. It is worse for quick scripts, and it is a breaking change to switch after publishing, so decide before the first release rather than after.

Either way, a timeout and a 500 are different conditions with different retry semantics, and collapsing them into one error type removes the consumer's ability to respond correctly.

Runtime targets, module format, and the dependency budget

A published TypeScript SDK runs in environments its author never tests: Node on a server, a bundler targeting browsers, React Native, Deno, Bun, and edge runtimes such as Cloudflare Workers and Vercel Edge Functions. Three decisions determine how many of those work.

Module format. Publishing ESM and CommonJS from one package via the exports field in package.json is the only option that serves both a modern bundler and a legacy require() consumer without complaints. Fern's TypeScript generator targets CommonJS by default and emits esnext when outputEsm is set to true; a package intended for wide distribution generally wants both builds rather than a choice between them.

HTTP layer. Node has exposed a global fetch since version 18 and stabilized it in version 21, so a generated client no longer needs to bundle axios or node-fetch to work on the server. That matters beyond dependency count, because node-fetch and the Node http module are unavailable in edge runtimes, and a client that reaches for either is unusable there. Fern exposes fetchSupport to select between node-fetch and native fetch, and allowCustomFetcher to let consumers inject their own implementation, which is the escape hatch for proxies, request signing, and instrumentation.

Dependency budget. Every runtime dependency in an SDK is weight every consumer's bundle carries, and a version constraint every consumer's resolver has to satisfy. This is the clearest live tradeoff between commercial generators: Speakeasy's TypeScript SDKs ship Zod as a runtime dependency to power response validation, which is a reasonable trade for a server-side client and a real cost for a browser bundle. Fern's TypeScript generator produces SDKs with no runtime dependencies, which suits frontend and browser-targeted distribution where bundle size is a review criterion. Neither is universally correct; what matters is knowing which one a generator picked before publishing under your organization's name.

Where a dependency genuinely is needed, it should be additive rather than assumed. Fern's extraDependencies and packageJson options merge entries into the generated package.json, with packageJsonMergeStrategy controlling how deeply.

Pagination, retries, and streaming belong in the client

The difference between a typed HTTP wrapper and an SDK is the behavior it removes from the consumer's code.

Pagination. A generated client should expose paginated endpoints as an async iterable so a consumer writes for await (const user of client.users.list()) and never sees a cursor. Fern configures this through the x-fern-pagination extension and supports cursor, offset, page-number, URI, path, and token-based schemes, which matters for APIs that accumulated more than one convention over time.

Retries. Transient failures should be retried with exponential backoff and jitter, on 408, 429, and 5XX only. Fern's default is two retries, overridable globally or per request through maxRetries in the request options. A client that retries a 400 is a bug; a client that retries nothing pushes a resilience loop into every consumer.

Timeouts. A default is required, because a client without one inherits whatever the runtime does, which in a browser is "wait indefinitely." Fern's generated TypeScript clients use a 60-second default configurable through defaultTimeout.

Streaming. Server-sent events and WebSockets need typed treatment rather than a raw ReadableStream. An SSE endpoint should yield typed events with access to the event ID and type; a WebSocket endpoint should produce a client with typed send and receive methods derived from an AsyncAPI document or the equivalent. This is where hand-rolled clients most often stop, and it is worth confirming a generator handles it before committing, since retrofitting streaming into a published SDK is a breaking change.

Extend the generated client without forking it

Every SDK eventually needs code no generator will produce: a helper wrapping a three-call sequence, a credential provider that signs short-lived JWTs, a polyfill for an unsupported runtime. In TypeScript the usual patterns are a subclass or wrapper around the generated client plus standalone modules importing from it, and both need to survive regeneration or the team quietly stops regenerating. Fern leaves paths listed in .fernignore alone entirely, and Replay tracks hand-edits inside otherwise-generated files as patches reapplied across regenerations with a three-way merge. Dynamic authentication is worth planning for specifically, since per-request credential computation is the most common reason a team edits generated code; Fern handles it through fetcher middleware rather than an edit, and the custom code guide covers the remaining patterns.

Wire generation and npm publishing into CI

Regeneration should be a consequence of merging a specification change, not a task someone remembers. In Fern this is declared in generators.yml, with the npm target and the client's exported name configured together:

output:
  location: npm
  package-name: your-package-name
  token: OIDC
config:
  namespaceExport: YourClientName

token: OIDC selects npm trusted publishing, which exchanges a short-lived, workflow-scoped identity token for publish rights instead of storing a long-lived NPM_TOKEN in CI secrets. The github block controls whether regeneration opens a pull request for review (mode: pull-request), commits directly (mode: push), or tags and releases (mode: release); for a public SDK, review before release is the safer default.

Two checks belong in front of the publish step. Generated tests are one: Fern produces unit tests and mock-server tests that assert the client sends and parses exactly what the specification describes, enabled by default for TypeScript and run on every pull request and release. A type-level compatibility check against the previously published package is the other, since it catches the case where a specification change is additive over HTTP and breaking in the emitted types.

How Fern generates idiomatic TypeScript clients from OpenAPI

Fern reads an OpenAPI, AsyncAPI, OpenRPC, gRPC, or Fern Definition document into an intermediate representation, then emits a TypeScript client with no runtime dependencies. Each decision described above is an explicit configuration option rather than a fixed behavior: noSerdeLayer for the serialization boundary, useBrandedStringAliases and noOptionalProperties for type strictness, neverThrowErrors for the error model, outputEsm for module format, and allowCustomFetcher for the HTTP layer. Generated unit and mock-server tests run in CI, retries and pagination are built into the client, npm publishing supports OIDC, and fern export writes the definition back out as standard OpenAPI so the toolchain is not a lock-in decision.

Final thoughts on generating idiomatic TypeScript clients from OpenAPI

Generator selection gets more attention than it deserves, and the output's defaults get less. Whether a client narrows a discriminated union, distinguishes absent from null, types errors by status, ships ESM alongside CommonJS, and runs on an edge runtime is decided by a handful of options and by the quality of the specification feeding them. Read the emitted code against that list before publishing, because each of those defaults becomes a compatibility promise the moment the package is on npm, and reversing one afterward is a major version.

To see a TypeScript client generated from a real OpenAPI document with each of these options set deliberately, book a demo.

FAQ

How do you generate a TypeScript client from an OpenAPI specification?

Point a generator at the document and configure the output. The mechanical step is a single command in every tool; the work that determines quality is upstream and downstream of it. Upstream, the specification needs operationId values, consistent tags, discriminated unions, and accurate required and nullable markings. Downstream, the generator's options for casing, error handling, and module format have to be set for the environments the client will run in.

What is the best OpenAPI TypeScript client generator?

It depends on whether the client is consumed internally or published. For an application consuming an internal API, openapi-typescript with openapi-fetch gives type safety with minimal generated surface, and Orval or Hey API generate more if you want hooks and per-operation functions. For a public SDK distributed on npm, the requirements shift to idiomatic output, no unwanted runtime dependencies, generated tests, and automated publishing, which is what commercial generators including Fern are built around.

Why does a generated TypeScript client use camelCase when the API uses snake_case?

Because the generator includes a serialization layer that transforms property names between the wire format and idiomatic TypeScript. The layer also enables runtime validation and types that JSON cannot represent directly, such as Date. Disabling it — noSerdeLayer: true in Fern — keeps property names identical to the API and produces a smaller bundle at the cost of those features. An API with fields that differ only by casing has to leave it disabled, since the transformation would not be reversible.

How do you get discriminated unions in a generated TypeScript client?

Define the polymorphic schema with oneOf plus a discriminator object naming the property that distinguishes the variants, rather than a bare oneOf. Generators that respect the discriminator emit a union of object types sharing a string-literal property, which TypeScript narrows automatically in a switch. Support varies significantly between tools, so inspect the generated types before relying on it; a union that requires a hand-written type guard in consumer code is the failure signal.

Do generated TypeScript SDKs work in browsers and edge runtimes?

Only if the generator targets them. A client that depends on node-fetch or Node's http module fails in Cloudflare Workers and Vercel Edge Functions, and a large runtime dependency is a problem in any browser bundle. The portable configuration is native fetch, which Node has shipped globally since version 18 and stabilized in version 21, with an ESM build alongside CommonJS and no unnecessary runtime dependencies. Fern's TypeScript generator produces SDKs with no runtime dependencies for this reason.