Skip to main content

Generate TypeScript Types from OpenAPI with openapi-typescript 7

The symptom is familiar: a backend engineer renames a response field in openapi.yaml, the frontend TypeScript still compiles, the PR merges, and production returns a 500 because data.userName is now data.username. Hand-written interfaces drift from the spec silently — there is no compiler error because nobody told TypeScript the interface came from the spec in the first place.

This guide is part of the Compile-Time Type Generation from OpenAPI workflow. It covers the full fix in four steps: install the toolchain, generate types, wire a typed client, and add a CI gate that catches drift before it merges.

Symptom

You are seeing one or more of the following:

  • A field removed from the spec still compiles in client code, then throws undefined is not an object at runtime.
  • A new required request-body field fails silently as a 400 Bad Request rather than as a TypeScript error.
  • tsc passes locally but the application throws TypeError: Cannot read properties of undefined (reading 'email') in staging.
  • A CI job runs contract tests that catch a mismatch only after a multi-minute test suite, not at compile time.

The underlying root cause is a hand-maintained layer of TypeScript interfaces that shadow the spec instead of deriving from it.

Root Cause

When engineers hand-write interface User { email: string } alongside an OpenAPI spec that defines the same schema, there is no mechanical link between the two. The spec can change and tsc will not know. This divergence is compounded by three patterns:

  1. No single source of truth. The spec and the interfaces are updated in separate PRs by separate people.
  2. No CI enforcement. Nothing regenerates the types automatically, so drift accumulates until a runtime error appears.
  3. No schema-first discipline. A schema-first workflow establishes the spec as the canonical contract; without it, the spec is treated as documentation rather than a binding contract.

openapi-typescript 7 solves this by reading the spec and emitting TypeScript types mechanically. Humans never write the generated file; CI enforces that it matches the spec.

Step-by-Step Fix

Step 1 — Install and pin the toolchain

Pin exact versions. If local and CI resolve different patch releases, the generator output can differ in whitespace or key ordering, which makes the drift check non-deterministic.

# Exact-version install — no ^ or ~ ranges.
npm install --save-dev openapi-typescript@7.4.4 typescript@5.5.4 @redocly/cli@1.21.0
npm install openapi-fetch@0.13.3   # ~2 KB runtime, kept as a regular dependency

# Confirm the generator version before committing.
npx openapi-typescript --version   # must print 7.4.4

Your tsconfig.json must enable strict null checks or nullable fields collapse to T instead of T | null:

{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": true,
    "exactOptionalPropertyTypes": true,
    "moduleResolution": "bundler"
  }
}

Why this works: Pinning ensures every engineer and every CI runner produces byte-identical output from the same spec. A mismatch here is the most common cause of “it’s fine locally, CI is broken” reports.

Step 2 — Run the generate command

Point openapi-typescript at your spec and emit a single artifact. If your spec uses external $ref files, bundle first with @redocly/cli so the generator sees one document. Unresolved external references cause Could not resolve $ref errors at generation time — not at runtime, which makes them easy to catch.

# Optional: bundle multi-file specs into one document first.
# If your openapi.yaml is self-contained, skip this step.
npx redocly bundle ./api/openapi.yaml -o ./api/openapi.bundled.yaml

# Generate the type declarations.
# --output            writes the file instead of printing to stdout
# --root-types        exports bare aliases (e.g. User) alongside the indexed form
# --alphabetize       sorts keys so git diffs are stable across machines
npx openapi-typescript ./api/openapi.yaml \
  --output ./src/generated/api-types.ts \
  --root-types \
  --alphabetize

The emitted file has three top-level exports. A minimal example for a GET /users/{id} operation:

// src/generated/api-types.ts
// AUTO-GENERATED — do not edit. Run `npm run generate:api` to regenerate.

export interface paths {
  "/users/{id}": {
    get: {
      parameters: {
        path: { id: number };
        query?: { include?: "manager" | "reports" };
      };
      responses: {
        200: { content: { "application/json": components["schemas"]["User"] } };
        404: { content: { "application/json": components["schemas"]["Problem"] } };
      };
    };
  };
}

export interface components {
  schemas: {
    User: {
      id: number;
      email: string;
      displayName: string | null;       // nullable: true in OAS 3.0
      manager?: components["schemas"]["User"];  // recursive $ref
    };
    Problem: {
      type: string;
      title: string;
      status: number;
      detail?: string;
    };
  };
}

// Root-type aliases (--root-types flag)
export type User = components["schemas"]["User"];
export type Problem = components["schemas"]["Problem"];

Note displayName: string | null — the spec declares nullable: true and the generator reflects it exactly. manager? is a self-referential interface from a circular $ref; TypeScript supports recursive types natively. If you encounter stack overflows during generation, the culprit is a pre-processing step that attempts to fully inline $ref rather than preserve them — see the guide on fixing OpenAPI $ref circular reference errors for the resolution.

Why this works: The generator traverses the spec’s AST and emits structural TypeScript. No data is read at runtime. The $ref resolution happens at generation time, so the consumer sees a fully typed tree with no unresolved pointers.

Step 3 — Build the typed client with openapi-fetch

Generated types are most valuable when a client enforces them at every call site. openapi-fetch accepts the paths type as a generic and infers path strings, parameters, request bodies, and response shapes from it — no code generation of individual methods, one client instance for 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",
});

Calling an endpoint:

// src/features/users/getUser.ts
import { api } from "../api/client";

export async function getUser(id: number) {
  const { data, error } = await api.GET("/users/{id}", {
    params: { path: { id } },          // id typed as number — string is a compile error
  });

  if (error) {
    // error is narrowed to Problem (the 404 schema)
    throw new Error(`${error.status}: ${error.title}`);
  }

  // data is narrowed to User — data.displayName is string | null, not string
  return data;
}

A mutation with a checked request body:

// Missing required fields or wrong types are caught by tsc, not by a 400 from the server.
const { data } = await api.POST("/users", {
  body: {
    email: "new@example.com",
    displayName: null,   // spec allows null — this compiles
  },
});

Why this works: openapi-fetch uses TypeScript template literal types and conditional types to map the path string literal to the matching entry in paths. The compiler resolves parameter shapes, the request body schema, and the response union at the call site. A spec change that removes email from the User schema makes every data.email access a compile error — immediately, before any test runs.

Step 4 — Add a package.json generate script

Wire the command into package.json so every developer runs it with the same flags and no one remembers the right incantation:

{
  "scripts": {
    "generate:api": "openapi-typescript ./api/openapi.yaml --output ./src/generated/api-types.ts --root-types --alphabetize",
    "generate:api:bundle": "redocly bundle ./api/openapi.yaml -o ./api/openapi.bundled.yaml && openapi-typescript ./api/openapi.bundled.yaml --output ./src/generated/api-types.ts --root-types --alphabetize"
  }
}

generate:api is the everyday command for self-contained specs. generate:api:bundle is the pre-bundling variant for multi-file specs. Both produce identical output if the spec is self-contained.

Why this works: A named script removes the “what flags do I pass?” question and is the entry point the CI job references, keeping local and CI invocations identical.

Step 5 — Gate drift in CI

Commit src/generated/api-types.ts to the repository. Make CI regenerate it and fail the job if the result differs from what was committed. This turns a silent drift into a required status check that blocks merge.

# .github/workflows/api-type-gate.yml
name: API Type Gate

on:
  pull_request:
    paths:
      - "api/openapi.yaml"
      - "src/generated/api-types.ts"
      - "package-lock.json"

jobs:
  drift-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install pinned dependencies
        run: npm ci    # uses package-lock.json — resolves exact versions, not ranges

      - name: Regenerate types
        run: npm run generate:api

      - name: Fail on stale generated types
        run: |
          git diff --exit-code src/generated/api-types.ts \
            || (echo "::error::api-types.ts is stale. Run 'npm run generate:api' and commit the result." && exit 1)

      - name: Type-check consumers
        run: npx tsc --noEmit
        # This step catches code that no longer compiles against the regenerated types.
        # A red tsc here means a breaking spec change reached a consumer — fix the consumer
        # or revert the spec change.

Why this works: git diff --exit-code returns a non-zero exit code when the working tree file differs from the committed version. If a developer merged a spec change without regenerating, CI regenerates, the file changes relative to what was committed, and the job fails with a clear error message before any reviewer approves the PR. The tsc --noEmit step catches the inverse case: types were regenerated but the code that consumed the old types has not been updated.

Before / After

Before — hand-written interface, silent drift:

// Written by a developer, never regenerated automatically.
interface User {
  id: number;
  email: string;
  userName: string;   // the spec renamed this to `displayName` six months ago
}

async function getUser(id: number): Promise<User> {
  const res = await fetch(`/users/${id}`);
  return res.json() as User;   // cast hides the mismatch
}

// This compiles. data.userName is undefined at runtime.
const user = await getUser(1);
console.log(user.userName.toUpperCase());  // TypeError at runtime

After — generated types, compile-time enforcement:

import { api } from "./api/client";

const { data, error } = await api.GET("/users/{id}", {
  params: { path: { id: 1 } },
});

if (!error) {
  // data.userName → compile error: Property 'userName' does not exist on type 'User'
  // data.displayName → string | null, correct per spec
  console.log(data.displayName?.toUpperCase());
}

The rename from userName to displayName in the spec becomes a compile error at the call site the next time npm run generate:api is run. No runtime, no test run, no staging environment required.

Verification

Run these three checks in order. All three must pass before trusting the pipeline:

# 1. Spec is valid (exits 0, no lint warnings).
npx redocly lint ./api/openapi.yaml

# 2. Generated output is in sync with the committed artifact.
npm run generate:api
git diff --exit-code src/generated/api-types.ts && echo "types in sync"

# 3. All consumers compile against the current types.
npx tsc --noEmit && echo "consumers compile"

A successful run prints types in sync and consumers compile with exit code 0. In CI the same signal appears as a passing API Type Gate check. A failure on step 2 means someone changed the spec without regenerating; a failure on step 3 means the spec change broke a consumer.

Edge Cases

Path parameters typed as strings when the spec says integer. OpenAPI path parameters are always transmitted as strings in URLs, but the spec may declare schema: { type: integer }. openapi-typescript 7 emits the TypeScript type as number, not string, reflecting the semantic type rather than the wire encoding. openapi-fetch coerces the value for serialization; do not cast path params to string before passing them or you will get a type error.

Enums: string-literal unions vs. TypeScript enums. By default openapi-typescript emits "pending" | "active" | "archived" for an OpenAPI enum. Passing --enum emits a const enum, which is not tree-shakeable and can break across compilation boundaries (e.g., in monorepos with isolatedModules). Prefer the default union; if you need a runtime array of values, use --enum-values which exports const StatusValues = ["pending", "active", "archived"] as const without generating a const enum.

Nullable vs. optional — the two-axis problem. A field can be nullable, optional, both, or neither. openapi-typescript represents them on two independent axes: nullable: trueT | null; absent from requiredprop?: T. A field that is both is prop?: T | null. Never use as T to cast away null — widen the handling instead. This is especially common after migrations from OpenAPI 3.0 to 3.1, where nullable: true is replaced by type: ["string", "null"]; openapi-typescript handles both forms correctly.

Frequently Asked Questions

Does openapi-typescript 7 add runtime code to my bundle?

No. The generated file contains only TypeScript type declarations, which the compiler erases entirely. Nothing from openapi-typescript ships in your built JavaScript. openapi-fetch, the companion client, is ~2 KB of runtime code.

What is the difference between openapi-typescript and openapi-fetch?

openapi-typescript reads your spec and emits a .ts file of pure types. openapi-fetch is a tiny typed fetch wrapper that accepts those types as a generic so every route, param, request body, and response is inferred at compile time. You install and use both.

Why does my CI drift check fail even though I regenerated locally?

Local and CI used different generator versions. Pin openapi-typescript to an exact version (no ^ or ~) in package.json and run npm ci, not npm install, in CI. Add --alphabetize to stabilize key ordering across machines.

How do I type a path parameter like /users/{id}?

openapi-typescript generates path parameters under params.path. With openapi-fetch, pass params: { path: { id: 42 } } and TypeScript infers the id type from the spec. Passing a string where an integer is declared is a compile error.

How do nullable fields differ from optional fields in generated types?

Nullable means the value is present on the wire but can be null — openapi-typescript emits string | null. Optional means the key may be absent — openapi-typescript emits prop?: string. A field can be both: prop?: string | null. They are separate contracts and must not be conflated.