Skip to content

API ​

createClient ​

createClient accepts the following options, which set the default settings for all subsequent fetch calls.

ts
createClient<paths>(options);
NameTypeDescription
baseUrlstringPrefix all fetch URLs with this option (e.g. "https://myapi.dev/v1/")
fetchfetchFetch instance used for requests (default: globalThis.fetch)
RequestRequestRequest class the client builds requests with (default: globalThis.Request)
querySerializerQuerySerializer(optional) Provide a querySerializer
bodySerializerBodySerializer(optional) Provide a bodySerializer
pathSerializerPathSerializer(optional) Provide a pathSerializer
requestInitExtRecord<string, unknown>(optional) Passed as the second argument to fetch, for options of Node.js's fetch (undici) such as dispatcher; other platforms ignore it
(Fetch options)Any valid fetch option (headers, mode, cache, signal …) (docs

Another fetch implementation ​

fetch and Request of another Fetch API implementation, such as the undici package, are typed by that package, not by the DOM lib, so TypeScript cannot relate them to Request and Response. createClient and createPathBasedClient take them anyway (CustomFetchClientOptions) and check only that fetch returns a promise and Request is a class. Pass both from the same package: the undici fetch reads a request of another class as the URL [object Request].

ts
import { fetch, Request } from "undici";

const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/", fetch, Request });

TRACE ​

The client has TRACE() because OpenAPI has trace operations, but the Fetch standard forbids the method, so what happens depends on the platform:

Platformclient.TRACE()
Browsers (as the standard says), Node.js, Denorejects with the platform's TypeError before anything is sent (Node.js: 'TRACE' HTTP method is unsupported., Deno: Method is forbidden)
Bun (1.4)sends the request, but the response body is always empty, so data is undefined

Node.js 24, Bun 1.4.4 and Deno 2.9.6 were checked against a server that answers TRACE with a body. Use another HTTP client for TRACE if you need its response body.

Fetch options ​

The following options apply to all request methods (.GET(), .POST(), etc.)

ts
client.GET("/my-url", options);
NameTypeDescription
paramsParamsObjectpath and query params for the endpoint
body{ [name]:value }requestBody data for the endpoint
querySerializerQuerySerializer(optional) Provide a querySerializer
bodySerializerBodySerializer(optional) Provide a bodySerializer
pathSerializerPathSerializer(optional) Provide a pathSerializer
parseAs"json" | "text" | "arrayBuffer" | "blob" | "stream"(optional) Parse the response using a built-in instance method (default: "json"). "stream" skips parsing altogether and returns the raw stream.
baseUrlstringPrefix the fetch URL with this option (e.g. "https://myapi.dev/v1/")
fetchfetchFetch instance used for requests (default: fetch from createClient)
middlewareMiddleware[]See docs
(Fetch options)Any valid fetch option (headers, mode, cache, signal, …) (docs)

Response body ​

As in openapi-fetch, a success body is parsed as JSON unless parseAs says otherwise, whatever its Content-Type; a text/plain response needs parseAs: "text". A success body that is not JSON (for example the HTML page of a proxy) throws a SyntaxError that names the status, the Content-Type and the first 100 characters of the body, and says which parseAs to set (parseAs: "text" for a text/* response); the parser's own error is its cause. An error response (4xx, 5xx) whose body is not JSON gives the text as error instead of throwing.

Headers ​

headers can be set on the client, on a request, and as params.header; for the same name the request wins over the client, and params.header over both. They can be a Headers object, a list of [name, value] pairs or a record. A record value can be a string, a number, a boolean or an array of them (each item is appended), and null removes a header set at a lower level.

Cookie parameters

params.cookie is typed but not sent, as in openapi-fetch. Browsers do not let scripts set the Cookie header; they send their own cookies when credentials allows it. On a server, set the Cookie header in headers yourself.

wrapAsPathBasedClient ​

wrapAsPathBasedClient wraps the result of createClient() to return a Proxy-based client that allows path-indexed calls:

ts
const client = createClient<paths>(clientOptions);
const pathBasedClient = wrapAsPathBasedClient(client);

pathBasedClient["/my-url"].GET(fetchOptions);

The fetchOptions are the same as for the base client.

A path based client can lead to better type inference but comes at a runtime cost due to the use of a Proxy.

createPathBasedClient is a convenience method combining createClient and wrapAsPathBasedClient if you only want to use the path based call style:

ts
const client = createPathBasedClient<paths>(clientOptions);

client["/my-url"].GET(fetchOptions);

Note that it does not allow you to attach middlewares. If you need middlewares, you need to use the full form:

ts
const client = createClient<paths>(clientOptions);

client.use(authMiddleware);

const pathBasedClient = wrapAsPathBasedClient(client);

client.use(loggingMiddleware); // the client reference is shared, so the middlewares will propagate.

pathBasedClient["/my-url"].GET(fetchOptions);

querySerializer ​

OpenAPI supports different ways of serializing objects and arrays for parameters (strings, numbers, and booleans—primitives—always behave the same way). By default, this library serializes arrays using style: "form", explode: true, and objects using style: "deepObject", explode: true, but you can customize that behavior with the querySerializer option (either on createClient() to control every request, or on individual requests for just one).

Object syntax ​

openapi-typesafe-fetch ships the common serialization methods out-of-the-box:

OptionTypeDescription
arraySerializerOptionsSet style and explode for arrays (docs). Default: { style: "form", explode: true }.
objectSerializerOptionsSet style and explode for objects (docs). Default: { style: "deepObject", explode: true }.
allowReservedbooleanSet to true to skip URL encoding of values (may break the request) (docs). Default: false.
ts
const client = createClient<paths>({
  querySerializer: {
    array: {
      style: "pipeDelimited", // "form" (default) | "spaceDelimited" | "pipeDelimited"
      explode: true,
    },
    object: {
      style: "form", // "form" | "deepObject" (default)
      explode: true,
    },
  },
});

A Date value is sent as ISO 8601 in UTC (2026-11-08T12:30:00.000Z, from toISOString()), on its own, in an array or in an object, and in path parameters too; an invalid Date throws a RangeError. A parameter that serializes to nothing (null, undefined, an empty array or an empty object) is left out together with its &, and so are the null and undefined items and members of exploded arrays and objects.

Parameter names and object keys are encoded too, so a key from your data ({ "a&b": 1 }) cannot add a parameter or a path segment. [, ], $, :, @ and , stay as written, so names such as filter[status] and $filter are sent unchanged.

Array styles ​

StyleArray id = [3, 4, 5]
form/users?id=3,4,5
form (exploded, default)/users?id=3&id=4&id=5
spaceDelimited/users?id=3%204%205
spaceDelimited (exploded)/users?id=3&id=4&id=5
pipeDelimited/users?id=3|4|5
pipeDelimited (exploded)/users?id=3&id=4&id=5

Object styles ​

StyleObject id = {"role": "admin", "firstName": "Alex"}
form/users?id=role,admin,firstName,Alex
form (exploded)/users?role=admin&firstName=Alex
deepObject (default)/users?id[role]=admin&id[firstName]=Alex

TIP

deepObject is always exploded, so it doesn’t matter if you set explode: true or explode: false—it’ll generate the same output.

Alternate function syntax ​

Sometimes your backend doesn’t use one of the standard serialization methods, in which case you can pass a function to querySerializer to serialize the entire string yourself. You’ll also need to use this if you’re handling deeply-nested objects and arrays in your params:

ts
const client = createClient<paths>({
  querySerializer(queryParams) {
    const search: string[] = [];
    for (const name in queryParams) {
      const value = queryParams[name];
      if (Array.isArray(value)) {
        for (const item of value) {
          search.push(`${name}[]=${encodeURIComponent(String(item))}`);
        }
      } else {
        search.push(`${name}=${encodeURIComponent(String(value))}`);
      }
    }
    return search.join("&"); // ?tags[]=food&tags[]=california&tags[]=healthy
  },
});

WARNING

When serializing yourself, the string will be kept exactly as-authored, so you’ll have to call encodeURI or encodeURIComponent to escape special characters.

bodySerializer ​

Similar to querySerializer, bodySerializer allows you to customize how the requestBody is serialized if you don’t want the default JSON.stringify() behavior.

A multipart/form-data body needs no serializer: set the header and pass the object. Each field becomes a part (a Blob or File a file part, an array one part per item, null and undefined left out), and the platform sets the boundary. Generate the types with --binary-as-blob so file fields are typed Blob.

ts
const { data, error } = await client.POST("/avatars", {
  headers: { "Content-Type": "multipart/form-data" },
  body: { file: new File([bytes], "avatar.png"), description: "Me" },
});

A nested object in such a body throws, because a part with JSON needs its own content type and servers differ. For that, or any other format, write a bodySerializer:

ts
const { data, error } = await client.PUT("/submit", {
  body: {
    name: "",
    query: { version: 2 },
  },
  bodySerializer(body) {
    const fd = new FormData();
    fd.append("name", body.name);
    // the nested object becomes a JSON part with its own content type
    fd.append("query", new Blob([JSON.stringify(body.query)], { type: "application/json" }));
    return fd;
  },
});

TIP

For convenience, openapi-typesafe-fetch sets Content-Type to application/json automatically for any request that provides value for the body parameter. When the body (or the bodySerializer result) is a FormData, Blob, File, URLSearchParams, ArrayBuffer or typed array, Content-Type is omitted, allowing the platform to set it automatically (for FormData, with the correct message part boundary). A Content-Type without a boundary that you set for a FormData body is dropped for the same reason.

You can also set Content-Type manually through headers object either in the fetch options, or when instantiating the client.

Text body ​

A string body is sent as is when you set a non-JSON Content-Type, such as text/plain. Without a Content-Type, or with a JSON one, a string is sent as a JSON string value ("abc" becomes "\"abc\"").

ts
const { data, error } = await client.POST("/notes", {
  body: "Remember the milk",
  headers: { "Content-Type": "text/plain" },
});

Binary body ​

A Blob, File, ArrayBuffer or typed array (the usual type for a format: binary schema) is sent as is. Set Content-Type yourself when the server needs one; a Blob with a type gets it from the platform.

ts
const { data, error } = await client.PUT("/files/{name}", {
  params: { path: { name: "photo.png" } },
  body: file,
  headers: { "Content-Type": "image/png" },
});

URL-encoded body ​

To send a body request in application/x-www-form-urlencoded format, which is commonly used to transmit key-value pairs in APIs like OAuth 2.0, pass the appropriate header (parameters such as ; charset=UTF-8 may follow) and body as an object. openapi-typesafe-fetch will automatically encode the body to the correct format.

ts
const { data, error } = await client.POST("/tokens", {
  body: {
    clientId: "someClientId",
    clientSecret: "someClientSecret",
  },
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
  },
});

Form encoding has no standard for nested objects (servers read a[b], a.b or JSON), so a nested object value throws an error instead of being sent as [object Object]. An array becomes one comma-separated value (ids=1%2C2), and a null or undefined field is left out, as in a multipart body. For nested values or repeated keys, write a bodySerializer in the format your server reads.

pathSerializer ​

Similar to querySerializer and bodySerializer, pathSerializer allows you to customize how path parameters are serialized. This is useful when your API uses a non-standard path serialization format, or you want to change the default behavior.

Custom Path Serializer ​

You can provide a custom path serializer when creating the client:

ts
const client = createClient<paths>({
  pathSerializer(pathname, pathParams) {
    let result = pathname;
    for (const [key, value] of Object.entries(pathParams)) {
      result = result.replace(`{${key}}`, `[${value}]`);
    }
    return result;
  },
});

const { data, error } = await client.GET("/users/{id}", {
  params: { path: { id: 5 } },
});

// URL: `/users/[5]`

WARNING

The default path serializer rejects a request when a path parameter makes a . or .. path segment (for example { id: ".." }): URL resolution would send it to another endpoint. This holds for a path with a query string or fragment in its template too (/echo/{id}?v=2); the query and fragment themselves are not checked. It also rejects a path that starts with // once the parameters are in (an empty first parameter, as in /{org}/{repo} with org: ""): with a relative baseUrl (the default "") a browser reads //evil.example as another host and sends the request, headers included, there. An empty value in a later segment (/acme/) is sent as before. A custom pathSerializer is not checked.

Default Path Serializer ​

openapi-typesafe-fetch supports path serialization as outlined in the 3.1 spec. This happens automatically, based on the specific format in your OpenAPI schema:

TemplateStylePrimitive id = 5Array id = [3, 4, 5]Object id = {"role": "admin", "firstName": "Alex"}
/users/{id}simple (default)/users/5/users/3,4,5/users/role,admin,firstName,Alex
/users/{id*}simple (exploded)/users/5/users/3,4,5/users/role=admin,firstName=Alex
/users/{.id}label/users/.5/users/.3,4,5/users/.role,admin,firstName,Alex
/users/{.id*}label (exploded)/users/.5/users/.3.4.5/users/.role=admin.firstName=Alex
/users/{;id}matrix/users/;id=5/users/;id=3,4,5/users/;id=role,admin,firstName,Alex
/users/{;id*}matrix (exploded)/users/;id=5/users/;id=3;id=4;id=5/users/;role=admin;firstName=Alex

Middleware ​

Middleware is an object with onRequest(), onResponse() and onError() callbacks that can observe and modify requests, responses and errors.

ts
import createClient, { type Middleware } from "openapi-typesafe-fetch";
import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typesafe

const myMiddleware: Middleware = {
  async onRequest({ request, options }) {
    // set "foo" header
    request.headers.set("foo", "bar");
    return request;
  },
  async onResponse({ request, response, options }) {
    // change status of response; Response fields are getters, so copy them by name
    return new Response(response.body, {
      status: 200,
      statusText: response.statusText,
      headers: response.headers,
    });
  },
  async onError({ error }) {
    // wrap errors thrown by fetch
    return new Error("Oops, fetch failed", { cause: error });
  },
};

const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });

// register middleware
client.use(myMiddleware);

API ​

Parameters ​

Each middleware callback receives the following options object with the following:

NameTypeDescription
requestRequestThe current Request to be sent to the endpoint.
schemaPathstringThe original OpenAPI path called (e.g. /users/{user_id})
paramsObjectThe original params object passed to GET() / POST() / etc.
idstringA unique ID for this request: a counter, shared by all clients in the process.
optionsMergedOptionsThe options of this request after merging the client and request options (read-only): baseUrl, fetch, parseAs, querySerializer, bodySerializer and pathSerializer.

In addition to these, the onResponse callback receives an additional response property:

NameTypeDescription
responseResponseThe Response returned from the endpoint.

And the onError callback receives an additional error property:

NameTypeDescription
errorunknownThe error thrown by fetch, probably a TypeError or a DOMException.

Response ​

Each middleware callback can return:

  • onRequest: A Request to modify the request, a Response to short-circuit the middleware chain, or undefined to leave request untouched (skip)
  • onResponse: Either a Response to modify the response, or undefined to leave it untouched (skip)
  • onError: Either an Error to modify the error that is thrown, a Response which means that the fetch call will proceed as successful, or undefined to leave the error untouched (skip)

A Request is recognized by its method, url and headers, a Response by its status and headers, not by its class. So one from the undici package, from another realm (an iframe, an Electron host) or of a custom Request class works too. A plain copy such as { ...request } has none of them and throws.

Ejecting middleware ​

To remove middleware, call client.eject(middleware):

ts
import type { Middleware } from "openapi-typesafe-fetch";

const myMiddleware: Middleware = {
  onRequest({ request }) {
    // …
  },
};

// register middleware
client.use(myMiddleware);

// remove middleware
client.eject(myMiddleware);

For additional guides & examples, see Middleware & Auth

Other exports ​

The package also exports the helpers the client is built from. They follow semantic versioning like the rest of the API.

NameDescription
defaultBodySerializer(body, headers?)The default bodySerializer. See below for its result type.
defaultPathSerializer(pathname, pathParams)The default pathSerializer.
createQuerySerializer(options?)Builds the default querySerializer from its object options.
serializePrimitiveParam, serializeObjectParam, serializeArrayParamSerialize one parameter with an OpenAPI style and explode.
createFinalURL(pathname, options)Builds the request URL from baseUrl, the path and the query parameters.
mergeHeaders(...headers)Merges header sets into one Headers; later ones win, and null removes a header.
removeTrailingSlash(url)Removes one trailing /.
ClientPathsWithMethod<Client, Method>, MethodResponse<Client, Method, Path>Types: the paths of a client that have a method, and the data type of one call.

defaultBodySerializer returns by the body type:

  • A raw body (FormData, Blob, URLSearchParams, ArrayBuffer, a typed array) is returned as it is.
  • undefined gives undefined.
  • An object gives string (JSON, or URL-encoded form data with a form Content-Type). With a multipart/form-data Content-Type in headers written as a record literal it gives FormData; with headers of another form (Headers, pairs, a string-typed value) the type is string | FormData.
  • Any other value gives string.

Released under the MIT License. Built on openapi-typescript, openapi-fetch and openapi-react-query.