Skip to content

OpenAPI TypeScript Client Generator

Turn an OpenAPI 3 document into a typed TypeScript fetch client with no dependencies — and see exactly which parts the spec left too vague to type.

OpenAPI document

YAML

Output

Petstore

4 operations · 2 types · 3.1 KB
/**
 * Generated from an OpenAPI document. No runtime dependencies — uses fetch.
 *
 * Anything the generator could not resolve is typed `unknown` rather than
 * `any`, so the compiler still makes you handle it.
 */

export type RequestOptions = {
  /** Base URL, without a trailing slash. */
  baseUrl?: string;
  headers?: Record<string, string>;
  signal?: AbortSignal;
};

export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly statusText: string,
    readonly body: unknown,
  ) {
    super(`${status} ${statusText}`);
    this.name = "ApiError";
  }
}

function buildQuery(params: Record<string, unknown>): string {
  const search = new URLSearchParams();
  for (const [key, value] of Object.entries(params)) {
    if (value === undefined || value === null) continue;
    if (Array.isArray(value)) {
      for (const item of value) search.append(key, String(item));
    } else {
      search.set(key, String(value));
    }
  }
  const query = search.toString();
  return query ? `?${query}` : "";
}

async function request<T>(
  method: string,
  url: string,
  options: RequestOptions,
  body?: unknown,
): Promise<T> {
  const response = await fetch(`${options.baseUrl ?? ""}${url}`, {
    method,
    headers: {
      ...(body === undefined ? {} : { "Content-Type": "application/json" }),
      ...options.headers,
    },
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: options.signal,
  });

  // fetch resolves for 4xx and 5xx, so this check is the one thing every
  // hand-written client forgets.
  if (!response.ok) {
    const text = await response.text();
    let parsed: unknown = text;
    try {
      parsed = text ? JSON.parse(text) : undefined;
    } catch {
      /* keep the raw text */
    }
    throw new ApiError(response.status, response.statusText, parsed);
  }

  if (response.status === 204) return undefined as T;
  const text = await response.text();
  return (text ? JSON.parse(text) : undefined) as T;
}

// ------------------------------ schemas ------------------------------

export type Pet = {
  id: number;
  name: string;
  tag?: string;
  status?: "available" | "pending" | "sold";
};

export type NewPet = {
  name: string;
  tag?: string;
};

// ----------------------------- operations -----------------------------

/**
 * List all pets
 *
 * GET /pets
 */
export async function listPets(query: { limit?: number; status: "available" | "pending" | "sold" }, options: RequestOptions = {}): Promise<Pet[]> {
  return request<Pet[]>("GET", `/pets${buildQuery(query)}`, options);
}

/**
 * Add a pet
 *
 * POST /pets
 */
export async function createPet(body: NewPet, options: RequestOptions = {}): Promise<Pet> {
  return request<Pet>("POST", `/pets`, options, body);
}

export async function getPet(petId: string, options: RequestOptions = {}): Promise<Pet> {
  return request<Pet>("GET", `/pets/${encodeURIComponent(String(petId))}`, options);
}

export async function deletePet(petId: string, options: RequestOptions = {}): Promise<void> {
  return request<void>("DELETE", `/pets/${encodeURIComponent(String(petId))}`, options);
}

Operations

4
GET/petslistPets() → Pet[]
POST/petscreatePet() → Pet
GET/pets/{petId}getPet() → Pet
DELETE/pets/{petId}deletePet() → void

Every operation and schema was typed from the document — nothing fell back to unknown.

Generated in your browser — an unpublished specification is never uploaded. External $ref targets are not fetched; anything unresolved is typed unknown and listed above rather than being quietly typed any.

About the OpenAPI TypeScript Client Generator

Paste an OpenAPI 3 document and this OpenAPI TypeScript client generator returns a typed fetch client: one exported type per component schema, one async function per operation, path parameters encoded, query strings built, and the response.ok check that fetch does not do for you. There is nothing to install and no import in the output — it is plain TypeScript over the platform's own fetch.

The rule that makes the result trustworthy is the one inherited from the OpenAPI Validator on this site: anything the generator could not resolve is typed unknown and listed, never quietly typed any. An external $ref, a response with no schema, a body that is not JSON — each one appears in the Not typed exactly panel with the reason. A generated client that types half your payload as any passes code review and fails in production; one that types it unknown does not compile until you deal with it.

It runs entirely in your browser, which is the practical difference from the CLI generators. openapi-typescript and openapi-generator are better tools for a build pipeline — they are configurable, they support many languages, and they belong in CI. But seeing what the output actually looks like for your spec should not require an npx download or a Java runtime, and pasting an unpublished internal specification into a web tool should not mean uploading it anywhere.

Formats are deliberately not over-interpreted. A string with format: date-time is typed string, because that is what arrives over the wire — typing it Date would be a claim about JSON that JSON does not support.

  • One TypeScript type per components/schemas entry, with required and optional properties distinguished correctly
  • Enums rendered as literal unions, so the compiler catches a wrong status string
  • Nullability handled in both spellings: 3.1's type: [string, "null"] and 3.0's nullable: true
  • oneOf and anyOf as unions, allOf as an intersection
  • Path parameters encoded with encodeURIComponent; query parameters assembled with URLSearchParams, including repeated array values
  • 204 responses typed void; a colliding function name renamed and reported rather than dropped
  • Standalone functions or a single class, with an ApiError carrying the status and parsed body
  • Zero dependencies in the output — no imports at all

How to use it

  1. Paste your OpenAPI 3.0 or 3.1 document, or open it from a file. JSON and YAML are both fine.
  2. Choose standalone functions or one class. Functions tree-shake better; the class is tidier when every call shares a base URL and auth header.
  3. Read the Not typed exactly panel before using the output. Everything in it is a place the specification did not say enough for the generator to be sure.
  4. Copy the client into your project as a single file. There is nothing to install.
  5. Pass a baseUrl and any auth headers through the options argument on each call, or once to the class constructor.

Real-world use cases

Frontend & client developers

Paste a spec to see the actual shape of a typed client before deciding whether to install a CLI generator — this is a preview and evaluation step, not a replacement for the generator a real build pipeline should use.

Backend developers

Generate a one-off client for a small internal API that will only ever be called from one other service, where setting up openapi-typescript in a build step costs more than the client is worth.

API product & platform teams

Hand a consumer team a working TypeScript client immediately after publishing a spec, without waiting on their build tooling to be wired up to fetch and codegen it themselves.

QA & API testers

Get typed request functions for a spec under test quickly, to write a script against an endpoint without hand-rolling fetch calls and guessing at response shapes.

Developers evaluating a specification

Check whether a third-party or vendor-supplied spec is complete enough to generate a usable client at all — the Not typed exactly panel surfaces exactly where the document falls short before you commit to it.

Examples

An operation with a path parameter

Input
/pets/{petId}:
  get:
    operationId: getPet
    responses:
      "200":
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Pet"
Output
export async function getPet(
  petId: string,
  options: RequestOptions = {},
): Promise<Pet> {
  return request<Pet>("GET", `/pets/${encodeURIComponent(String(petId))}`, options);
}

The parameter is encoded, not interpolated raw — a path parameter containing a slash would otherwise change which endpoint you called.

Required and optional query parameters

Input
parameters:
  - name: limit
    in: query
    schema: { type: integer }
  - name: status
    in: query
    required: true
    schema:
      type: string
      enum: [available, pending, sold]
Output
export async function listPets(
  query: { limit?: number; status: "available" | "pending" | "sold" },
  options: RequestOptions = {},
): Promise<Pet[]>

required: true makes the field non-optional, and the enum becomes a union — passing status: "sold " with a trailing space stops compiling.

An external $ref is not guessed

Input
schema:
  $ref: "./common.yaml#/components/schemas/Thing"
Output
Promise<unknown>

Not typed exactly:
GET /a: $ref to "./common.yaml#/components/schemas/Thing"
points outside this document, so it was typed unknown
rather than guessed.

The alternative — typing it any — would compile everywhere and check nothing. unknown forces a decision at the call site.

A 204 with no content

Input
responses:
  "204":
    description: Deleted
Output
export async function deletePet(petId: string, options: RequestOptions = {}): Promise<void>

The runtime returns early for 204 rather than trying to parse an empty body as JSON, which is the usual cause of "Unexpected end of JSON input" from a generated client.

Common errors

MessageCauseFix
This is a Swagger 2.0 document, which this generator does not readThe document declares swagger: "2.0" rather than openapi: 3.x.Convert it to OpenAPI 3 first. This is a refusal rather than a best effort on purpose: request bodies and response shapes moved between the two versions, so reading a 2.0 document as 3.x produces a client that is wrong in exactly the places you would not check.
Cannot find name 'Missing' in the generated codeA $ref pointed at #/components/schemas/Missing and no such schema is defined.Fix the specification. The generator emits the type name rather than falling back to unknown precisely so the compiler reports it — a dangling reference is a bug in the document, not something for a client to paper over.
Property 'x' does not exist on type 'unknown'That part of the response could not be typed — check the Not typed exactly panel for the reason.Usually the specification declares no schema for the response, or the content type is not application/json. Add the schema, or narrow the unknown yourself at the call site.
Two operations generated the same function nameDuplicate operationId values, or two paths that reduce to the same generated name.Nothing breaks — the second is suffixed and the rename is reported. Give the operations distinct operationId values to control the naming yourself.
Unexpected end of JSON input at runtimeAn endpoint returned an empty body with a status other than 204, so the client tried to parse it.The generated runtime handles 204 and an empty body. If it happens on another status, the API is returning an empty body where its specification promises content — worth fixing on the server.
The request fails with 401 and no auth header is being sentSecurity schemes are not turned into parameters — the generator does not know whether your token belongs in a header, a cookie or a query string at call time.Pass it through options.headers, or construct the class with the header once. This is a deliberate limit rather than an oversight: guessing the auth mechanism would produce a client that looks authenticated and is not.

Frequently asked questions

Is my specification uploaded anywhere?

No. Parsing and generation both run in your browser, which is the reason this exists alongside the CLI tools — an unpublished internal API specification is exactly the kind of document you should not paste into a web service. Disconnect from the network and it keeps working.

How does this compare to openapi-typescript or openapi-generator?

They are better tools for a build pipeline and you should use them there: more configurable, many target languages, and they run in CI so the client cannot drift from the specification. What they are not is quick to try — openapi-generator needs a Java runtime, and both need an install before you can see whether the output suits you. This gives you the whole client from a paste, with no dependencies in the output. Use it to evaluate, prototype, or generate a one-off client for a small internal API.

Why is a date typed as string rather than Date?

Because that is what the API sends. format: date-time is an annotation on a string; JSON has no date type, and fetch will not revive one. A generator that typed it Date would be making a claim the runtime does not honour, and every call site would need a cast the moment you passed the value back. Parse it yourself where you need a Date.

Does it support security schemes, servers or webhooks?

Not as generated code. Auth goes through options.headers because the generator cannot know when your token is available or how it should be attached — and a client that silently omitted a required credential would look correct. The servers array is not read either: pass baseUrl explicitly, which also handles the common case where the specification's server URL is not the environment you are calling. Webhooks describe inbound requests, so there is no client function to generate.

What happens with a recursive schema?

It works. A schema in components/schemas is emitted as a named type and referenced by name, so a node pointing at itself becomes next?: Node — which is valid TypeScript. Cycles through inline schemas are guarded and resolve to unknown rather than recursing forever.

Can I generate a client for one tag or a few endpoints?

Not from the UI — the whole document is generated. The output is one plain file, so deleting the functions you do not want is straightforward, and unused exports tree-shake out of a bundle anyway. For a filtered generation as part of a build, the CLI generators support it properly.

Is the generated code the same every time?

Yes. Generation is deterministic — the same document with the same options produces byte-identical output — so you can commit the result and see a real diff when the specification changes. Operations come out in document order, and types in the order they appear under components/schemas.

Should I commit this generated client, or wire up a CLI generator instead?

For anything more than a small internal API or a prototype, wire up a CLI generator in your build — it stays in sync with the specification automatically, and a committed file from this page will silently drift the first time the spec changes and nobody regenerates it. This tool is honest about being a paste-and-preview step: use it to see the shape of the client fast, then decide whether the investment of setting up openapi-typescript or openapi-generator in CI is worth it for your case.

Last updated