Most frontend apps talk to a backend that already has an OpenAPI description. Yet a lot of us still write the types by hand, or copy them from Swagger UI, and then watch them slowly drift away from what the server actually returns.
Code generators fix the drift, but they can bring their own problems:
- the whole API ends up in your bundle, even if the page calls three endpoints,
- the request type for "create a pet" still demands the
idthe server assigns, - errors are typed as
unknown, so everycatchblock is guesswork, - regenerating is scary, because who knows what it overwrites.
I wanted a generator that solves all four, so I built swaggerz. It's open source (MIT), and v0.1 is out now. This post shows what it generates, using the classic Petstore API, and explains the design decisions behind it.
Generating a client
npm install --save-dev swaggerz
npx swaggerz init # writes swaggerz.config.ts; detects your spec and whether you use React Query
npx swaggerz generate # writes the client
The config is small:
import { defineConfig } from 'swaggerz';
export default defineConfig({
spec: { version: '3.0', input: './openapi.yaml' },
output: { directory: './src/api', language: 'typescript' },
client: { adapter: 'fetch' }, // or 'axios'
features: { query: true, validation: 'zod' },
});
Swagger 2.0 and OpenAPI 3.0, 3.1 and 3.2 are all supported. The input can be JSON or YAML, local or remote, and split across files.
For Petstore, that config produces 66 files in about 50 ms:
src/api/
├── index.ts # one barrel for everything
├── client.ts # the client instance you configure
├── runtime/ # ~16 KB minified, copied in, no dependency on swaggerz
├── models/ # pet.ts, pet-input.ts, order.ts, …
├── pet/ # get-pet-by-id.ts, add-pet.ts, update-pet.ts, …
├── store/
├── user/
├── react-query/ # hooks, query options and key factories
└── zod/ # one schema per model
That runtime folder means the generated code doesn't import anything from swaggerz. The generator is a dev dependency only, and you can read (or patch) every line it ships.
What one operation looks like
Here's getPetById, slightly trimmed. This is real output, not a hand-written example:
export type GetPetByIdParams = {
/** ID of pet to return */
petId: number;
};
export type GetPetByIdResponse = Pet;
/** One member per documented status, so checking `status` narrows `body`. */
export type GetPetByIdApiError =
| ApiError<undefined, 400>
| ApiError<ErrorModel, 404>
| ApiError<unknown, Exclude<HttpStatus<1 | 3 | 4 | 5>, 400 | 404>>;
export function getPetById(
params: GetPetByIdParams,
options?: RequestOptions,
): Promise<GetPetByIdResponse> {
return (options?.client ?? client).request<GetPetByIdResponse>(
{
operationId: 'getPetById',
method: 'GET',
path: '/pet/{petId}',
pathParams: { petId: params.petId },
accept: 'application/json',
security: [['api_key'], ['petstore_auth']],
validateResponse: (data) => PetSchema.parse(data),
},
options,
);
}
A few things are worth pointing out:
-
One
paramsobject. Path, query, header and cookie parameters and the body all go in a single object, so adding an optional parameter to the API never breaks your existing calls. -
Errors are typed per status. The spec says a 404 returns an
Errorbody and a 400 returns nothing, and the types say exactly that. -
Validation is opt-in, per response. With
validation: 'zod', responses are parsed against generated Zod schemas, so you find out when the backend breaks its contract instead of renderingundefined. Valibot and ArkType work too.
Using it in React
With features.query on, every GET becomes a typed TanStack Query hook, and every other method becomes a mutation hook:
import { useQueryClient } from '@tanstack/react-query';
import { client, isAddPetApiError, isGetPetByIdApiError } from './api/index.js';
import { petKeys, useAddPet, useGetPetById } from './api/react-query/index.js';
client.setConfig({
baseUrl: 'https://petstore3.swagger.io/api/v3',
auth: { api_key: () => sessionStorage.getItem('apiKey') ?? undefined },
});
export function PetCard({ petId }: { petId: number }) {
const pet = useGetPetById({ petId });
if (pet.isPending) return <p>Loading…</p>;
if (pet.isError) {
const { error } = pet;
// Checking the status narrows the body to that response's type.
if (isGetPetByIdApiError(error) && error.status === 404) return <p>{error.body.message}</p>;
return <p>Something went wrong.</p>;
}
return <h2>{pet.data.name}</h2>;
}
export function AddPetButton() {
const queryClient = useQueryClient();
const addPet = useAddPet({
onSuccess: () => queryClient.invalidateQueries({ queryKey: petKeys.all }),
});
// No `id` here: it's readOnly in the spec, so the request type leaves it out.
return (
<button onClick={() => addPet.mutate({ body: { name: 'Rex', photoUrls: [] } })}>
Add Rex
</button>
);
}
I type-checked this snippet against the generated client with strict and exactOptionalPropertyTypes on. Query keys include the full params object, so every parameter takes part in caching. The signal TanStack Query passes in is forwarded to fetch, so when TanStack Query cancels a query, the HTTP request is aborted too. petKeys.all invalidates every query for the pet tag.
The same hooks are available for Vue, Svelte and Solid (framework: 'vue' | 'svelte' | 'solid'), and there's SWR if you prefer it.
Three design decisions
1. One module per operation
Every operation and every model is its own file. The modules only use static imports and have no side effects at the top level. There's no registry of operations and no client class holding every method. That's what lets your bundler drop everything you don't import, even when you import from the barrel.
The integration tests check this. They generate a client with 1,000 operations and 602 models, then bundle small apps with esbuild, Rollup, Vite and webpack:
| Bundle (minified, not gzipped) | Size |
|---|---|
| 1 operation, direct import (esbuild) | 16.1 KB |
| 3 operations through the barrel (Vite production) | 21.4 KB |
| 3 operations through the barrel (webpack production) | 16.7 KB |
| 1 React Query hook, without TanStack Query (esbuild) | 16.6 KB |
| The whole client (esbuild) | 336.7 KB |
The ~16 KB base is the shared runtime: serialization, errors, the client and the fetch transport. Each further operation adds about 0.2 KB. The tests don't compare exact byte counts, which change with every minifier release. Instead they assert which operations end up in each bundle: only the imported ones.
2. Request and response models are different types
In Petstore, Pet.id is marked readOnly: the server assigns it, and the client never sends it. With a single Pet type, you either have to invent an id to create a pet, or make id optional everywhere, which is wrong for responses.
swaggerz creates a request-side variant only where the spec says the two directions differ:
// models/pet.ts: what you get back
export type Pet = NewPet & {
readonly id: number;
};
// models/pet-input.ts: what you send
export type PetInput = NewPet;
addPet({ body: { id: 1, … } }) is now a type error, and pet.data.id is guaranteed in responses. writeOnly fields like passwords work the other way round: they're in the request type and missing from the response. Models without readOnly or writeOnly fields don't get duplicated, so most specs see no extra types at all.
3. Regenerating is boring, on purpose
Every generated file starts with an @generated marker and is recorded, with its hash, in a manifest. swaggerz only overwrites or deletes files that are in that manifest:
- If one of your own files is where a generated file should go, generation stops with an error instead of overwriting it (unless you pass
--force). - When an operation disappears from the spec, its file is deleted. If you had edited that file by hand, it's kept, with a warning.
-
swaggerz cleandeletes exactly the files in the manifest and nothing else.
The output is also deterministic: the same spec always produces byte-identical files, and files that didn't change aren't rewritten. That makes a CI check straightforward:
npx swaggerz generate --check # exits 1 and lists the differences if the client is out of date
Beyond the client
A linter for your spec. swaggerz lint doesn't judge API style. It reports what makes the generated client worse: missing operationIds, untyped responses, inline schemas, duplicate models. On Petstore, it points out that Category and Tag are structurally identical.
An MCP server for coding agents. Agents like Claude Code and Cursor are writing more and more API calls, and they tend to guess from the raw spec. swaggerz mcp gives them the exact generated function, its import path, its types and an example call:
claude mcp add swaggerz -- npx swaggerz mcp
It's read-only by default, and spec text (descriptions, examples) comes back in fields marked as data, so the agent doesn't treat it as instructions.
There's also a local dashboard (swaggerz dashboard) for browsing operations, models and their dependency graph, and for seeing which generated functions your app actually imports.
What it doesn't do (yet)
I'd rather you hear these from me than find them in production:
-
int64maps tonumber, so values above 2^53 lose precision when the JSON is parsed. - XML bodies aren't serialized or parsed for you. You pass a string, and you get text back.
- OpenAPI 3.2 streaming media types (JSON Lines, server-sent events) are typed as text. Typed streaming isn't generated yet.
- Webhooks and callbacks get types, but no server or routing code.
Try it
- In the browser, no install: swaggerz.dev/playground. Paste a spec and look at the output.
-
In your project:
npx swaggerz init && npx swaggerz generate - Coming from openapi-typescript, orval or openapi-generator? There's a migration guide.
- Source: github.com/alirezahematidev/swaggerz
Real-world specs are messy, and they're the best test this project can get. If you point swaggerz at your API and something comes out wrong, please open an issue with the spec, or just the part that breaks. And if it works, a ⭐ on GitHub helps other people find it.
Top comments (0)