Skip to content

Usage

Using the generated client

You generated Api.ts. Now what? This guide covers everything after generation: configuring the client, auth, errors, cancellation, files and framework integration. Every example uses the petstore test schema of the repository and is type-checked in strict mode against the code the generator emits today.

Generate the client

Install the generator as a dev dependency and point it at your schema. The generated files are plain TypeScript with no runtime dependency, except axios for the axios client.

Terminal
npm i -D swagger-typescript-api-es

# fetch client (default): writes src/api/Api.ts
npx swagger-typescript-api-es -u ./openapi.json -o ./src/api

# axios client (install axios in your app)
npm i axios
npx swagger-typescript-api-es -u ./openapi.json -o ./src/api --httpClientType axios

Or keep the options in a config file and run npx swagger-typescript-api-es (for example from a gen-api script). All options are described in the options reference.

swagger-typescript-api.config.ts
import { defaultConfig } from 'swagger-typescript-api-es';

export default defaultConfig({
  input: './openapi.json', // or url: 'https://api.example.com/openapi.json'
  output: './src/api',
  name: 'Api.ts',
  httpClientType: 'fetch', // or 'axios'
});

The examples below use the generated names of the petstore schema: api.pets.listPets, api.pets.getPet, api.store.getInventory, … Method names come from operationId, and routes are grouped by the first path segment (/pets/… → api.pets).

Create a client

Create the client once, in its own module, and import it everywhere. Api extends HttpClient, so the constructor takes the HTTP client config.

fetch

src/api-client.ts
import { Api } from './api/Api';

export const api = new Api({
  // default: the first `servers[].url` of the spec
  baseUrl: 'https://petstore.example.com/v1',
  // RequestInit defaults for every request
  baseApiParams: {
    credentials: 'include', // send cookies to another origin
    headers: { 'X-Client': 'web' },
  },
});

Defaults are replaced, not merged. baseApiParams replaces the built-in defaults (credentials: 'same-origin', redirect: 'follow', referrerPolicy: 'no-referrer'), so repeat the ones you still want. In the same way, baseUrl: undefined (an unset environment variable) removes the default server URL: write process.env.API_URL ?? 'https://…'.

axios

The axios client passes the config to axios.create(), so every AxiosRequestConfig option works.

src/api-client.ts
import { Api } from './api/Api';

// any AxiosRequestConfig option is accepted and passed to axios.create()
export const api = new Api({
  baseURL: 'https://petstore.example.com/v1',
  headers: { 'X-Client': 'web' },
  timeout: 10_000,
  withCredentials: true,
});

Your own axios instance

Pass instance to reuse an axios instance that already has interceptors, retries or a custom adapter. The client then uses it as is.

src/api-client.ts
import axios from 'axios';

import { Api } from './api/Api';

const instance = axios.create({
  baseURL: 'https://petstore.example.com/v1',
  timeout: 10_000,
});

instance.interceptors.response.use(undefined, (error) => {
  if (axios.isAxiosError(error) && error.response?.status === 401) {
    window.location.assign('/login');
  }
  return Promise.reject(error);
});

// with `instance`, the other axios options of the config are ignored
export const api = new Api({ instance });

// the instance is always available as `api.instance`
api.instance.defaults.headers.common['X-Client'] = 'web';

Client options

OptionfetchaxiosWhat it does
Server URLbaseUrlbaseURLDefaults to the first servers[].url of the spec.
Request defaultsbaseApiParamsany AxiosRequestConfig optionHeaders, credentials, timeout, … for every request.
securityWorker✓✓Returns extra request params for secure requests, from setSecurityData().
Always securebaseApiParams.securesecureCall the securityWorker for every request.
Response formatbaseApiParams.formatformatDefault way to read the response body.
injectHeaders✓✓Rewrites the final headers of every request (may be async).
customFetch✓–Replaces fetch (retries, tests, SSR).
instance–✓Your own AxiosInstance instead of axios.create().

Call endpoints

Each operation is a method. Path parameters come first as positional arguments, then the query object, then the request body. The last argument is always an optional RequestParams. Everything is typed from the schema, so a wrong field or a missing required property is a compile error.

TypeScript
import { PetStatus } from './api/Api';

// query parameters are one object (all optional here)
const { data: pets } = await api.pets.listPets({ status: PetStatus.Available, limit: 20 });

// path parameters are positional arguments
const { data: pet } = await api.pets.getPet(42);

// the request body is typed from the schema (NewPet)
const { data: created } = await api.pets.createPet({ name: 'Rex', tags: ['good-boy'] });

// path parameters first, then the body
await api.pets.updatePet(created.id, { name: 'Rex II' });
await api.pets.deletePet(created.id);

Per-request options

RequestParams is the per-call escape hatch. For the fetch client it accepts any RequestInit option plus secure, type, format, baseUrl and cancelToken. For axios it is an AxiosRequestConfig plus secure, type and format.

HTTP client
TypeScript
const controller = new AbortController();

// the last argument of every method: RequestParams
await api.pets.listPets(
  { limit: 10 },
  {
    headers: { 'X-Request-Id': crypto.randomUUID() }, // merged over baseApiParams.headers
    signal: controller.signal, // or any other RequestInit option
    cache: 'no-store',
    secure: false, // skip the securityWorker for this call
    format: 'json', // how to read the body: 'json' | 'text' | 'blob' | ...
    baseUrl: 'https://staging.petstore.example.com/v1',
  }
);

Headers and auth

Headers are merged in this order, the last one wins:

  1. client defaults: baseApiParams.headers (fetch) or headers (axios);
  2. the headers of the call's RequestParams;
  3. what the securityWorker returns, for secure requests;
  4. the Content-Type of the operation (not set for multipart/form-data, the browser adds the boundary);
  5. finally injectHeaders can rewrite the result.

injectHeaders

Both clients accept injectHeaders: (headers) => headers | Promise<headers>. It runs before every request, after all the merging, which makes it the place for values that change over time: a CSRF token, the current locale, a tracing id.

TypeScript
import { Api } from './api/Api';

export const api = new Api({
  // runs before every request with the merged headers; may be async
  injectHeaders: async (headers: Record<string, string>) => ({
    ...headers,
    'Accept-Language': navigator.language,
    'X-CSRF-Token': await getCsrfToken(), // your own helper
  }),
});

Tokens with securityWorker

The type argument of Api is the type of your security data. Store it with api.setSecurityData() after login, and the securityWorker turns it into request params (usually an Authorization header) for every secure request.

HTTP client
src/api-client.ts
import { Api } from './api/Api';

// the type argument is the type of your "security data", here an access token
export const api = new Api<string>({
  // called for every `secure` request with the value given to setSecurityData()
  securityWorker: (token) =>
    token ? { headers: { Authorization: `Bearer ${token}` } } : {},
});

export async function login(username: string, password: string) {
  const response = await fetch('/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password }),
  });
  const { accessToken } = (await response.json()) as { accessToken: string };
  api.setSecurityData(accessToken); // every secure request now sends the token
}

export function logout() {
  api.setSecurityData(null);
}

The worker can be async, for example to refresh an expired token first:

TypeScript
import { Api } from './api/Api';

// the worker may be async: refresh an expired token right before the request
export const api = new Api({
  securityWorker: async () => ({
    headers: { Authorization: `Bearer ${await auth.getFreshToken()}` },
  }),
  // call the worker for every request, not only for routes with `security`
  baseApiParams: { secure: true },
});

Secure routes

  • Operations with a security requirement, their own or the schema's global one, are generated with secure: true and a @secure JSDoc tag. Only those call the worker. An operation with security: [] opts out (like GET / in the petstore).
  • To send credentials with every request, set baseApiParams: { secure: true } (fetch) or secure: true (axios) in the client config.
  • To skip the worker for one call, pass { secure: false } as RequestParams.

Share one HttpClient

With singleHttpClient: true, Api no longer extends HttpClient: it receives one in its constructor and keeps it in api.http. The transport (base URL, auth, fetch or axios instance) is configured and replaced independently of the API surface, which helps with dependency injection and tests.

src/api-client.ts
import { Api, HttpClient } from './api/Api';

// configure the transport once...
export const http = new HttpClient<string>({
  baseUrl: 'https://petstore.example.com/v1',
  securityWorker: (token) =>
    token ? { headers: { Authorization: `Bearer ${token}` } } : {},
});

// ...and hand it to the Api (it is available as api.http)
export const api = new Api(http);

http.setSecurityData('my-jwt');
const { data: pets } = await api.pets.listPets({ limit: 10 });

One output, one HttpClient. HttpClient has private fields, so TypeScript treats the class of each generated output as a different type. Share an instance between the modules of one output, not between two separately generated APIs.

Modular output

With modular: true the output is split into http-client.ts, data-contracts.ts and one class per module (Pets.ts, Store.ts, Users.ts, plus Common.ts for routes like GET /). By default each module class extends HttpClient, so give them the same config:

src/api-client.ts
import type { ApiConfig } from './api/http-client';
import { Pets } from './api/Pets';
import { Store } from './api/Store';

const config: ApiConfig<string> = {
  baseUrl: 'https://petstore.example.com/v1',
  securityWorker: (token) =>
    token ? { headers: { Authorization: `Bearer ${token}` } } : {},
};

// every module class extends HttpClient: each one has its own state
export const pets = new Pets(config);
export const store = new Store(config);

export function setToken(token: string | null) {
  pets.setSecurityData(token);
  store.setSecurityData(token);
}

Add singleHttpClient: true to share one client instead: one config, one setSecurityData() call, and you can group the modules into a single object.

src/api-client.ts
import type { Pet } from './api/data-contracts';
import { HttpClient } from './api/http-client';
import { Pets } from './api/Pets';
import { Store } from './api/Store';
import { Users } from './api/Users';

export const http = new HttpClient<string>({
  baseUrl: 'https://petstore.example.com/v1',
  securityWorker: (token) =>
    token ? { headers: { Authorization: `Bearer ${token}` } } : {},
});

// one transport, one token, many modules
export const api = {
  pets: new Pets(http),
  store: new Store(http),
  users: new Users(http),
};

http.setSecurityData('my-jwt');
const { data } = await api.pets.listPets({ limit: 10 });
const first: Pet | undefined = data[0];

Responses and errors

fetch: HttpResponse

Methods resolve with an HttpResponse<Data, Error>: the native Response (status, headers, ok, …) with the parsed body in data. A non-2xx response rejects with the same object, the parsed error body in error, typed from the schema's error responses.

TypeScript
// the spec's `Error` schema would shadow the global Error: rename it on import
import type { Error as ApiError, HttpResponse } from './api/Api';

try {
  const response = await api.pets.getPet(42); // HttpResponse<Pet, ApiError>
  console.log(response.status, response.data.name);
} catch (e) {
  if (e instanceof Response) {
    // a non-2xx response rejects with the HttpResponse; `error` is the parsed body
    const { status, error } = e as HttpResponse<unknown, ApiError>;
    console.error(status, error.message);
  } else {
    throw e; // network error, aborted request, ...
  }
}

disableThrowOnError

Generate with disableThrowOnError: true to resolve every HTTP response and branch on ok instead of using try/catch. Network errors still reject.

TypeScript
// generated with disableThrowOnError: true
const response = await api.pets.getPet(42);

if (response.ok) {
  console.log(response.data.name); // Pet
} else {
  console.error(response.status, response.error.message); // the spec's error type
}

axios: AxiosError

The axios client resolves with an AxiosResponse<Data> and rejects with an AxiosError. Pass the error type to axios.isAxiosError to type e.response.data.

TypeScript
import axios from 'axios';

import type { Error as ApiError } from './api/Api';

try {
  const { data: pet, status } = await api.pets.getPet(42); // AxiosResponse<Pet>
  console.log(status, pet.name);
} catch (e) {
  if (axios.isAxiosError<ApiError>(e)) {
    // non-2xx responses reject with an AxiosError (axios' validateStatus)
    console.error(e.response?.status, e.response?.data.message);
  } else {
    throw e;
  }
}

unwrapResponseData

Generate with unwrapResponseData: true to get the body directly. This works for both clients; failed requests still reject (with the HttpResponse or the AxiosError).

TypeScript
// generated with unwrapResponseData: true
const pets = await api.pets.listPets({ limit: 10 }); // Pet[]
const pet = await api.pets.getPet(42); // Pet, not HttpResponse<Pet>

console.log(pets.length, pet.name);

Cancel requests

The fetch client can cancel by token: tag a request with cancelToken and call api.abortRequest(token). Both clients also accept a standard AbortSignal through signal. With the fetch client, cancelToken takes precedence over signal.

HTTP client
TypeScript
// tag a request with a cancelToken (string | number | symbol)...
const search = api.pets.listPets({ tags: ['cat'] }, { cancelToken: 'pet-search' });

// ...and abort it with the same token (e.g. when the user types again)
api.abortRequest('pet-search');
await search.catch((e: unknown) => console.log('aborted', e));

// or pass your own AbortSignal
await api.pets.listPets({ limit: 10 }, { signal: AbortSignal.timeout(5_000) });

Upload and download files

Operations with a multipart/form-data body are generated with type: ContentType.FormData. Pass a plain object that matches the schema: File and Blob values are appended as they are, other objects are sent as JSON. Do not set the Content-Type header yourself.

TypeScript
// POST /pets/{petId}/photo is multipart/form-data (type: ContentType.FormData):
// pass a plain object, the client builds the FormData
export async function onPhotoSelected(petId: string, event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0];
  if (!file) return;

  const { data: photo } = await api.pets.uploadPetPhoto(petId, { file, caption: 'Rex' });
  console.log(photo.url);
}

// application/x-www-form-urlencoded bodies are serialized for you as well
await api.store.placeOrder({ petId: 'pet-1', quantity: 2 });

For downloads, the format decides how the body is read. Responses declared as type: string, format: binary are generated with format: 'blob' and typed File (at runtime the value is a Blob). For other routes, pass format in RequestParams or call api.request():

TypeScript
// routes whose response is `format: binary` are generated with format: 'blob'.
// For anything else, override the format per call or use api.request():
const { data: blob } = await api.request<Blob>({
  path: '/pets/export',
  method: 'GET',
  secure: true,
  format: 'blob',
});

const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'pets.csv';
link.click();
URL.revokeObjectURL(link.href);

With axios, format is the responseType: use 'blob' in the browser and 'arraybuffer' or 'stream' in Node.js.

Custom fetch

The fetch client calls customFetch instead of the global fetch when you pass one. Wrap any fetch-compatible function: retries, logging, a polyfill, a test double.

TypeScript
import { Api } from './api/Api';

// retry GET requests on network errors and 5xx responses
const fetchWithRetry: typeof fetch = async (input, init) => {
  const retries = (init?.method ?? 'GET') === 'GET' ? 2 : 0;
  for (let attempt = 0; ; attempt++) {
    try {
      const response = await fetch(input, init);
      if (response.status < 500 || attempt >= retries) return response;
    } catch (e) {
      if (attempt >= retries || init?.signal?.aborted) throw e;
    }
    await new Promise((resolve) => setTimeout(resolve, 300 * 2 ** attempt));
  }
};

export const api = new Api({ customFetch: fetchWithRetry });

In tests, answer with real Response objects: no network and no mocking library.

pets.test.ts
import { Api, type Pet } from './api/Api';

const rex: Pet = { id: '1', name: 'Rex', createdAt: '2026-01-01T00:00:00Z' };

// no network, no mocking library: answer with a Response
const api = new Api({
  customFetch: async (input) => {
    if (String(input).endsWith('/pets/1')) return Response.json(rex);
    return Response.json({ code: 404, message: 'Not found' }, { status: 404 });
  },
});

const { data } = await api.pets.getPet(1);
console.assert(data.name === 'Rex');

For server-side rendering, create one client per incoming request so that headers such as cookies are never shared between users:

TypeScript
import { Api } from './api/Api';

// on the server, create a client per request and forward the caller's cookies
export function createServerApi(cookie: string) {
  return new Api({
    baseUrl: process.env.PETSTORE_URL ?? 'https://petstore.example.com/v1',
    baseApiParams: { headers: { cookie } },
  });
}

Pass a function, not window.fetch. The client calls this.customFetch(…). Passing window.fetch itself can fail with Illegal invocation in browsers; use (...args) => fetch(...args).

React Query and SWR

Generated methods return promises, so they plug into any data-fetching library. Map the response to its data (or generate with unwrapResponseData and pass the method directly), and forward the library's AbortSignal so unused requests are canceled.

src/pets/queries.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';

import { api } from '../api-client';
import type { NewPet } from '../api/Api';

export function usePets(limit = 20) {
  return useQuery({
    queryKey: ['pets', { limit }],
    // pass React Query's signal so unmounted queries are canceled
    queryFn: ({ signal }) => api.pets.listPets({ limit }, { signal }).then((r) => r.data),
  });
}

export function useCreatePet() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (pet: NewPet) => api.pets.createPet(pet).then((r) => r.data),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['pets'] }),
  });
}
src/pets/pet-name.tsx
import useSWR from 'swr';

import { api } from '../api-client';

export function PetName({ petId }: { petId: number }) {
  const { data: pet, error, isLoading } = useSWR(['pet', petId] as const, ([, id]) =>
    api.pets.getPet(id).then((r) => r.data)
  );

  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>Could not load the pet.</p>;
  return <p>{pet?.name}</p>;
}

A rejected request surfaces as the query's error: the HttpResponse (fetch) or the AxiosError (axios).

Node.js

Node.js 18 and later have fetch, FormData, Blob and File built in, so the fetch client runs unchanged in scripts, CLIs and servers. The axios client works too, with axios installed.

scripts/sync-inventory.ts
import { openAsBlob } from 'node:fs';

import { Api } from '../src/api/Api';

// Node.js 18+ has fetch, FormData and File built in: the fetch client works as is
const api = new Api<string>({
  baseUrl: process.env.PETSTORE_URL ?? 'https://petstore.example.com/v1',
  securityWorker: (token) =>
    token ? { headers: { Authorization: `Bearer ${token}` } } : {},
});
api.setSecurityData(process.env.PETSTORE_TOKEN ?? null);

const { data: inventory } = await api.store.getInventory();
console.table(inventory);

// upload a file from disk
const blob = await openAsBlob('./rex.jpg', { type: 'image/jpeg' });
const file = new File([blob], 'rex.jpg', { type: blob.type });
await api.pets.uploadPetPhoto('pet-1', { file });

Typing tips

Data contracts

Every schema of components/schemas is exported as a type (and enums as enums, or unions with generateUnionEnums). Import them from Api.ts, or from data-contracts.ts in modular mode. Inline parameter and response types can be derived from the methods.

TypeScript
// every schema of components/schemas is exported
// (from data-contracts.ts in modular mode)
import { PetStatus, type Api, type NewPet, type Pet } from './api/Api';

export const draft: NewPet = { name: 'Rex', status: PetStatus.Available };

export function isAvailable(pet: Pet) {
  return pet.status === PetStatus.Available;
}

// types of inline parameters and responses, derived from the methods
type PetsApi = Api<unknown>['pets'];
export type ListPetsQuery = NonNullable<Parameters<PetsApi['listPets']>[0]>;
export type ListPetsResult = Awaited<ReturnType<PetsApi['listPets']>>['data']; // Pet[]

Route types

With generateRouteTypes: true, each operation also gets a namespace with its RequestParams, RequestQuery, RequestBody, RequestHeaders and ResponseBody types, handy for forms, mocks and server handlers. In modular mode they are written to PetsRoute.ts, StoreRoute.ts, …

TypeScript
// generated with generateRouteTypes: true
import type { Pets } from './api/Api';

type Query = Pets.ListPets.RequestQuery; // { status?, tags?, limit?, sort? }
type Body = Pets.CreatePet.RequestBody; // NewPet
type Result = Pets.GetPet.ResponseBody; // Pet

export function toSearchParams(query: Query) {
  const entries = Object.entries(query).map(([key, value]) => [key, String(value)]);
  return new URLSearchParams(entries);
}

export type { Body, Result };

Need a different shape? Customize the output with hooks or custom templates, and preview the result in the playground.