Skip to content
Last updated

Use the generated client

How to consume the TypeScript client produced by generate-client: authentication, argument styles, error handling, middleware, retries, and the optional add-on generators. For invoking the command itself (flags, output modes, config), see the generate-client command reference. To shape what gets generated — publisher defaults, custom generators — see Customize client generation.

Generators

--generator selects what to emit (default sdk). Each non-sdk generator adds a standalone sibling module next to the client; the client itself never imports it, so an add-on never adds a dependency to the client. Incompatible selections fail fast with an explanation.

GeneratorEmitsApp peer dependency
sdkThe typed client (default).none
zod<output>.zod.tsZod schemas + validation middleware.zod ^3.23 || ^4
tanstack-query<output>.tanstack.tsTanStack Query v5 factories, including <op>InfiniteOptions for paginated operations. React by default; tanstack-query-vue/-svelte/-solid switch the adapter import.@tanstack/<framework>-query ^5
swr<output>.swr.tsSWR hooks.swr ^2
mock<output>.mocks.tsMSW v2 handlers + create<Schema> factories.msw ^2 (+ @faker-js/faker for --mock-data faker)
transformers<output>.transformers.tstransform<Name> functions that parse wire dates to Date.none
redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator zod --generator mock

tanstack-query and swr wrap the throw-mode sdk functions, so they require --error-mode throw; transformers requires --date-type Date. See the zod, tanstack-query, and mock examples.

Package runtime

By default the runtime is embedded in the generated file, so the client is self-contained. With --runtime package the generated file instead imports the runtime from @redocly/client-generator — your application code is identical in both modes (same exports, same call shapes); only where the engine lives changes. Choose package when you want engine fixes and improvements via npm update @redocly/client-generator, with no regeneration.

Install the runtime as a regular dependency and set the mode in redocly.yaml:

npm install @redocly/client-generator
client:
  runtime: package # default: inline (self-contained)

An incompatible generated-file/runtime pair fails your tsc build (the descriptor satisfies check) rather than misbehaving at runtime. Package mode works with both output modes and every generator. See the package-runtime example.

Run with Node directly

Node 22.7+ runs TypeScript natively (type stripping), so you can execute a script that uses the generated client with plain node — no tsx, no build step. Node resolves import specifiers literally — there is no .js.ts remap — so generate with --import-ext ts to get real on-disk .ts specifiers, and import the client with a .ts extension in your own code:

redocly generate-client openapi.yaml -o src/api/client.ts --import-ext ts
// src/main.ts
import { listMenuItems } from './api/client.ts';

const menu = await listMenuItems({ limit: 3 });
node src/main.ts

Keep the default js when the client goes through tsc or a bundler — plain tsc rejects .ts specifiers unless the project enables allowImportingTsExtensions. Loaders such as tsx remap .js to .ts themselves, so they work with the default. See the node-native example.

Authentication

Credentials are per instance: they live in the client's config (ClientConfig.auth), and each operation automatically sends the credentials its security requires. A setter is generated for each securityScheme the runtime can apply:

SchemeSetterApplied as
HTTP bearer / OAuth2setBearer(token)Authorization: Bearer <token>
HTTP basicsetBasicAuth(user, pass)Authorization: Basic <base64>
apiKey (header/query/cookie)setApiKey(key) / setApiKey<Name>(key)the named header, query param, or cookie

setApiKey is unsuffixed for a single apiKey scheme; otherwise each gets setApiKey<SchemeName>. mutualTLS is not injectable. Cookie apiKey credentials travel in the Cookie request header, which browsers refuse to set — cookie auth works only in server-side clients (the generator warns when a spec declares one). Bearer and apiKey credentials accept a TokenProvider — a string or a (possibly async) function called per request, useful for refresh flows:

import { setBearer } from './client.ts';

setBearer(async () => await getFreshAccessToken());

Each setter is shorthand for the exported client instance's auth member (export const setBearer = client.auth.bearer;), so it configures that instance. Equivalently, pass credentials up front with configure({ auth: { … } }) or set them via client.auth.bearer(…) / client.auth.basic(…) / client.auth.apiKey(scheme, …).

For multiple independent instances with different credentials, build extra clients over the same generated descriptors — the generated module exports createClient, the OPERATIONS descriptors, and the Ops type in both runtimes:

import { createClient } from '@redocly/client-generator';
import { OPERATIONS, type Ops } from './client.ts';

const internal = createClient<Ops>(OPERATIONS, {
  serverUrl: 'https://api.example.com',
  auth: { basic: { username: 'svc', password: 's3cr3t' } },
});
const publicApi = createClient<Ops>(OPERATIONS, { serverUrl: 'https://api.example.com' }); // no auth

Argument style

By default (--args-style flat) each operation takes positional arguments — path params in URL order, then params (query), body, headers, and cookies — with the per-call init last. Cookie parameters are serialized into the Cookie request header, which browsers refuse to set — like cookie apiKey auth, they work only in server-side clients. With --args-style grouped, every input is bundled into one vars object typed as the operation's <Op>Variables:

// flat (default)
await updateOrder('ord_01khr…', { ...orderBody });

// grouped — order-independent, a good fit for React Query / SWR mutationFns
await updateOrder({ orderId: 'ord_01khr…', body: { ...orderBody } });

An unknown top-level key in the grouped object (for example a leftover flat-style { limit: 10 } instead of { params: { limit: 10 } }) fails the call with a TypeError naming the key. TypeScript catches this at compile time; the runtime check covers transpilers that skip type-checking, so a mis-shaped call never silently drops data.

Error handling

By default (--error-mode throw) an operation throws ApiError on any non-2xx response and returns the success body directly. With --error-mode result it never throws for HTTP errors, returning a discriminated Result<TData, TError> whose error is typed from the description's 4xx/5xx bodies:

// throw (default)
try {
  const order = await getOrderById('ord_123');
} catch (err) {
  if (err instanceof ApiError) console.error(err.status, err.body);
}

// result
const { data, error, response } = await getOrderById('ord_123');
if (error) console.error(response.status, error.title);
else console.log(data.id);

Transport and abort failures still throw in both modes. The choice is fixed at generate time.

Middleware

Beyond the single onRequest/onResponse/onError hooks on ClientConfig, the client takes composable middleware for cross-cutting concerns (auth refresh, logging, tracing, request IDs). Register with use() (shorthand for client.use()); it accepts several at once:

import { use } from './client.ts';

use({
  onRequest: (ctx) => {
    // ctx.operation is { id, path, tags } — target by identity, not URL matching
    if (ctx.operation.tags.includes('Orders')) {
      ctx.headers['X-Idempotency-Key'] = crypto.randomUUID();
    }
  },
});

onRequest runs in registration order; onResponse runs in reverse order. onRequest may mutate ctx (url, method, headers, and body — body edits are serialized and sent); onResponse may return a replacement Response. onError (throw mode only) is threaded through each middleware. ctx.operation's fields are typed as literal unions from the description (OperationId/OperationPath/OperationTag), so ctx.operation.id === '…' and ctx.operation.tags.includes('…') autocomplete, and a misspelled operation id fails compilation instead of silently never matching. A header for a single call instead goes in that operation's trailing init argument. Per-request headers merge lowest to highest — the caller always wins:

  1. Injected auth credentials.
  2. Typed header parameters.
  3. The caller's init.headers.

use() appends to the middleware chain, composing with any already-registered or publisher pre-configured middleware. configure({ middleware: [...] }) replaces the whole chain — use it to reset, but prefer use() to add to existing (including publisher pre-configured) middleware.

See the configure-and-middleware example for a runnable version.

Retries

Retry is opt-in, configured through ClientConfig with an optional per-call override:

configure({ retry: { retries: 3 } }); // the module's client instance
const other = createClient<Ops>(OPERATIONS, { retry: { retries: 3 } }); // another instance
await getOrderById('ord_123', {}, { retry: { retries: 5 } }); // per call

By default only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS) are retried, on a network error or a transient status (408, 429, 500, 502, 503, 504). POST/PATCH are not, since re-sending can duplicate side effects — opt in with a custom retryOn when safe. Backoff is exponential with full jitter (retryStrategy: 'fixed' for a constant delay); a Retry-After header takes precedence; an aborted AbortSignal stops retries immediately.

A retry resends the same request — the onRequest chain, config.headers(), and body serialization run once and are reused across attempts. To refresh a token, signature, or timestamp per attempt, do it in onResponse/onError or a custom retryOn rather than expecting onRequest to re-run.

RetryConfig fieldTypeDefault
retriesnumber0 (extra attempts after the first; 0 disables)
retryDelaynumber1000 (base delay, ms)
retryStrategy'fixed' | 'exponential''exponential'
jitterbooleantrue
retryOn(ctx: RetryContext) => boolean | Promise<boolean>idempotent-only predicate

A custom retryOn receives the failed attempt's RetryContext (attempt, request, and exactly one of response / error) and fully replaces the default. To inspect a response body, read ctx.response.clone() — the body is a single-use stream:

await createOrder(body, {
  retry: {
    retries: 3,
    retryOn: async (ctx) => {
      if (ctx.error) return true; // transport error
      return (ctx.response?.status ?? 0) >= 500; // server error
    },
  },
});

Query serialization

Query parameters follow their OpenAPI style / explode / allowReserved. The default (form, explode: true) repeats array values:

styleexplode['a', 'b'] on the wire
form (default)truekey=a&key=b
formfalsekey=a,b
spaceDelimitedfalsekey=a%20b
pipeDelimitedfalsekey=a|b

Delimiters are literal (values are still percent-encoded). allowReserved: true leaves the RFC-3986 reserved set un-encoded. Object-valued params serialize as deepObject brackets (key[sub]=val).

Multipart uploads

A multipart/form-data body whose schema is an object is generated as a typed object; pass a plain object and the client serializes it to FormData (after the onRequest chain, so middleware can mutate it). Binary fields (format: binary) are typed as Blob:

// type UploadBody = { file: Blob; orgId: string; tags?: string[] };
await upload({ file, orgId: 'org_1', tags: ['a', 'b'] });

Blob/strings pass through, arrays append one field per item, nested objects are JSON-encoded, undefined/null are skipped. A multipart body whose schema isn't a concrete object keeps the raw FormData type. format: byte (base64) stays a string.

Response decoding

The client reads each response by negotiating from its Content-Type (JSON, then text/*, then Blob). Force a reader per call with parseAs:

const res = await getMenuItemPhoto('prd_123', { parseAs: 'stream' });

parseAs accepts 'json', 'text', 'blob', 'arrayBuffer', 'formData', 'stream', or 'auto' (default). It changes the runtime reader only, not the static return type.

An operation whose success response declares no content is typed void, but if the server sends a JSON body anyway (a gap in the API description), the runtime still parses and returns it rather than silently dropping real data — reach it with a cast while the description catches up.

Runtime validation

The zod generator emits operationSchemas — request/response validators keyed by operationId — and the zodValidation middleware that wires them into the client:

import { use } from './api/client';
import { zodValidation } from './api/client.zod';

use(zodValidation()); // validate request bodies and JSON responses

The two directions default differently, because they catch different parties' bugs:

  • An invalid request body throws ZodValidationError before any network call — it is the caller's own bug, caught at the cheapest possible moment.
  • A successful JSON response that drifts from its schema warns by default (via console.warn, or a custom onViolation callback) and lets the call succeed — a server drifting from its description should not crash the consumer. Pass response: 'throw' for the strict behavior (it then throws even on result-mode clients), or response: false to skip.

ZodValidationError carries operationId, direction, the raw zod issues, and flattened violations — each with the full nested path (union branches included) and a truncated preview of the offending value, so the failing field is identifiable without reproducing the payload. Note that previews can surface payload data; point onViolation at a scrubbed logger when responses may carry secrets.

For servers that reject undeclared properties, stripRequestBodies: true replaces the outgoing body with the parsed result, dropping any key the schema does not declare (a spread like { ...entity } compiles past TypeScript's excess-property check but would otherwise reach the wire as-is). Operations without a JSON body pass through untouched, and payloads are never mutated unless stripRequestBodies is set. Pass { request: false } to narrow the scope, or import a schema from operationSchemas for a one-off check.

Operation metadata

The client exports an OPERATIONS map keyed by operationId — the same operation descriptors the runtime routes requests by, holding each operation's method, path template, tags, and wire shape:

export const OPERATIONS = {
  getOrderById: { id: 'getOrderById', method: 'GET', path: '/orders/{orderId}', tags: ['Orders'] },
  // …
} as const satisfies Record<string, OperationDescriptor>;

Because keys and values are plain string literals, they survive bundling/minification — making OPERATIONS the stable handle for cache keys, span names, or log labels (rather than fn.name, which a minifier can rename). Every client method also carries its own identity as client.getOrderById.operationId — an explicit, minification-proof cache key for consumer wrappers (react-query keys and the like). The same OperationId / OperationPath / OperationTag unions type ctx.operation in middleware.

Discriminated unions

A oneOf / anyOf with a usable discriminator gets an exported is<Member> type guard per member, taken from the description's discriminator or inferred when every member pins a shared property to a distinct string const:

export type MenuItem = Beverage | Dessert;
export function isBeverage(value: MenuItem): value is Beverage { … }

Guards are also emitted for unions nested inside another schema (array items, property values) as long as every member is a named schema. A union without a usable discriminator gets no guard.

Server-Sent Events

An operation whose 2xx response declares text/event-stream is generated as a typed async-generator function (a client method plus the matching free function) — no flag required. Each event's data is typed from the OpenAPI 3.2 itemSchema (falling back to the media schema, then string) and JSON.parsed when structured:

import { streamMessages } from './client.ts';

for await (const ev of streamMessages()) {
  console.log(ev.id, ev.data.text); // ServerSentEvent<T>: { event?, data, id?, retry? }
}

The stream auto-reconnects on a dropped connection, resuming from the last event id via Last-Event-ID (backoff honors the server's retry:, then reconnectDelay, then 1s; capped at 30s). Tune per call with { reconnect: false } or { reconnectDelay: 500 }. breaking the loop or aborting an AbortSignal ends it cleanly (no throw). SSE always throws ApiError on a non-2xx initial response, regardless of --error-mode.

Pagination is declared, never guessed: describe how your API paginates in redocly.yaml under client.pagination, or per operation with the x-redocly-pagination extension in the description. The rule fields, the generate-time verification, and the precedence between the convention, x-redocly-pagination, and per-operation overrides are documented in the client.pagination reference; there is no CLI flag. Each paginated operation keeps its one-shot call and gains two async iterators — .pages(args?, init?) yielding full pages and .items(args?, init?) yielding individual items, typed statically from the response schema.

Four styles are supported: cursor sends the response's nextCursor back in cursorParam, stops when it's absent, null, or empty, and throws if the server returns the same cursor twice in a row. For connection-style APIs whose cursor stays non-null on the last page, add the optional hasMore pointer (for example /pageInfo/hasNextPage) — iteration stops as soon as it resolves to false, skipping the follow-up empty request. offset advances offsetParam by each page's item count, and page increments offsetParam by 1; both stop on an empty page. link follows the response's RFC 8288 Link header rel="next" target (the GitHub pattern) — no advance parameter at all: the runtime merges the target's query params into the next call, so every page goes through the same declared endpoint (auth and middleware apply unchanged, and credentials are never handed to a cross-origin URL); iteration stops when no rel="next" is present and throws if the target repeats. A link convention rule applies only to operations whose success response documents a Link header; an explicit rule applies regardless but warns when the header is undocumented. limitParam is optional metadata for any style: the iterator never sets it, so pass your page size in params yourself.

import { client } from './client.ts';

for await (const order of client.listOrders.items({ params: { limit: 20 } })) {
  console.log(order.id); // `order` is `Order` — resolved from the response schema at generate time
}

for await (const page of client.listOrders.pages()) {
  console.log(page.orders.length); // each full page, last one included
}

The flat free functions keep both iterators. Note that the flat function itself takes positional arguments, but its .pages/.items always take the grouped shape — they are the client method's iterators.

Resume by passing the advance param in the initial args — iteration starts from there instead of the beginning. Abort by passing an AbortSignal, forwarded to every page request:

const controller = new AbortController();
for await (const page of client.listOrders.pages(
  { params: { cursor: 'c2' } }, // start from a saved cursor (or offset/page number)
  { signal: controller.signal }
)) {
  // …
}

A failed page always aborts iteration by throwing ApiError, even on an --error-mode result client. On a result-mode client, .pages() yields raw pages rather than { data, error, response } envelopes — only the one-shot call keeps the envelope — and the throw-mode-only onError middleware hook is not invoked.

For shapes the built-in styles don't cover — for example a cursor that travels in the request body or a header — page with a small hand-written helper over the generated call, which stays fully typed end to end (see the custom-pagination example).

TanStack Query factories

The tanstack-query generator emits typed TanStack Query v5 factories per operation:

  • <op>Options(vars, init?) per query (GET/HEAD) — pass to useQuery/prefetchQuery. Its queryFn forwards TanStack's abort signal into the request, so an unmounted or superseded query cancels its network call.
  • <op>InfiniteOptions(vars, init?) per paginated query — pass to useInfiniteQuery/fetchInfiniteQuery. The initialPageParam/getNextPageParam pair is compiled from the same pagination rule that powers .pages()/.items(), including the hasMore stop, so infinite queries need no hand-written getNextPageParam. (link-style operations are the exception — their next page lives in a response header a queryFn cannot see; use the sdk's .pages()/.items() iterators for those.)
  • <op>QueryKey(vars?) — with vars, the exact key the options use; without arguments, the invalidation prefix that matches every cached page and filter of the operation: queryClient.invalidateQueries({ queryKey: listOrdersQueryKey() }).
  • <op>Mutation(init?) per mutation — per-call RequestOptions (headers, a retry override) reach the mutation's requests.

The module-level factories bind the sdk's default client. For an isolated instance (its own credentials, middleware, retry), build a bound set with createQueryFactories:

import { createClient } from '@redocly/client-generator';
import { OPERATIONS, type Ops } from './api/client';
import { createQueryFactories } from './api/client.tanstack';

const internal = createQueryFactories(
  createClient<Ops>(OPERATIONS, { auth: { basic: { username: 'svc', password: 's3cr3t' } } })
);
useQuery(internal.getOrderOptions({ orderId }));

When several generated APIs share one QueryClient, their operationIds can collide (two APIs with a check operation would mix caches). Set queryKeyPrefix in the client block to namespace every key: queryKeyPrefix: main makes the keys ['main', 'check', vars].

Formatting and linting the generated files

The generator prints one canonical style — the TypeScript compiler's printer (four-space indent, double quotes). If your project's formatter enforces a different style, its check fails on freshly generated files. Either run your formatter over the output right after generating (for example, as the next step in the same script), or add the generated paths to your formatter's ignore list — generated files are not hand-edited, so reformatting them is churn without review value.

Linting is different: the generated code is expected to pass strict lint configurations as-is (no any, no non-null assertions, no unused imports). If your linter flags generated output, report it — that is a generator bug, not a style choice.

Resources