Here's a bug I've shipped more than once, and reviewed more times than that. The backend renames a field from user_name to username. The frontend still has an interface that says user_name. TypeScript is happy, the build passes, CI is green. Then a real user loads the page and a name renders as undefined.
The problem isn't the rename. The problem is that the frontend's idea of the API was a hand-written copy that nobody kept in sync. The types said one thing, the API did another, and the compiler had no way to know.
openapi-typescript fixes this at the root. You point it at your OpenAPI schema, it generates the TypeScript types for every endpoint, and now your types come from the same source the API does. When the backend changes, you regenerate, and the compiler shows you every place that needs updating. No more guessing.
This post walks through the actual problem, then the setup: generating types, using them with a type-safe fetch client, wiring it into React, and adding a CI check so the types can never quietly lie again. It's aimed at developers, but I'll explain the moving parts in plain English as we go, so you can follow even if OpenAPI is new to you.
The real problem: hand-written types drift
If you've built any TypeScript app that talks to an API, you've written something like this:
interface User {
id: string;
user_name: string;
email: string;
}
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
It looks safe. It isn't. That User interface is a promise you made about the API, by hand, from memory or from a doc you read once. res.json() returns any, so TypeScript trusts your interface completely and checks nothing against the real response.
Every one of these is a lie waiting to happen:
- A field gets renamed on the backend and your interface still has the old name.
- A field that was required becomes optional (or
nullable) and your code never null-checks it. - A new endpoint ships and you typo the URL or a query param, and nothing flags it.
- Someone copies a type from one file to another, and now there are two slightly different
Usershapes drifting apart.
The deeper issue is the same one I wrote about in Rust-inspired TypeScript: 4 habits that kill whole classes of bugs: your API boundary is exactly where untrusted data enters your app, and it's the spot most worth making bulletproof. Hand-written types make that boundary a soft handshake instead of a checked contract.
What OpenAPI and openapi-typescript actually are
Quick plain-English version, then we move on.
An OpenAPI schema is a single file (YAML or JSON) that describes your API: every endpoint, every parameter, the shape of every request body, and the shape of every response. Most backend frameworks can produce one automatically (FastAPI, NestJS, ASP.NET, and Laravel with a package all do). It's the API's own description of itself.
openapi-typescript is a small Node tool that reads that schema and writes a TypeScript file full of types matching it exactly. No Java, no code generator that spits out a whole client library, no running server needed. It reads the schema and produces types, in milliseconds even for large APIs. The output is types only, so it adds zero weight to your bundle and zero cost at runtime.
The shift is simple but it changes everything: your types stop being something you maintain and become something you generate. The schema is the source of truth, and your types are downstream of it.
Getting the schema from your backend
Before you can generate types, you need that schema file. The good news is you almost never write it by hand. Whatever framework your backend runs on, there's a way to produce it from the code that already exists. Here's how, for the stacks I see most.
FastAPI (Python)
FastAPI builds the OpenAPI schema for you automatically from your route definitions and Pydantic models. A running FastAPI app already serves it. Just hit the endpoint:
# App running on localhost:8000
curl http://localhost:8000/openapi.json -o schema.json
You can point openapi-typescript straight at http://localhost:8000/openapi.json and skip the file entirely. Nothing to install, it's built in.
NestJS (Node)
NestJS uses the @nestjs/swagger package. If you've already set up Swagger docs, the schema is served at whatever path you mounted it on (often /api-json). To write it to a file instead of only serving it, add a few lines to your bootstrap so it saves the spec on startup:
import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { writeFileSync } from "fs";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle("My API")
.setVersion("1.0")
.build();
const document = SwaggerModule.createDocument(app, config);
writeFileSync("./schema.json", JSON.stringify(document, null, 2));
await app.listen(3000);
}
bootstrap();
Run the app once and you get schema.json next to it.
ASP.NET Core (.NET)
This one changed recently, so check your version. From .NET 9 onward, OpenAPI support is built into the framework through the Microsoft.AspNetCore.OpenApi package (the older Swashbuckle setup is no longer the default). With AddOpenApi() and MapOpenApi() wired up, a running app serves the document at /openapi/v1.json:
curl http://localhost:5037/openapi/v1.json -o schema.json
To write the file at build time instead of running the app, add the Microsoft.Extensions.ApiDescription.Server package, which exports the document during dotnet build. That's handy when you want the schema committed to source control or generated in CI.
Laravel (PHP)
Laravel doesn't ship OpenAPI out of the box, so you add a package. Two common picks:
- Scribe generates an OpenAPI file from your routes and annotations. Run
php artisan scribe:generateand it writes the spec (and human docs) for you. - L5-Swagger reads OpenAPI annotations in your controllers and serves the JSON at a route like
/api/documentation.
Either way you end up with a JSON file or a URL you can feed to openapi-typescript.
Anything else
The pattern holds across frameworks. Spring Boot has springdoc-openapi (served at /v3/api-docs), Express has packages like swagger-jsdoc, Go and Rust have their own. The question to ask is always the same: does my framework expose an OpenAPI document, as a file or a URL? If yes, you're set. If your API genuinely has no way to produce one, that's the case where openapi-typescript is harder to justify, since you'd be writing the schema by hand.
A quick tip: serving the schema from a running app (the URL approach) is the easiest to start with, but for a real project I prefer generating the file and committing it. That way the schema is versioned alongside the code, and you don't need the backend running just to regenerate your frontend types.
Setup: generate your types
You need Node 20 or newer. Install the tool as a dev dependency:
npm i -D openapi-typescript typescript
In your tsconfig.json, make sure the module settings are right so the generated types load cleanly:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true
}
}
That last line, noUncheckedIndexedAccess, is worth turning on. It makes TypeScript treat indexed lookups as possibly undefined, which matches reality and catches a whole class of "I assumed this was there" bugs. The openapi-typescript docs recommend it, and I leave it on everywhere now.
Now generate the types. Point the tool at your schema and choose an output path:
# From a local schema file
npx openapi-typescript ./schema.yaml -o ./src/lib/api/schema.d.ts
# Or straight from a running API that serves its schema
npx openapi-typescript https://myapi.dev/openapi.yaml -o ./src/lib/api/schema.d.ts
That's it. You now have a schema.d.ts file describing your whole API in TypeScript.
I keep that command in package.json so I never have to remember the exact paths:
{
"scripts": {
"gen:api": "openapi-typescript ./schema.yaml -o ./src/lib/api/schema.d.ts"
}
}
When the backend changes, you run npm run gen:api and regenerate. The diff in that file shows you exactly what changed in the API, which is useful on its own.
Using the generated types
The generated file exports a few top-level types, mainly paths (every endpoint) and components (every reusable schema object). You pull what you need out of them:
import type { paths, components } from "./lib/api/schema";
// A reusable object from the schema
type User = components["schemas"]["User"];
// The success response of a specific endpoint
type UserResponse =
paths["/users/{id}"]["get"]["responses"][200]["content"]["application/json"]["schema"];
This works, but reaching that deep into nested types by hand gets ugly fast. You don't usually want to write paths["/users/{id}"]["get"]["responses"][200]... all over your code. That's where the companion library comes in.
openapi-fetch: the type-safe client
openapi-fetch is a tiny fetch client (about 6 kB) from the same project. You hand it your generated paths type, and every request you make is checked against the schema: the URL, the path params, the query, the request body, and the response shape. All of it, with no manual typing and no generics to write.
npm i openapi-fetch
import createClient from "openapi-fetch";
import type { paths } from "./lib/api/schema";
const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
const { data, error } = await client.GET("/users/{id}", {
params: {
path: { id: "123" },
},
});
A few things just got done for you. The URL /users/{id} has to match an endpoint in your schema, so a typo is a compile error. The path.id is required and typed, so leaving it out fails to compile. And data is typed as the real success response, while error is typed as the error response. You check which one you got:
const { data, error } = await client.GET("/users/{id}", {
params: { path: { id: "123" } },
});
if (error) {
// error is fully typed here
console.error(error.message);
return;
}
// data is the success shape, no `any`, no `as`
console.log(data.email);
The win here is what's gone. No interface User to keep in sync. No res.json() as User cast that papers over reality. No generics you have to remember to fill in. Every part of the call is inferred from the schema, so the only way to write a request the API would reject is to write one TypeScript also rejects. That's the whole point: a bug at the API boundary becomes a red squiggle in your editor instead of an undefined in production. Good API design makes this even smoother, which I get into in API design and architecture: a complete developer's guide.
With React: openapi-react-query
If you're on React and use TanStack Query (formerly react-query), there's a 1 kB wrapper called openapi-react-query that gives you the same type safety inside hooks:
npm i openapi-react-query openapi-fetch
import createFetchClient from "openapi-fetch";
import createClient from "openapi-react-query";
import type { paths } from "./lib/api/schema";
const fetchClient = createFetchClient<paths>({
baseUrl: "https://myapi.dev/v1/",
});
const $api = createClient(fetchClient);
function UserName({ id }: { id: string }) {
const { data, error, isLoading } = $api.useQuery("get", "/users/{id}", {
params: { path: { id } },
});
if (isLoading || !data) return "Loading...";
if (error) return `Error: ${error.message}`;
return <div>{data.email}</div>;
}
Same deal: the method, the URL, the params, and the returned data are all typed from your schema, inside a normal useQuery. You get caching and refetching from TanStack Query and type safety from the schema, with almost nothing to wire up. (If you're on a recent React, the React 19.2 post covers what changed around data fetching.)
The step most people skip: wire it into CI
Generating types once is nice. The real payoff comes when you make sure they never drift again, and that takes two small habits.
First, run real typechecking, not just your linter. Add this to package.json:
{
"scripts": {
"test:ts": "tsc --noEmit"
}
}
tsc --noEmit runs the TypeScript compiler purely to check types, without producing output files. Nothing checks types as accurately as the compiler itself, so this is what catches a schema change that broke your code. Run npm run test:ts in CI on every pull request. If the backend changed a response and your code didn't keep up, the build goes red. That's the safety net the whole approach is built around. (If smoke tests are part of your pipeline, this slots in right next to them. I wrote about that in smoke testing: the 30-second check that saves your deploys.)
Second, decide how the schema stays fresh. Two common setups:
- Commit the generated file. Run
gen:api, commitschema.d.ts, and review it in PRs. The diff makes API changes visible to reviewers, which I like. - Regenerate in CI. Pull the latest schema and regenerate as a build step, so the types always match the live API. Better when the backend and frontend ship independently.
Either way, the rule is the same: the schema is the source of truth, the types come from it, and tsc --noEmit in CI makes sure your code agrees with both.
When this is worth it (and when it isn't)
I reach for openapi-typescript whenever there's a real API contract and more than a couple of endpoints, especially when the backend and frontend are different teams or repos. That's where hand-written types rot fastest and where the compiler catching a mismatch saves an actual incident. Treating the boundary as a checked contract is the same instinct behind getting auth right, which I covered in why your auth system is probably wrong.
It's less useful if you don't have an OpenAPI schema and can't easily produce one, or if you're hitting a third-party API that doesn't publish one. You can hand-write a schema, but at that point you're maintaining the schema by hand instead of the types, so weigh it. For a quick script hitting one endpoint, it's overkill. For a product with a growing API, it pays for itself the first time a rename would have shipped a bug and didn't.
The tool is MIT-licensed and free, so trying it on one slice of your app costs you nothing but a few minutes.
What to do now
Pick the noisiest part of your codebase, the API calls you're least sure about, and start there. Grab your schema (or generate one from your backend), run openapi-typescript on it, and swap one hand-written fetch for an openapi-fetch call. Delete the matching hand-written interface and watch whether anything turns red. If it does, you just found a bug that was already there. Then add tsc --noEmit to CI and let the compiler keep watch from now on.
Frequently asked questions
What is openapi-typescript used for?
openapi-typescript reads an OpenAPI 3.0 or 3.1 schema and generates TypeScript types for your whole API: every endpoint, parameter, request body, and response. You use those types to make your API calls type-safe, so the compiler catches mismatches between your code and the real API instead of letting bugs reach production.
How is openapi-typescript different from old code generators?
Older OpenAPI code generators produce a full client library: classes, functions, and runtime code you import and call. openapi-typescript generates types only, with zero runtime code and zero bundle weight. You keep using normal fetch (or the tiny openapi-fetch client) and the types just check your calls. It's faster, lighter, and there's no generated client API to learn.
Do I need a separate fetch library to use it?
No. openapi-typescript only generates types, and you can apply those types to plain fetch yourself. But pairing it with openapi-fetch (about 6 kB) is much nicer, because it infers the URL, params, body, and response automatically, so you don't reach into deeply nested types by hand. For React with TanStack Query, openapi-react-query adds the same safety inside hooks.
Where do I get an OpenAPI schema?
Most backend frameworks generate one for you. FastAPI, NestJS, and ASP.NET produce an OpenAPI schema out of the box, and Laravel can with a package. You point openapi-typescript at that schema file or at the URL where your API serves it. If your API has no schema and you can't generate one, this approach is harder to justify, since you'd be hand-maintaining the schema instead.
How do I keep the generated types in sync with my API?
Two habits. Keep a script like openapi-typescript ./schema.yaml -o ./src/api.d.ts and rerun it whenever the schema changes, either committing the output so PR diffs show API changes, or regenerating it in CI. Then run tsc --noEmit in CI on every pull request, so any code that no longer matches the schema fails the build before it merges.