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_casewire fields tocamelCaserequires a runtime layer; skipping that layer means the types mirror the wire exactly and the bundle stays smaller. - OpenAPI
oneOfwith adiscriminatoris what makes TypeScript narrowing work in aswitch. 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
fetchsince 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.yml—noSerdeLayer,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
switchwithout 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 parsingerror.message. - Nothing is
any.unknownis 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 thangetApiV2UsersGet().
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
operationIdon every operation. Without it, generators synthesize names from the HTTP method and path, which is wheregetApiV2UsersUserIdGetcomes 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
oneOfwith adiscriminatorfor polymorphic responses. This is the single highest-leverage change for TypeScript output, covered in detail below. - Distinguish
requiredfromnullable. In OpenAPI 3.1 a nullable field istype: ["string", "null"], and it is not the same as omitting the field fromrequired. Collapsing the two producesstring | null | undefinedeverywhere and a client that tells developers nothing. - Model formats explicitly.
format: date-time,format: uri, andformat: binaryare what let a generator emitDateinstead ofstring. 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.
| Tool | What it produces | Best fit |
|---|---|---|
| openapi-typescript | Types 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 API | A generated client with a plugin architecture, successor to openapi-typescript-codegen | Application-side clients where per-operation functions and framework plugins matter |
| Orval | TanStack Query hooks, Axios or fetch clients | React applications consuming an internal API |
| Kubb | Configurable plugin pipeline over types, clients, mocks | Teams that want to control exactly what is emitted |
OpenAPI Generator (typescript-fetch, typescript-axios) | Class-based clients from Mustache templates | Organizations already standardized on it across many languages |
| Kiota | Fluent request-builder clients | Microsoft-ecosystem teams wanting one client style across languages |
| Speakeasy | Published SDKs with runtime validation | Teams that want Zod-based validation and accept the runtime dependency |
| Fern | Published SDKs with per-option control over casing, unions, errors, and module format | Teams 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 one — noSerdeLayer: 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: YourClientNametoken: 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.