How to Generate TypeScript Types from an OpenAPI Spec

Generate TypeScript types from an OpenAPI spec with openapi-typescript, orval or openapi-generator, plus a worked example and the pitfalls generated types hide.

On this page

To generate TypeScript types from an OpenAPI spec, run npx openapi-typescript openapi.yaml -o src/api/schema.d.ts. The command reads an OpenAPI 3.0 or 3.1 document, from a local file or a URL, in JSON or YAML, and writes a single .d.ts file with a type for every schema, path and operation. It adds no runtime code to your bundle. If you also want generated fetch functions or React Query hooks, use orval. If your team already runs openapi-generator for Java or Go clients, its typescript-fetch generator fits that pipeline. If you need types for one or two endpoints right now, paste the spec URL into APIForge and generate them in the browser. Whichever tool you choose, read the pitfalls section before you trust the output: nullable fields, discriminators and readOnly properties are where generated types most often disagree with what the API returns.

Why generate types instead of writing them

A hand-written interface is a copy of the contract, and copies drift. The backend team adds a cancelled status, renames total to totalAmount, or makes note nullable, and your interface keeps compiling against the old shape. The bug shows up at runtime, usually as undefined in a place TypeScript promised a string.

Generated types move that failure to compile time. When the spec changes, you regenerate, and every component that reads the renamed field fails tsc with a file and line number. Hand-writing still makes sense for a two-endpoint integration with a third party that publishes no spec. Past that, the generator is faster and more accurate than a person copying YAML into interfaces.

Generation is only as good as the spec. A response documented as type: object with no properties produces a type that tells you nothing. If you are unsure whether your spec is complete, run it through the OpenAPI validator first, and see the response schema best practices for what a useful response schema contains.

OpenAPI TypeScript generators compared

Four options cover most teams. They differ mainly in how much code they produce: types only, types plus a client, or a full SDK.

ToolOutputRuntime codeSpec versionsBest for
openapi-typescriptOne .d.ts file with paths, components and operationsNone (pair with openapi-fetch if you want a client)OpenAPI 3.0 and 3.1Teams that want types and keep their own fetch layer
orvalModels plus a client per operation (fetch, axios, React Query, SWR, Vue Query, Angular, Zod and more)Yes, one function or hook per operationOpenAPI 3.x and Swagger 2.0React or Vue apps that want generated hooks
openapi-generator (typescript-fetch)Model files plus API classes in many filesYes, a full SDK with runtime helpersSwagger 2.0, OpenAPI 3.0, 3.1 support still marked betaOrganisations already generating SDKs in several languages
APIForgeInterfaces, request and response types, and fetch helpers for one endpoint or a whole collectionOptional fetch helpers in the same fileOpenAPI 3.0, 3.1 and Swagger 2.0Getting types for a few endpoints without installing anything
Versions checked: openapi-typescript 7.13.0, orval 8.45.0, @openapitools/openapi-generator-cli 2.42.0.

openapi-typescript

openapi-typescript is the smallest step from spec to types. It needs Node and TypeScript 5, and nothing ships to the browser because the output is declarations only. Useful flags: --root-types exports each schema as a top-level type such as SchemaOrder (add --root-types-no-schema-prefix to get plain Order), --enum emits TypeScript enums instead of unions, --immutable marks every property readonly, and --check fails when the file on disk is stale. Version 7 rejects Swagger 2.0 files with "Unsupported Swagger version: 2.x", so convert those to OpenAPI 3 first. The Swagger vs OpenAPI guide explains the differences between the two formats.

orval

orval generates models and a callable client from a config file. Set client to fetch for plain async functions, or to react-query, swr or vue-query for hooks. It also accepts Swagger 2.0 input directly, which openapi-typescript does not.

TypeScriptorval.config.ts
import { defineConfig } from "orval";

export default defineConfig({
  orders: {
    input: "./openapi.yaml",
    output: {
      target: "./src/api/orders.ts",
      client: "fetch",
    },
  },
});

Run npx orval and it writes the target file. Running it against the example spec later in this guide produced enums as a const object plus a type, which gives you both a union type and a runtime list of values:

TypeScriptsrc/api/orders.ts (excerpt)
export type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus];

export const OrderStatus = {
  pending: 'pending',
  paid: 'paid',
  shipped: 'shipped',
  cancelled: 'cancelled',
} as const;

export interface Order {
  readonly id: string;
  status: OrderStatus;
  total: number;
  /** @nullable */
  note?: string | null;
  createdAt: string;
  payment: CardPayment | BankPayment;
  metadata?: OrderMetadata;
}

export const getOrder = async (orderId: string, options?: RequestInit): Promise<getOrderResponse> => {
  // fetch call, status handling and JSON parsing generated here
};

openapi-typescript vs orval

Pick openapi-typescript when you already have an HTTP layer (a fetch wrapper with auth and retries, TanStack Query written by hand, tRPC alongside REST) and want the compiler to check it against the spec. Pair it with openapi-fetch and the URL, path params, query params and response body are all checked against the paths type. Pick orval when you would otherwise write one hook per endpoint by hand. It writes those hooks for you, and with --mock it can also generate MSW handlers for tests. The trade-off is ownership. orval's generated client is code you ship and debug, while openapi-typescript's output disappears at build time.

openapi-generator typescript-fetch

The npm package @openapitools/openapi-generator-cli is a wrapper that downloads the Java generator, so it needs a Java runtime on the machine and in CI. The command is npx @openapitools/openapi-generator-cli generate -i openapi.yaml -g typescript-fetch -o src/generated. It produces a models folder, an apis folder and a runtime.ts. That is a lot of files for a frontend that only needs types, so reach for it when the same pipeline already generates Kotlin, Swift or Go clients from one spec.

APIForge in the browser

In the APIForge workspace, paste a spec URL, a Swagger UI page or a JSON file, pick an endpoint, and open the TypeScript tab. Switch the scope to Collection to cover every operation at once. The output is an interface per schema, a Request type holding pathParams, queryParams, headers and body, a Response type for the 2xx bodies, an ErrorResponse type when error bodies are documented, and a small fetch helper per operation. Enums come out as string-literal unions and spec descriptions become JSDoc comments. An AI model writes the types from the resolved spec, so review them before you commit them. For a long-lived project, wire up openapi-typescript or orval as well so regeneration is repeatable. If your spec lives behind Swagger UI, extract the OpenAPI JSON from Swagger UI first.

Worked example: from schema to types

This spec has one operation and the schema features that cause most surprises: a readOnly id, an enum, a nullable field written the 3.1 way, a date-time, a discriminated oneOf and a map.

YAMLopenapi.yaml
openapi: 3.1.0
info:
  title: Orders API
  version: 1.0.0
paths:
  /orders/{orderId}:
    get:
      operationId: getOrder
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The order
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "404":
          description: Not found
components:
  schemas:
    Order:
      type: object
      required: [id, status, total, createdAt, payment]
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
        status:
          type: string
          enum: [pending, paid, shipped, cancelled]
        total:
          type: number
        note:
          type: [string, "null"]
        createdAt:
          type: string
          format: date-time
        payment:
          oneOf:
            - $ref: "#/components/schemas/CardPayment"
            - $ref: "#/components/schemas/BankPayment"
          discriminator:
            propertyName: method
            mapping:
              card: "#/components/schemas/CardPayment"
              bank: "#/components/schemas/BankPayment"
        metadata:
          type: object
          additionalProperties:
            type: string
    CardPayment:
      type: object
      required: [method, last4]
      properties:
        method:
          type: string
          const: card
        last4:
          type: string
    BankPayment:
      type: object
      required: [method, iban]
      properties:
        method:
          type: string
          const: bank
        iban:
          type: string

Running npx openapi-typescript openapi.yaml -o src/api/schema.d.ts with version 7.13.0 produced the following. The paths interface and the empty never entries are trimmed.

TypeScriptsrc/api/schema.d.ts (trimmed)
export interface components {
    schemas: {
        Order: {
            /** Format: uuid */
            readonly id: string;
            /** @enum {string} */
            status: "pending" | "paid" | "shipped" | "cancelled";
            total: number;
            note?: string | null;
            /** Format: date-time */
            createdAt: string;
            payment: components["schemas"]["CardPayment"] | components["schemas"]["BankPayment"];
            metadata?: {
                [key: string]: string;
            };
        };
        CardPayment: {
            /**
             * @description discriminator enum property added by openapi-typescript
             * @enum {string}
             */
            method: "card";
            last4: string;
        };
        BankPayment: {
            /** ... */
            method: "bank";
            iban: string;
        };
    };
}
export interface operations {
    getOrder: {
        parameters: {
            path: {
                orderId: string;
            };
            /* query, header, cookie: never */
        };
        responses: {
            200: {
                content: {
                    "application/json": components["schemas"]["Order"];
                };
            };
            404: {
                content?: never;
            };
        };
    };
}

Each line traces back to the spec. id is readonly, status is a union of four strings, note is optional because it is missing from required and accepts null because of the type array, createdAt is a plain string with the format kept as a comment, and metadata became an index signature. To use the types, import them directly or let openapi-fetch infer everything from the path:

TypeScriptsrc/api/orders.ts
import createClient from "openapi-fetch";
import type { paths, components } from "./schema";

export type Order = components["schemas"]["Order"];

const api = createClient<paths>({ baseUrl: "https://api.example.com" });

export async function loadOrder(orderId: string): Promise<Order> {
  const { data, response } = await api.GET("/orders/{orderId}", {
    params: { path: { orderId } },
  });
  if (!response.ok || !data) {
    throw new Error(`GET /orders/${orderId} failed with ${response.status}`);
  }
  return data;
}

function paymentLabel(payment: Order["payment"]): string {
  // The discriminator narrows the union: last4 exists only on card payments.
  return payment.method === "card" ? `Card ending ${payment.last4}` : `Bank ${payment.iban}`;
}
Type-checks with tsc --strict against openapi-fetch 0.17.0.

Pitfalls that make generated types lie

nullable in 3.0 vs type arrays in 3.1

OpenAPI 3.0 marks a nullable field with nullable: true. OpenAPI 3.1 aligned with JSON Schema 2020-12 and dropped that keyword: you list "null" in a type array instead. openapi-typescript 7.13 maps both forms to string | null, and in our test it even honoured nullable: true inside a 3.1 file. Do not rely on that. A JSON Schema validator treats nullable as an unknown keyword and ignores it, so your response validation and your types will disagree. When you bump the openapi field from 3.0.3 to 3.1.0, convert every nullable: true in the same commit.

YAML
# OpenAPI 3.0
note:
  type: string
  nullable: true

# OpenAPI 3.1 (JSON Schema 2020-12)
note:
  type: [string, "null"]

# Both generate:
#   note?: string | null;

oneOf with a discriminator but no mapping

This one surprised us while writing the example. With a discriminator that has only propertyName, openapi-typescript 7.13 replaced the const values with the schema names. CardPayment.method became the literal "CardPayment", although the schema says const: card. Code that checks payment.method === "card" then fails to compile, or worse, compiles against a cast and never matches at runtime. Adding an explicit mapping fixed it.

AvoidDiscriminator without mapping

YAML
payment:
  oneOf:
    - $ref: "#/components/schemas/CardPayment"
    - $ref: "#/components/schemas/BankPayment"
  discriminator:
    propertyName: method

# openapi-typescript 7.13 output for CardPayment:
#   method: "CardPayment";
# The server sends "card", so payment.method === "card" never matches.

Without mapping, OpenAPI's rule is that the discriminator value equals the schema name, so the generator is following the spec. The mismatch comes from the server sending "card".

PreferDiscriminator with explicit mapping

YAML
payment:
  oneOf:
    - $ref: "#/components/schemas/CardPayment"
    - $ref: "#/components/schemas/BankPayment"
  discriminator:
    propertyName: method
    mapping:
      card: "#/components/schemas/CardPayment"
      bank: "#/components/schemas/BankPayment"

# Output for CardPayment is now:
#   method: "card";

The mapping states the wire values, and the generated union narrows correctly on payment.method.

Enums as unions or TypeScript enums

By default openapi-typescript emits "pending" | "paid" | "shipped" | "cancelled", a string-literal union that costs nothing at runtime. The --enum flag switches to a TypeScript enum, which does emit JavaScript and is not supported by the type-stripping mode Node uses to run .ts files directly. Unions are the safer default. If you need the list of values at runtime for a select menu, use --enum-values, or orval's const-object output shown earlier.

additionalProperties and empty objects

In JSON Schema an object allows extra properties unless additionalProperties is false, but the generated type lists only the declared properties. For maps, declare additionalProperties with a schema, as metadata does above, and you get a typed index signature; additionalProperties: true gives [key: string]: unknown. An object schema with no properties at all becomes Record<string, never>, which rejects every key. If your spec has many of those placeholders, pass --empty-objects-unknown, then fix the spec so they describe real fields.

readOnly and writeOnly

readOnly: true on id became readonly id: string, which stops you reassigning the field. It still requires id when you build a create request from the same type. writeOnly is weaker: in our test a writeOnly password came out as a plain required password: string, so the type claims responses contain it. Fix it in the spec by splitting the shape into separate OrderCreate and Order schemas. Without that, use Omit<Order, "id"> for request bodies, or try --read-write-markers, which wraps those properties in $Read<T> and $Write<T> marker types.

date-time is a string, int64 is a number

JSON has no date type, so format: date-time arrives as a string and the generated type says string. Parse it at the boundary with new Date() or Temporal and keep the conversion in one place. format: int64 maps to number, which loses precision above 2^53 - 1. If your IDs are 64-bit integers, ask the backend to send them as strings. format: uuid and format: email are also plain strings; the format survives only as a comment.

Keep generated types in sync in CI

Commit the generated file so reviewers see the type diff next to the spec diff, then check it in CI. The --check flag regenerates in memory, compares with the file on disk, and exits with code 1 and "Generated types are not up-to-date!" when they differ.

JSONpackage.json
{
  "scripts": {
    "api:types": "openapi-typescript openapi.yaml -o src/api/schema.d.ts",
    "api:types:check": "openapi-typescript openapi.yaml -o src/api/schema.d.ts --check"
  }
}
YAML.github/workflows/api-types.yml
name: api-types
on:
  pull_request:
    paths: ["openapi.yaml", "src/api/schema.d.ts"]
jobs:
  types-in-sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx openapi-typescript openapi.yaml -o src/api/schema.d.ts --check
      - run: npx tsc --noEmit
The tsc step catches code that no longer matches the regenerated types.

If the spec lives in a backend repository, point the input at its raw URL and run the job on a schedule as well as on pull requests, so a backend change that lands without a frontend update shows up as a red build. orval has no --check flag; run npx orval and then git diff --exit-code on the output folder to get the same result. Types catch shape changes at compile time, and the server can still return something the spec never promised. To catch that drift, validate API responses against your OpenAPI schema in your tests.

Frequently asked questions

For types alone, openapi-typescript: one .d.ts file, no runtime code, and a --check flag for CI. For generated fetch functions or React Query hooks, orval. For a multi-language SDK pipeline, openapi-generator's typescript-fetch generator.

Yes. orval and openapi-generator read Swagger 2.0 directly, and APIForge parses it in the browser. openapi-typescript 7 rejects 2.0 files, so convert them to OpenAPI 3 first or use one of the other tools.

Usually yes. Reviewers see type changes next to spec changes, builds do not depend on a generator, and openapi-typescript --check in CI fails the build when someone edits the spec without regenerating.

JSON has no date type, so the API sends an ISO 8601 string and the type reflects that. Convert it to a Date where the data enters your app rather than changing the generated type.

Sources

  1. openapi-typescript CLI documentationopenapi-ts.dev
  2. orval documentationorval.dev
  3. typescript-fetch generator optionsOpenAPI Generator
  4. OpenAPI Specification 3.1.0, Discriminator ObjectOpenAPI Initiative