Compile-Time Type Generation from OpenAPI
When the contract and the code disagree, the contract is right and the code ships a bug. Compile-time type generation closes that gap by deriving TypeScript types directly from your OpenAPI document, so a changed response shape becomes a red squiggle in the editor and a failed tsc run in CI instead of a 2 a.m. incident. This guide extends the broader Schema Design & Validation Patterns and focuses on a single-source-of-truth workflow with openapi-typescript 7, a typed openapi-fetch client, correct $ref and circular-reference handling, and a CI gate that keeps generated types from drifting. It pairs naturally with the OpenAPI Specification Deep Dive for spec authoring and with Runtime Validation with Zod when you also need to verify untrusted payloads at runtime.
The pipeline below is deliberately one-directional: the spec is the source, everything downstream is generated, and nothing hand-edits the output. Static types are erased at build time, so this approach adds correctness without adding a single byte to your shipped bundle.
When to Use This Approach
Reach for compile-time type generation when these conditions hold:
- You already have, or can adopt, an OpenAPI document as the authoritative contract. If the spec is generated from code instead, read Schema-First vs Code-First Workflows first to decide which direction your team should own.
- A TypeScript client or BFF consumes the API and you want path, parameter, request-body, and response shapes verified by
tscrather than by hand-written interfaces. - The contract changes often enough that drift between spec and client types is a real, recurring failure mode.
- You want zero runtime cost. Generated types disappear at compile time; nothing ships to the browser or server bundle.
- You can enforce regeneration in CI. The value collapses if engineers can merge a spec change without updating the types.
Avoid or supplement this approach when you must validate data you do not control — third-party webhooks, user input, message-queue payloads. Static types describe the contract, not reality on the wire. For that boundary, layer in Runtime Validation with Zod so a malformed payload is rejected, not silently trusted.
Prerequisites
Pin an exact toolchain so local output is byte-identical to CI output. Drift between generator versions is the single most common cause of false CI failures.
| Dependency | Version | Role |
|---|---|---|
openapi-typescript |
7.x | Generates the .ts type declarations from the spec |
openapi-fetch |
0.13.x | Tiny typed fetch wrapper that consumes the generated paths type |
typescript |
5.x | Compiler; satisfies, const type params, recursive types |
@redocly/cli |
1.x | Lints and bundles the spec (resolves external $ref) |
# Install the toolchain as dev dependencies (runtime client stays small).
npm install --save-dev openapi-typescript@7 typescript@5 @redocly/cli@1
npm install openapi-fetch@0.13 # openapi-fetch ships ~2KB of runtime code
# Verify the pinned versions resolved as expected.
npx openapi-typescript --version # → 7.x.x
npx tsc --version # → Version 5.x.x
A tsconfig.json with strict enabled is required — without it, generated nullable and optional fields collapse to looser types and the safety guarantee evaporates.
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"exactOptionalPropertyTypes": true,
"moduleResolution": "bundler"
}
}
Step 1: Validate and Bundle the Spec
Generation is only as trustworthy as the spec it reads. Lint first to catch malformed pointers, then bundle so every external $ref is resolved into one self-contained document. Generating from a bundled spec eliminates a whole class of “cannot resolve reference” failures and makes circular-reference handling deterministic. The mechanics of resolving and untangling references are covered in depth in fixing OpenAPI $ref circular reference errors.
# Lint structure, required arrays, and reference targets.
npx redocly lint ./api/openapi.yaml
# Resolve external + remote $ref into a single bundled document.
# Internal recursive ($ref to a parent) refs are intentionally preserved —
# openapi-typescript turns them into self-referential interfaces.
npx redocly bundle ./api/openapi.yaml -o ./api/openapi.bundled.yaml
Why this works: redocly bundle flattens external file references but does not try to inline recursive internal references, which is exactly what you want. A naive inliner that dereferences everything will recurse forever on a self-referencing schema and throw Maximum call stack size exceeded before the generator ever runs.
Step 2: Generate the Type Declarations
Run openapi-typescript against the bundled spec. Version 7 emits a single artifact containing three top-level types: paths (every operation keyed by route and method), components (reusable schemas, responses, parameters), and operations (operations keyed by operationId). The full surface of flags and what each emits is detailed in generating TypeScript types with openapi-typescript.
# Emit one zero-runtime .ts file. --enum produces real TS enums from
# OpenAPI enum members; omit it to get string-literal unions (usually better).
npx openapi-typescript ./api/openapi.bundled.yaml \
--output ./src/generated/api-types.ts \
--root-types # also export bare aliases for components/schemas
The output is purely declarative. A small slice looks like this:
// src/generated/api-types.ts (auto-generated — do not edit)
export interface paths {
"/users/{id}": {
get: {
parameters: { path: { id: number } };
responses: {
200: { content: { "application/json": components["schemas"]["User"] } };
404: { content: { "application/json": components["schemas"]["Problem"] } };
};
};
};
}
export interface components {
schemas: {
User: {
id: number;
email: string;
/** OpenAPI 3.1 type:[string,null] → string | null */
displayName: string | null;
manager?: components["schemas"]["User"]; // recursive $ref, fully typed
};
Problem: { type: string; title: string; status: number; detail?: string };
};
}
Note manager?: components["schemas"]["User"] — a circular $ref (a user references a user) becomes an ordinary self-referential interface. TypeScript handles recursive types natively, so there is no special flag and no stack overflow at this stage.
Extracting reusable helper types
Indexing into paths is verbose at call sites. Define a couple of helpers once and reuse them everywhere.
import type { paths } from "./generated/api-types";
// Response body for a given path + method + status.
type GetUser =
paths["/users/{id}"]["get"]["responses"][200]["content"]["application/json"];
// openapi-typescript ships helper generics for the common extractions.
import type { PathsWithMethod } from "openapi-typescript-helpers";
type GettableRoutes = PathsWithMethod<paths, "get">;
Step 3: Build a Typed Client with openapi-fetch
Generated types are most useful when a client enforces them. openapi-fetch is a ~2 KB wrapper that takes your paths type as a generic; from there, the route string, path params, query, request body, and response are all inferred. There is no codegen of methods — one client instance covers the whole API.
// src/api/client.ts
import createClient from "openapi-fetch";
import type { paths } from "../generated/api-types";
export const api = createClient<paths>({ baseUrl: "https://api.example.com" });
// `id` is required and typed as number; an unknown route is a compile error.
const { data, error } = await api.GET("/users/{id}", {
params: { path: { id: 42 } },
});
if (error) {
// `error` is narrowed to the Problem schema from the 4xx response.
console.error(error.title, error.status);
} else {
// `data` is narrowed to the 200 User schema — data.displayName is string | null.
console.log(data.email);
}
The { data, error } discriminated result forces you to handle the failure branch before touching data; under strictNullChecks you cannot read data.email without first proving error is absent. This is the payoff of the whole pipeline: a contract change that removes email from the 200 response turns this line red immediately.
For mutations, the request body is checked against the spec too:
// Missing or mistyped body fields are compile errors, not runtime 400s.
const { data } = await api.POST("/users", {
body: { email: "new@example.com", displayName: null },
});
Step 4: Gate Drift in CI
The contract guarantee only holds if generated types cannot fall behind the spec. Commit api-types.ts to the repo, then make CI regenerate it and fail if the result differs from what was committed. This is the same pattern used to gate breaking changes generally — a regenerate-and-diff step that turns silent drift into a red check.
# .github/workflows/openapi-type-gate.yml
name: OpenAPI Type Gate
on: [pull_request]
jobs:
type-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci # installs the PINNED generator version
- run: npx redocly bundle ./api/openapi.yaml -o ./api/openapi.bundled.yaml
- run: npx openapi-typescript ./api/openapi.bundled.yaml --output ./src/generated/api-types.ts --root-types
- name: Fail on stale generated types
run: |
git diff --exit-code ./src/generated/api-types.ts \
|| (echo "::error::Generated types are stale. Run codegen and commit api-types.ts." && exit 1)
- run: npx tsc --noEmit # prove the codebase still type-checks against the new contract
Why this works: git diff --exit-code returns non-zero when the working tree differs from the commit. If a developer edited the spec but forgot to regenerate, CI regenerates, the file changes, the diff is non-empty, and the job fails before merge. The trailing tsc --noEmit catches the consumers — code that no longer matches the regenerated contract fails to compile, surfacing the breaking change in the PR that caused it.
Spec / Schema Reference
The flags that change generator output, with their defaults and effect:
| Option | Type | Default | Effect |
|---|---|---|---|
--output / -o |
path | stdout | Writes the single generated artifact to a file instead of stdout |
--enum |
flag | off | Emits real TypeScript enums; off yields string-literal unions (tree-shakeable, usually preferred) |
--enum-values |
flag | off | Also exports the enum members as a runtime array of values |
--root-types |
flag | off | Exports bare aliases (e.g. User) alongside components["schemas"]["User"] |
--alphabetize |
flag | off | Sorts keys; stabilizes diffs so the CI gate is not tripped by ordering |
--additional-properties |
flag | off | Adds an index signature for objects without additionalProperties: false |
--empty-objects-unknown |
flag | off | Types schema-less {} objects as Record<string, unknown> instead of empty {} |
--immutable |
flag | off | Marks all generated properties readonly |
--default-non-nullable |
flag | on | Treats schema fields with a default as required and non-null |
--path-params-as-types |
flag | off | Allows dynamic path lookups by templated string keys |
OpenAPI nullability maps to TypeScript as follows: in 3.1, type: ["string", "null"] → string | null; in 3.0, nullable: true → string | null. An optional property (absent from required) becomes prop?: T, which is T | undefined. The two are distinct — a field can be both nullable and optional (prop?: string | null).
Verification
Confirm each stage independently before trusting the pipeline end to end.
# 1. The spec is valid and bundles cleanly (exit 0, no warnings).
npx redocly lint ./api/openapi.yaml
# 2. Generation is deterministic — regenerating produces no diff.
npx openapi-typescript ./api/openapi.bundled.yaml -o ./src/generated/api-types.ts --root-types
git diff --exit-code ./src/generated/api-types.ts && echo "types in sync"
# 3. The codebase type-checks against the generated contract.
npx tsc --noEmit && echo "consumers compile"
A green run looks like three clean exits in sequence: lint passes, the diff is empty (types in sync), and tsc reports consumers compile. In CI the same signal appears as a passing OpenAPI Type Gate check; a failure annotation reading Generated types are stale means someone changed the spec without regenerating.
Troubleshooting
Maximum call stack size exceeded during pre-processing. The crash happens in a dereferencing step (e.g. an --resolve/inline pass), not in openapi-typescript itself. A schema references itself directly or through a cycle, and the inliner recurses forever. Fix: bundle with redocly bundle (which preserves internal recursive refs) instead of fully dereferencing, and let openapi-typescript emit a self-referential interface. The deeper recipe is in fixing OpenAPI $ref circular reference errors.
CI fails with “Generated types are stale” but they look fine locally. Local and CI ran different generator versions, or different key ordering. Fix: pin the exact openapi-typescript version in package.json (no ^), run npm ci (not npm install) in CI, and add --alphabetize so ordering is deterministic across machines.
TypeScript reports “Property does not exist” on a response. The schema declares additionalProperties: true, so the generated object has an index signature that strict property access rejects, or the field lives under a different status code than you indexed. Fix: set additionalProperties: false in the spec where the object is closed, and index the exact status — responses[200]["content"]["application/json"], not a guessed key.
Generated field is string | null but you expected string | undefined. The schema marks the field nullable, not optional. Nullable (null on the wire) and optional (absent from required) are different contracts. Fix: if the field can truly be omitted, remove it from required and drop the null type; if it is sent as null, handle null at the call site rather than casting it away with as.
Could not resolve reference for an external file. openapi-typescript was pointed at the raw multi-file spec instead of the bundled one. Fix: always run redocly bundle first and generate from the bundled output, or pass the spec via a URL the generator can fetch.
Frequently Asked Questions
Does openapi-typescript add any runtime code to my bundle?
No. openapi-typescript 7 emits only TypeScript type declarations, which are erased during compilation. The generated file ships zero JavaScript. Runtime work like fetching and validation belongs to companion libraries such as openapi-fetch.
What is the difference between openapi-typescript and openapi-fetch?
openapi-typescript generates the static types from your spec; openapi-fetch is a tiny typed fetch wrapper that consumes those types so paths, params, request bodies, and responses are checked at compile time. You typically use both together.
Does openapi-typescript 7 support OpenAPI 3.1 and JSON Schema nullability?
Yes. Version 7 targets OpenAPI 3.0 and 3.1. In 3.1 it reads type arrays like type: [string, null] and emits string | null; in 3.0 it reads nullable: true. It does not validate data at runtime, only the shape of the contract.
How do I stop generated types from drifting out of sync with the spec?
Regenerate the types in CI and run git diff --exit-code against the committed artifact. If the diff is non-empty the spec changed without the types being regenerated, and the job fails before merge. Pin the exact openapi-typescript version so local and CI output match.
Can openapi-typescript handle circular $ref references?
Yes. openapi-typescript resolves recursive schemas into self-referential TypeScript interfaces, which TypeScript supports natively. Older bundlers that inline $ref can still blow the stack, so resolve bundling separately if you pre-process the spec.