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.
| Tool | Output | Runtime code | Spec versions | Best for |
|---|---|---|---|---|
| openapi-typescript | One .d.ts file with paths, components and operations | None (pair with openapi-fetch if you want a client) | OpenAPI 3.0 and 3.1 | Teams that want types and keep their own fetch layer |
| orval | Models plus a client per operation (fetch, axios, React Query, SWR, Vue Query, Angular, Zod and more) | Yes, one function or hook per operation | OpenAPI 3.x and Swagger 2.0 | React or Vue apps that want generated hooks |
| openapi-generator (typescript-fetch) | Model files plus API classes in many files | Yes, a full SDK with runtime helpers | Swagger 2.0, OpenAPI 3.0, 3.1 support still marked beta | Organisations already generating SDKs in several languages |
| APIForge | Interfaces, request and response types, and fetch helpers for one endpoint or a whole collection | Optional fetch helpers in the same file | OpenAPI 3.0, 3.1 and Swagger 2.0 | Getting types for a few endpoints without installing anything |
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.
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:
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.
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: stringRunning 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.
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:
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}`;
}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.
# 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
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
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.
{
"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"
}
}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 --noEmitIf 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.