typescript_fetch_client_generator
Generates a browser-first TypeScript API client using native fetch, with zero third-party
runtime dependencies: one interface/enum/type alias per OpenAPI schema, plus one client
class per tag with an async method per operation.
The generated code owns no HTTP engine, framework state, or hooks - it works unchanged from React,
Vue, Angular, Svelte, vanilla TS/JS, or Node with a fetch polyfill. fetch itself can be
overridden per client instance (for SSR, testing, logging/retry wrappers, etc.), request headers
can be a static object or an async callback (for a rotating/expiring bearer token), and an
operation with a spec-declared security requirement (http/bearer, apiKey, oauth2, or
openIdConnect, including multiple simultaneous or alternative schemes) automatically applies the
credential(s) from a dedicated auth config - see "Authentication" below.
Usage
openapi-yagen g -o out -g typescript_fetch_client_generator openapi.yaml
No variable is required - TypeScript has no package/namespace system to parameterize, and the output layout below is fixed.
| Variable | Required | Description |
|---|---|---|
strict | no (default true) | true: an unsupported schema/operation aborts generation with an error. false: skip it with a printed warning and generate everything else - useful for large real-world specs (see "Known limitations" below). |
importExtension | no (default "") | Suffix appended to every relative import between generated files (from "./Pet" vs from "./Pet.js"). The default (extensionless) works out of the box with every common bundler/dev-server (Vite, webpack, esbuild, Next.js, Angular CLI, SvelteKit) and tsc's "bundler" module resolution. Set to .js if your build uses Node's ESM-strict resolution ("moduleResolution": "NodeNext"/"Node16"), which requires the compiled output extension even inside .ts source. |
validateResponses | no (default false) | false: response bodies are trusted as-is (JSON.parse(...) as T), zero runtime footprint. true: also generate a recursive type-guard function per schema (isPet, isPetStatus, isShape, ...) and have every client method validate its response against one before returning, throwing ResponseValidationError on a mismatch - see "Response validation" below. |
Output layout
models/<Name>.ts one file per schema (interface / enum / type alias)
apis/<Tag>Client.ts one client class per OpenAPI tag
runtime.ts ApiClientConfig, ApiError, and the shared request() helper every client class calls
index.ts barrel re-export of everything above, plus a composite ApiClient facade
Integrating the generated code
import { ApiClient } from "./out/index";
const api = new ApiClient({ baseUrl: "https://api.example.com/v1" });
const pet = await api.pets.getPetById("123");
baseUrl doesn't need to be absolute - "" (same-origin, exactly like a bare fetch("/api/..."))
or a relative path ("/api/v1") both work, for a frontend served from the same origin as its API.
Each <Tag>Client class is also usable on its own (new PetsClient(config)) if you'd rather not
construct the whole ApiClient facade. Because the generated code makes no assumption about where
it runs, this works identically inside a React component, a Vue composable, an Angular service, a
Svelte store, or a plain script tag - nothing here reaches for a framework-specific global or
lifecycle hook.
ApiClientConfig:
export interface ApiClientConfig {
baseUrl: string;
fetch?: typeof fetch; // override for SSR, testing, or an instrumented wrapper
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
auth?: { bearer?: () => string | Promise<string>; apiKey?: () => string | Promise<string> };
}
A rotating/expiring bearer token needs the callback form of headers - a static object captured
once at construction would go stale:
const api = new ApiClient({
baseUrl: "https://api.example.com/v1",
headers: async () => ({ Authorization: `Bearer ${await getFreshAccessToken()}` }),
});
Request body content types
Request-body content types are picked from whichever the spec declares in priority order
application/json > multipart/form-data > application/x-www-form-urlencoded > (a single
remaining media type, sent as text or raw bytes):
-
application/json(default):options.bodyisJSON.stringify'd,Content-Type: application/json. -
application/x-www-form-urlencoded:options.body(still a plain, generated-interface-typed object - every property scalar/enum or an array of either, see "Known limitations") is encoded withURLSearchParams,Content-Type: application/x-www-form-urlencoded. An array-typed property sends one repeated key per element (OpenAPI's defaultstyle: form, explode: true), same convention an array-typed query parameter already follows. -
multipart/form-data: same scalar/enum-or-array-of-either restriction, plus atype: string, format: binaryproperty (a file field) or an array of them (a multiple-file field) is allowed, mapping toBlob | File/(Blob | File)[]instead of the genericstring/string[]a JSON body'sformat: binaryfield gets.options.bodyis converted to a nativeFormDataand sent with noContent-Typeset by the generated code - the browser/fetchsets it itself, boundary included:await api.pets.uploadPetPhoto(petId, {body: {caption: "Rex at the park", // stringphoto: fileInput.files[0], // Blob | Filealbums: ["vacation", "favorites"], // string[] - one repeated "albums" part per element},});No extra dependency needed -
FormDatais as ambient a Web API asfetch/URLalready relied on. -
any single
text/*media type (text/plain,text/csv,text/html, ...):options.bodyis a plainstring, sent as-is withContent-Typeset to the exact declared media type (e.g.text/csv, not a generictext/plain). -
any single other remaining media type (
application/octet-stream,application/zip,application/pdf,image/png, ...):options.bodyis a rawUint8Array, sent as-is withContent-Typeset to the exact declared media type. The declared schema (type: string, format: binaryor otherwise) has no bearing here - the wire content-type alone decidesstringvs.Uint8Array, same as it does at runtime for a real client/server. A wildcard declared media type (e.g.application/*) is sent asapplication/octet-stream.A requestBody declaring two or more media types outside the three fixed ones above is ambiguous (which one would the generated method actually send?) and is a generator error, same as any other unsupported content-type - see "Known limitations".
Authentication (components.securitySchemes)
An operation with a non-empty security in the spec gets its credential from auth, not
headers - the generated method already knows which scheme(s) it needs (and, for apiKey, which
header/query parameter to put each in), so auth only needs to supply the raw value(s):
const api = new ApiClient({
baseUrl: "https://api.example.com/v1",
auth: {
bearer: async () => await getFreshAccessToken(), // http/bearer, oauth2, or openIdConnect
apiKey: () => process.env.API_KEY!, // apiKey (header or query)
},
});
oauth2/openIdConnect schemes are treated identically to http/scheme: bearer (RFC 6750: an
OAuth2/OIDC access token travels as Authorization: Bearer <token> regardless of how it was
obtained) - both draw from the same auth.bearer provider, since a client only ever holds one kind
of bearer token at a time regardless of which flow issued it.
An operation whose security lists more than one alternative (OR) or more than one scheme required
together (AND) sends exactly one request, using whichever alternative's every scheme has a
configured provider - tried in the spec's own declared order, first fully-satisfied one wins:
security:
- oauth2Auth: [write:widgets] # alternative 1: needs a bearer token alone
- apiKeyAuth: [] # alternative 2: needs an apiKey alone
// Only `apiKey` provided -> alternative 1 (needs `bearer`) isn't satisfied, so alternative 2 is
// used instead - no error, no extra request.
await new ApiClient({ baseUrl, auth: { apiKey: () => "..." } }).widgets.favoriteWidget(id);
Calling a method where no alternative is fully configured throws immediately (before any
request is sent), naming every alternative's required provider(s)
(ApiClientConfig.auth.bearer/.apiKey). An apiKey scheme located in: cookie isn't supported (a
browser-first fetch client can't set that header itself - the browser owns cookies) - see "Known
limitations".
Every generated method also accepts an options.signal?: AbortSignal, so request cancellation
(e.g. tied to a React AbortController cleanup, or a Vue component's unmount) works out of the box.
On any non-2xx response, the shared request() helper throws ApiError (status, statusText,
and the best-effort-parsed response body):
import { ApiError } from "./out/runtime";
try {
await api.pets.getPetById("missing");
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
// handle not-found
} else {
throw err;
}
}
Multiple success responses
A single success response resolves to the plain body, whatever 2xx status actually arrives. An
operation declaring two or more 2xx responses (e.g. 200 for a synchronous result and 202 for
an accepted background job) instead resolves to a discriminated union on status, parsed
according to the actual response status:
// Generated (exported from index.ts):
export type CreateBackupResponse =
| { status: 200; body: Uint8Array } // application/zip
| { status: 202; body: Operation } // application/json
| { status: 204 }; // a response without content
// Usage:
const response = await api.backups.createBackup({ sync: true });
if (response.status === 200) {
saveArchive(response.body); // narrowed to Uint8Array
} else if (response.status === 202) {
console.log(`backup job ${response.body.id} started`);
}
Any other status - including a 2xx the operation doesn't declare - throws ApiError. With
validateResponses=true, each variant's body is checked against that status's declared type.
oneOf/anyOf support
Every oneOf/anyOf - discriminated or not - becomes a plain native TS union type
(type Shape = Circle | Square;). TypeScript needs no runtime dispatcher to tell union members
apart: JSON.parse already returns a plain value, and TS's own control-flow narrowing works
directly on any shared property:
if (shape.shapeType === "circle") {
// shape is narrowed to Circle here
}
For a discriminated union (discriminator.propertyName + every variant a $ref to a named
schema), the discriminator property on each variant is typed as its literal value (e.g.
shapeType: "circle") specifically so this narrowing works. An undiscriminated union (or one
with inline variants) is still a perfectly usable TS union - narrow it with typeof, in, or
your own property checks instead of a single discriminator.
By default, there is no runtime validation that a JSON.parse'd response body actually matches
its declared TS type - same as most real-world TS OpenAPI generators, and the cheapest option
consistent with "zero third-party runtime dependencies." A non-2xx HTTP status always throws
ApiError regardless of body shape; a mismatched-shape 2xx response does not, unless you opt into
the next section.
Response validation (opt-in)
Set -v validateResponses=true to additionally generate a recursive type-guard function per
schema - export function isPet(value: unknown): value is Pet, and likewise isPetStatus,
isShape, isPets, etc. Every client method calls the guard matching its own response type
before returning; if the parsed body doesn't structurally match, it throws ResponseValidationError
instead of returning data that only looks like it has the right type:
import { ResponseValidationError } from "./out/runtime";
try {
const pet = await api.pets.getPetById("123");
} catch (err) {
if (err instanceof ResponseValidationError) {
// the server returned 2xx, but the body doesn't match Pet - err.value is the raw parsed body
}
}
The guards are hand-generated structural checks (typeof/Array.isArray/property presence,
recursing into nested objects, arrays, and union members) - still zero third-party dependencies,
just more generated code and one extra function call per response. They're also plain exported
functions, usable on their own outside the client wherever you have an unknown value to narrow
(e.g. data from localStorage, a webhook payload, or a different API entirely).
This is off by default because it's a real trade-off, not a strict improvement: every model file gets bigger (one guard function alongside its type), and every response pays for a recursive structural walk. Turn it on when you don't fully trust the server to honor its own spec (a third-party API, a backend team that iterates faster than its OpenAPI doc, a public API you don't control) or while integrating against a not-yet-stable backend; leave it off once you trust the contract, or for a high-volume internal API where the extra walk isn't worth paying on every call.
Formatting generated sources
Templates (templates/*.ts.j2) emit correctly indented TypeScript directly, using Inja's
indent()/{% filter %} (see docs/templating.md), so a formatter
isn't required for readable output. -p/--post-process is still available for house-style
polish (quote style, trailing commas, line-wrapping) - pipe through
prettier:
openapi-yagen g -o out -g typescript_fetch_client_generator openapi.yaml \
-p "ts:prettier --write %file%"
-p starts prettier once per generated file; for a larger spec it's faster to generate first
and format afterwards in a single batch call:
openapi-yagen g -o out -g typescript_fetch_client_generator openapi.yaml
npx prettier --write "out/**/*.ts"
Known limitations (v1)
- Request and response bodies support
application/json, a singletext/*media type (returned/sent as a plainstring), and a single other media type (returned/sent as a rawUint8Array); request bodies additionally supportapplication/x-www-form-urlencodedandmultipart/form-data(see "Request body content types" above). A requestBody/response declaring two or more media types outside those fixed ones is a generator error (aborts generation under defaultstrict=true; skips just that operation with a printed warning under-v strict=false) - it is never silently dropped. - Path/header parameters must resolve to a primitive scalar or enum (string/number/boolean) - an object or array in one of those positions is a generator error.
- An
in: cookieparameter is a generator error, not a silently-dropped one - the Fetch/XHR spec forbids scripts from setting aCookierequest header at all (a "forbidden header name"), so there's no correct way for generated code to ever send one; the browser sends its own cookies automatically. - Query parameters may be arrays, supporting all three OpenAPI 3 serialization styles:
style: form(default) withexplode: true(the OpenAPI default - a repeated key,?tag=a&tag=b) orexplode: false(comma-joined,?tag=a,b),spaceDelimited(space-joined), andpipeDelimited(pipe-joined,?tag=a|b) - eachexplode: falsestyle joins the array into a single value in the generated method itself, before it reachesruntime.ts'sbuildUrl(). Any otherstyleis a generator error. stringschemas with formatdate/date-time/byte/binaryall map to plainstring- noDateobject, no base64/binary decoding.integer/number(any format) map tonumber- values beyond 2^53 lose precision.- No runtime validation of response bodies against generated types unless
-v validateResponses=true(see "Response validation" above) - off by default. uniqueItems: trueis not enforced at the type level (still emitted asT[], notSet<T>), including by thevalidateResponsesguards.securityschemes are limited tohttp/scheme: bearer,apiKey(in: headerorin: query- notcookie),oauth2, andopenIdConnect(the latter two treated as a bearer token, RFC 6750; no scope/claim validation) -mutualTLS/HTTP Basic, and acookie-located apiKey, are a generator error (see "Authentication" above). Multiple simultaneous (AND) and alternative (OR) requirements are supported by picking whichever alternative is fully configured at request time, not by a singleApiClientConfig.authshape per scheme kind - a spec needing two DIFFERENT bearer-kind credentials in the same AND-group (e.g. two distinct oauth2 schemes together) can't be expressed, sinceAuthProviderhas only onebearerslot.- Generated files are not run through a formatter - see "Formatting generated sources" above.
Try it
From the generators/ directory, with openapi-yagen on PATH (see run_ts_client.sh, sibling
to run.sh):
cd generators && ./run_ts_client.sh
generates into generators/out/ts-client from test/resources/petstore.yaml.
For a real generate-then-typecheck-then-run check exercising every operation, positive and
negative, see
test/ -
this generator's own self-contained test suite (see also
../README.md for the collection-wide convention):
cd generators/typescript_fetch_client_generator/test
OPENAPI_YAGEN=/path/to/openapi-yagen npm install && npm test