Tutorial

How to generate JSON API docs, TypeScript types, and an API client from OpenAPI: Schema to codegen

Hand-written docs, hand-written types, and a hand-written fetch wrapper that each drift cost more than the endpoint. Treat the OpenAPI schema as the contract so docs, types, and the client grow from the same JSON.

The expensive part of GET /invoices is rarely the handler. It is the three artifacts that go stale on their own: human API docs, TypeScript types for the compiler, and a fetch wrapper for runtime. Teams still paste sample JSON into a wiki, re-declare the same fields in types.ts, and concatenate URLs in api.ts. An OpenAPI schema folds those into one machine-readable contract. You maintain openapi.json (or YAML); the doc site, the type file, and the client are generated from it. Change a field once, all three move. MCP and JSON Schema covers tool inputs; structured output covers the model’s final answer. This article is only the HTTP JSON API: schema to docs, types, and client. Spec: OpenAPI 3.1. Default generator: openapi-typescript.

One schema, three outputs: docs, types, client

Pin the goal. OpenAPI docs are not another Markdown book; they are a render of the same spec. Scalar, Swagger UI, and Redoc are readers. TypeScript types are not a second handwritten interface; they are a projection of paths and components.schemas. An API client is not “one more axios wrapper”; it is functions generated from operationId or paths, with request and response types attached. If two of the three are handwritten, the schema is a poster on the wall.

Pick tools later; get a contract first. Export a spec from FastAPI, Nest, Spring, or Go annotations, or write openapi.json first and implement against it. What you must not do: write the client first and invent docs from how callers “feel.” There is no single source of truth, and CI cannot decide who is right. If an agent uses REST as a tool, it should read this OpenAPI, not a second MCP inputSchema—two contracts drift immediately. Layers: the MCP article above.

OpenAPI 3.1 lines components.schemas up with JSON Schema 2020-12: nullable is type: ["string", "null"], not 3.0’s nullable: true. That cuts “docs say optional, types say required.” The object you inspect in JSONVue should be the object the generator eats—not a dialect you translate on export.

Output Where it comes from Do not hand-write
JSON API docsThe same OpenAPI JSON, rendered by Scalar / Swagger UISample JSON and screenshots in a wiki
TypeScript typesopenapi-typescript emits paths / componentsA parallel interface Invoice
API client / hooksopenapi-fetch, Orval, or hey-api emit call functionsHand-built URLs and as Invoice

Write the OpenAPI JSON first: paths, components, operationId

A generator cannot outrun spec quality. Minimum: openapi version, info, paths, and shared models in components.schemas. Give every operation a stable operationId (listInvoices, createInvoice); the client uses it as a function name. When a path changes, do not rename the id. Declare application/json on request and success response, and $ref components instead of pasting the field table on every path.

Error responses belong in the contract. Give 400 / 401 / 404 / 422 JSON Schemas so the client can form a readable union instead of unknown. Put query and path params in parameters; do not hide them in the body and “agree verbally” in the docs. Put security (Bearer, OIDC) in securitySchemes so the generated client knows where the token goes. The auth protocol itself: OAuth 2.1—on REST, the token still lives on the header, not in the JSON envelope.

Below is a minimal invoices API. It is not a product spec, but it is enough to spin docs, types, and a client at once. Invoice appears once; list and create both reference it. Format it locally to confirm it parses, then check that $ref values resolve.

{
  "openapi": "3.1.1",
  "info": {
    "title": "Invoices API",
    "version": "2026.09.18"
  },
  "paths": {
    "/invoices": {
      "get": {
        "operationId": "listInvoices",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["draft", "paid", "void"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Invoice list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["items"],
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": { "$ref": "#/components/schemas/Invoice" }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createInvoice",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/InvoiceDraft" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/Invoice" }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "InvoiceDraft": {
        "type": "object",
        "required": ["customerId", "amount"],
        "properties": {
          "customerId": { "type": "string" },
          "amount": { "type": "integer", "minimum": 1 },
          "note": { "type": ["string", "null"] }
        }
      },
      "Invoice": {
        "allOf": [
          { "$ref": "#/components/schemas/InvoiceDraft" },
          {
            "type": "object",
            "required": ["id", "status"],
            "properties": {
              "id": { "type": "string" },
              "status": {
                "type": "string",
                "enum": ["draft", "paid", "void"]
              }
            }
          }
        ]
      }
    }
  }
}

Generate JSON API docs people can read

The right doc site copies openapi.json into a static folder at build time and hangs a reader next to it. Scalar, Swagger UI, and Redoc all read the same file. Do not treat “docs” as another Markdown stack you then diff against the spec in CI. Humans edit docs, machines edit the spec, and they fork the next day.

A reader can only show what the spec contains: no example, no copy-paste sample; no error responses, no failure story; empty description, only a path on the page. Finish the contract before you generate docs. Preview the reader in a browser; inspect the spec itself in JSONVue—faster than hunting a broken $ref in a huge YAML file.

Put the doc URL in info.contact or an internal portal, but do not keep a second “field table for product.” Enums and required flags the product cares about already live in Schema enum / required. Two field tables are the classic drift door.

Generate TypeScript types with openapi-typescript

openapi-typescript is a sound default: zero runtime, OpenAPI 3.0 and 3.1, paths and components out. One command: npx openapi-typescript openapi.json -o src/api/schema.d.ts. Remote specs work; production CI should pin a file in the repo so the build does not hit a URL that moves.

import type { paths, components } from "./schema";

type Invoice = components["schemas"]["Invoice"];
type ListOk =
  paths["/invoices"]["get"]["responses"][200]["content"]["application/json"];

export type { Invoice, ListOk };

Index by path; do not declare another interface. paths["/invoices"]["get"]["responses"][200]["content"]["application/json"] is the list success body. Reuse models via components["schemas"]["Invoice"]. When a path changes, the compiler fails at call sites instead of leaving you to search three Invoice types.

Types are not runtime checks. The generated .d.ts does not exist in the browser. The wire can still deliver JSON with missing fields; a green compile only means you wrote the client against the contract. For a runtime gate, generate Zod with Orval/Kubb, or Schema-validate critical responses. Do not treat “TypeScript is green” as the only QA.

Generate an API client: openapi-fetch, Orval, hey-api

If you only need types plus calls, pair openapi-fetch: createClient<paths>({ baseUrl }), then client.GET("/invoices", { params: { query } }). Body, query, and path params are typed; you cannot send a createInvoice body to list. That is the thin 2026 frontend default: few files, your own fetch, no generated warehouse.

import createClient from "openapi-fetch";
import type { paths } from "./schema";

const client = createClient({
  baseUrl: "https://api.example.com",
  headers: { Authorization: "Bearer …" }
});

const { data, error } = await client.GET("/invoices", {
  params: { query: { status: "paid" } }
});

const created = await client.POST("/invoices", {
  body: { customerId: "cus_1", amount: 1999, note: null }
});

When you want React Query hooks, Zod, and MSW mocks, use Orval. It grows functions and hooks from the same spec—fits apps that already use Query as the data layer. hey-api (@hey-api/openapi-ts) is SDK functions plus interceptors when you want operationId as method names without thousands of files. OpenAPI Generator’s typescript-axios template still works; the output is heavier, and new work need not start there.

On every pipeline, do not edit generated files. Change behavior in the spec, the generator config, or a handwritten wrapper (timeouts, retries, tenant headers). Editing generated output abandons the contract. Put tokens on the Authorization header, not in the JSON body.

Tool What you get When to pick it
openapi-typescript + openapi-fetchTypes + a thin fetch client, near-zero runtimeDefault start; you own caching
OrvalTypes + functions + Query hooks + optional Zod/MSWYou already use TanStack Query; mocks should follow the spec
hey-api / KubbSDK method names, or plugin multi-outputYou want an interceptor platform, or one file per operation

Stop drift in CI; check the contract in JSONVue

When the contract changes, regenerate. In CI: lint the spec (Spectral or Redocly) → run the generator → git diff --exit-code on the generated tree. Fail on diff so the author commits new types. Do not run codegen once on a laptop and forget it. Backend changed a field, frontend types are last week’s—that is the standard accident.

npx --yes openapi-typescript openapi.json -o src/api/schema.d.ts
npx --yes @redocly/cli lint openapi.json
git diff --exit-code -- src/api/schema.d.ts

A lab session produces at least three JSON documents: the OpenAPI spec, a 200 body, a 422 error body, plus a slim fixture you feed the generator. They have different jobs; do not mash them into one “generic validate.” Keep one failure of each: a broken $ref, a missing operationId, a response whose content omitted application/json, two specs that only changed an enum.

You can finish in the browser:JSON formatter to see if the spec parses;JSON Schema validator for components.schemas vs a real body;JSON Diff to compare two OpenAPI versions or model arguments vs a downstream body. Data stays on this machine. Further reading: MCP and JSON Schema, structured output, and generating tool shells from the same field table.

Related: MCP and JSON Schema, AI structured output, AI JSON generation errors, what MCP is.

FAQ

YAML or JSON for the spec?

Generators eat both. YAML diffs more kindly for humans; send JSON to the browser and JSONVue. Pin one source file in CI; treat the other as a build artifact so two sources do not edit each other.

Can I generate types only, no client?

Yes, and you often should. openapi-typescript emits only .d.ts. An existing axios wrapper can start consuming those types. When paths and bodies are fenced by types, switch to openapi-fetch or Orval. That is more controllable than generating three thousand files on day one.

OpenAPI 3.0 or 3.1 for a new project?

New contracts: 3.1, so you align with JSON Schema 2020-12 and drop a dialect layer on nullability and const. Do not force-upgrade a 3.0 estate; confirm your generator (openapi-typescript 7.x, Orval 8) documents 3.1, then migrate. Upgrade Swagger 2.0 to 3.x before you talk about a client.

Can OpenAPI and MCP inputSchema share one document?

Business fields can share one canonical JSON Schema, then generate an OpenAPI requestBody and an MCP inputSchema. Do not hand a whole OpenAPI file to an MCP client as a tool catalog—transport, auth, and method names are different layers. One field table, two shells, drifts less than copy-paste.

Summary and next steps

OpenAPI codegen is one sentence: a machine-readable schema grows human JSON API docs, compile-time TypeScript types, and a typed client. Hand-write any one of them and the contract stops being a contract.

Ship in this order: openapi.json parses, has operationId, and lists error responses; hang a doc reader; generate types; then pick a client. Keep the spec, a success body, and a failure body in JSONVue on this machine. Tool inputs: MCP and JSON Schema. Model answers: structured output.