This is the full developer documentation for CodeWithAgents OpenAPI # openapi-typescript alternative with Zod v4 validation > Honest comparison of openapi-zod-ts with hey-api, orval, kubb, and openapi-fetch. Why choose this openapi-fetch alternative with Zod, how it differs from hey-api vs orval vs kubb, and where each tool genuinely wins. The TypeScript OpenAPI space is crowded and mature. [hey-api](https://heyapi.dev/), [orval](https://orval.dev/), [kubb](https://kubb.dev/), and [openapi-typescript / openapi-fetch](https://openapi-ts.dev/) are all good tools and in some areas more featureful than this one. If you want the broadest plugin ecosystem, the widest mock-generation tooling, or hooks for every framework, one of them is probably the better pick, and that is fine. This page exists so you can decide quickly and fairly. It is kept deliberately honest: where the alternatives are ahead, that is stated plainly. A comparison that only flatters itself is not worth reading. Note Accurate as of June 2026. These projects move fast, hey-api especially. Always confirm against each project’s own docs before making a decision. If anything here is out of date or wrong, please [open an issue](https://github.com/codewithagents/openapi-zod-ts/issues) and we will fix it. openapi-fetch and openapi-react-query are in maintenance mode In mid-2025 the openapi-typescript project placed both `openapi-fetch` and `openapi-react-query` into maintenance mode (GitHub Discussion #2559). No new features; bug fixes for critical issues only. If you are currently on openapi-fetch, see the [migration guide](/migrating-from-openapi-fetch). ## The one-line difference [Section titled “The one-line difference”](#the-one-line-difference) Most generators treat your generated code as disposable: every run overwrites everything, so the output is a black box you are not meant to touch. `openapi-zod-ts` is built around the opposite idea. The Zod schema file is **bootstrapped once and then yours forever**, the client uses **native `fetch` with no runtime library**, and the core generator is **2.x and deliberately small** rather than a moving target. (The packages are versioned independently: the `openapi-zod-ts` generator is 2.x, react-query 3.x, server 2.x, api-errors 1.x, and openapi-msw 0.x.) You trade breadth for ownership and stability. ## Feature matrix [Section titled “Feature matrix”](#feature-matrix) | | `openapi-zod-ts` | hey-api | orval | kubb | openapi-fetch (maintenance mode) | | ------------------------------------------ | -------------------------------------------------------------------------- | --------------------- | -------------------- | --------------------- | -------------------------------- | | TypeScript types | Yes | Yes | Yes | Yes | Yes | | Fetch client | Yes | Yes | Yes | Yes | Yes (is the client) | | Runtime library added to your app | None (native `fetch`) | None (client bundled) | Mutator, often axios | Client of choice | \~6 kb required | | Zod schemas generated | Yes (opt-in) | Yes (plugin) | Yes | Yes | No | | **Zod version** | **v4** | v3 (plugin) | v3 | v3 | n/a | | Runtime request + response validation | Yes | Yes (Zod plugin) | Yes (Zod) | Yes (Zod) | No | | **Schema file you own, never overwritten** | **Yes** | No | No | No | n/a | | React Query / TanStack hooks | Yes (React) | Yes (multi) | Yes (multi) | Yes (React, Vue, SWR) | Companion pkg (maintenance mode) | | Hooks for Vue / Svelte / Angular / Solid | No | Yes | Yes | Vue | Via ecosystem | | **Server-side service interface + router** | **Yes (framework-agnostic by default; Fastify / Hono / Express adapters)** | No | No | No | No | | Mocks (MSW / faker) | Yes (openapi-msw, MSW v2 + Faker) | Yes (plugin) | Yes | Yes | No | | **128-spec compatibility matrix in CI** | **Yes (Stripe, GitHub, OpenAI, Spotify, and more)** | No | No | No | No | | **Mutation testing (Stryker)** | **Yes (openapi-zod-ts, openapi-react-query)** | No | No | No | No | | OpenAPI 3.1 | Yes | Yes | Yes | Yes | Yes | | Swagger 2.0 | No | No | Yes | Yes | No | | Stable release with semver | Yes (core generator 2.x, semver) | No (0.x) | Yes | Yes | Yes (no longer actively bumped) | | Actively maintained | Yes | Yes | Yes | Yes | No (maintenance mode) | ## Where this toolchain genuinely differs [Section titled “Where this toolchain genuinely differs”](#where-this-toolchain-genuinely-differs) You own the Zod schemas With `input_schema` enabled, `schemas.ts` is written once and then never touched again. Add refinements, custom error messages, and business rules. Re-running the generator overwrites the client and types but leaves your schemas alone. Every other tool here regenerates schemas on every run, so hand edits do not survive. Full-stack from one spec The only tool in this set that also generates the server side: a service interface that is framework-agnostic by default (`none`), plus an optional router for Fastify, Hono, or Express. Fastify is the framework the project ships its canonical full-stack reference on. One spec drives the client, the hooks, and the server contract, validated at both ends. Nothing in your bundle The generated client uses only native `fetch`. The generator is a `devDependency`. openapi-fetch ships a small runtime library you import; orval and kubb generate code that calls a client you install. Here, nothing the codegen produces lingers in production that your project did not already have. Boring on purpose The core `openapi-zod-ts` generator is 2.x and semver-stable, proven on 128 real-world specs in CI with live smoke tests, mutation testing, and published provenance. A code generator has a wide blast radius; for this layer of your stack, predictable beats cutting-edge. ## Where the alternatives are ahead [Section titled “Where the alternatives are ahead”](#where-the-alternatives-are-ahead) Being honest about this is the point. ### openapi-typescript / openapi-fetch: where it wins [Section titled “openapi-typescript / openapi-fetch: where it wins”](#openapi-typescript--openapi-fetch-where-it-wins) **Lightness and TypeScript-first design.** `openapi-typescript` generates a `paths` type from your spec with zero runtime output. `openapi-fetch` wraps that with a \~6 kb client that keeps all type information at the TypeScript layer. If you want the absolute minimum surface area and do not need runtime validation or server generation, the combination is hard to beat for sheer lightness. There is no codegen step for the client itself; you install the packages and point them at a generated type file. The important caveat: both packages entered maintenance mode in mid-2025 (Discussion #2559). For a new project today, that is a meaningful factor. ### hey-api: where it wins [Section titled “hey-api: where it wins”](#hey-api-where-it-wins) **Adoption, ecosystem, and plugin breadth.** hey-api is used by Vercel, OpenCode, and PayPal. It has 20+ plugins covering MSW, Cypress, MCP adapters, custom transformers, and hooks for React, Vue, Svelte, Angular, and Solid. The community around it is large and active. If your team values picking the project with the most momentum and the widest ecosystem, hey-api is the clear leader. **Multi-framework hook generation.** If your frontend is not React, hey-api is likely the better pick. We generate React Query v5 hooks only. ### orval: where it wins [Section titled “orval: where it wins”](#orval-where-it-wins) **Swagger 2.0 and multi-framework.** orval reads Swagger 2.0 specs and generates clients and hooks for multiple frameworks. If you are stuck on a Swagger 2.0 spec or need Vue, Svelte, or Angular hooks, orval handles cases this toolchain explicitly excludes. **Mock generation depth.** orval has a mature Faker and MSW mock story with more configurability than `@codewithagents/openapi-msw` today. If rich mock generation is the primary driver, orval leads on ecosystem maturity in that area. ### kubb: where it wins [Section titled “kubb: where it wins”](#kubb-where-it-wins) **Plugin meta-framework and multi-framework targets.** kubb is built as an extensible plugin platform. It supports React, Vue, and SWR hooks, Swagger 2.0 input, and a plugin API designed for teams that want to bolt on custom transformers, Cypress fixtures, or other code generation steps. If you want to treat OpenAPI generation as a programmable pipeline with many output targets, kubb is built for that use case. ## When to pick which [Section titled “When to pick which”](#when-to-pick-which) Pick openapi-zod-ts when * You build in **React + TypeScript** and want the client, hooks, and Zod v4 validation as one coherent set. * You want to **add your own validation rules to the schemas** and keep them across regenerations. * You also own the **backend** and want one spec to drive both ends, typed at both ends. * You want **zero runtime footprint** and code you can read, review, and commit. * You value a **semver-stable 2.x** for a generator that touches every API call in your app. * You want runtime validation proven against **128 real-world specs** and Stryker mutation testing in CI. * You are **migrating from openapi-fetch** which has entered maintenance mode. Pick something else when * Your frontend is **Vue, Svelte, Angular, or Solid**: reach for hey-api, orval, or kubb. * You need **advanced mock generation** with rich faker plugins or framework-specific mock tooling: orval or kubb lead here despite `openapi-msw` covering the basics. * You have a **Swagger 2.0** spec: orval or kubb. * You want the **largest ecosystem and adoption**: hey-api. * You only need **types plus a tiny lightweight client**, no validation: openapi-typescript plus a minimal path-typed wrapper is the lightest combination (note: openapi-fetch itself is in maintenance mode). ## Coming from openapi-fetch or one of these? [Section titled “Coming from openapi-fetch or one of these?”](#coming-from-openapi-fetch-or-one-of-these) [Migrating from openapi-fetch](/migrating-from-openapi-fetch) [Migrating from hey-api or orval](/migrating) # Quickstart > Go from an OpenAPI spec to a working typed fetch client in minutes. This guide walks you through installing `openapi-zod-ts` and generating a typed fetch client from an OpenAPI spec. It takes about five minutes. 1. **Install** * npm ```sh npm i -D openapi-zod-ts ``` * pnpm ```sh pnpm add -D openapi-zod-ts ``` * yarn ```sh yarn add -D openapi-zod-ts ``` 2. **Add a config file** Create `openapi-zod-ts.config.json` in your project root: ```json { "input_openapi": "./openapi.json", "output": "./src/api" } ``` Point `input_openapi` at your spec file (JSON or YAML). The `output` directory is created if it does not exist. The two required fields: | Field | Description | | --------------- | -------------------------------------------- | | `input_openapi` | Path to your OpenAPI 3.x spec (JSON or YAML) | | `output` | Directory to write the generated files | Optional fields let you set a default `baseUrl`, enable Zod schemas (`input_schema`), or generate a server-side client factory (`server_client: true`). See the [full options reference](/openapi-zod-ts#options). 3. **Run the generator** ```bash npx openapi-zod-ts ``` This writes the following files to `./src/api/`: | File | Contents | | ------------------ | ---------------------------------------------------------------------------- | | `models.ts` | TypeScript interfaces for every schema in `components.schemas` | | `client.ts` | One typed `async function` per API operation, using native `fetch` | | `client-config.ts` | `configureClient()` and `getConfig()`: set base URL and auth once at startup | | `index.ts` | Barrel re-export of all three files above | 4. **Use the generated client** Call `configureClient` once at app startup, then import typed functions anywhere: ```typescript import { configureClient } from './src/api' import { listTasks, createTask, ApiError } from './src/api' // Call once at app startup (e.g. main.ts or App.tsx) configureClient({ baseUrl: 'https://api.example.com', token: () => getAccessToken(), // sync or async, called per request }) // Every function is fully typed: parameters, return type, and error shape const page = await listTasks({ status: 'pending', page: 1 }) try { const task = await createTask({ title: 'Ship it' }) console.log(task.id) } catch (err) { if (err instanceof ApiError) { console.error(err.status, err.body) } } ``` Note Function and type names are derived directly from your spec’s operation IDs and schema names. A `GET /tasks` operation with `operationId: listTasks` becomes `async function listTasks(...)`. ## What next? [Section titled “What next?”](#what-next) You have a fully typed fetch client. Layer in hooks, a server interface, or error mapping as your project grows: React Query hooks Generate `useQuery` and `useMutation` hooks on top of this client. [openapi-react-query guide](/openapi-react-query) Server interface Generate a typed service interface and optional Hono, Express, or Fastify router. [openapi-server guide](/openapi-server) Form error mapping Map `ApiError` responses from mutation hooks directly to form field errors. [api-errors guide](/api-errors) MSW mocks Generate seeded MSW v2 handlers from the same spec, so tests and local dev hit realistic mock data. [openapi-msw guide](/openapi-msw) Full-stack tutorial See the generators and api-errors working together in the petstore-fastify demo. [Full-stack tutorial](/tutorial/full-stack) # Introduction > What the toolchain does, and the spec-to-UI pipeline mental model. A TypeScript OpenAPI toolchain that turns a spec into everything you need: types, a fetch client, React Query hooks, Zod schemas, a server-side service interface, MSW mock handlers, and form error mapping. Every generated file compiles under `strict: true` with zero runtime wrapper dependencies. The pipeline is tested against 128 real-world specs (Stripe, GitHub, Spotify, OpenAI, and more) on every PR. ## The pipeline [Section titled “The pipeline”](#the-pipeline) Everything flows from a single spec. One source of truth. When the spec changes, re-run the generator and the TypeScript compiler tells you exactly what broke. ## Packages [Section titled “Packages”](#packages) | Package | What it does | | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | [`openapi-zod-ts`](/openapi-zod-ts) | Generates TypeScript interfaces, a native `fetch` client, and optional Zod schemas | | [`@codewithagents/openapi-react-query`](/openapi-react-query) | Generates typed React Query v5 hooks from the client `openapi-zod-ts` produces | | [`@codewithagents/openapi-server`](/openapi-server) | Generates a typed service interface and an optional router (`hono` \| `express` \| `fastify` \| `none`, default `none`) | | [`@codewithagents/openapi-msw`](/openapi-msw) | Generates MSW v2 HTTP handlers with seeded Faker mock data from your spec | | [`@codewithagents/api-errors`](/api-errors) | Maps API error responses to form field errors at runtime (no codegen step) | Each package is independent. Use only the ones your project needs. They share one spec and one output directory when used together. ## Who it is for [Section titled “Who it is for”](#who-it-is-for) Built for TypeScript projects that want end-to-end type safety from an OpenAPI spec. It works with any HTTP framework on the server side and any React setup on the client. The generated output is plain TypeScript: no axios, no wrapper libraries, nothing added to your runtime bundle beyond what you already use. If your team maintains an OpenAPI spec, the toolchain turns it into a first-class TypeScript artifact at codegen time rather than leaving the gap between spec and code to manual effort. ## Prerequisites [Section titled “Prerequisites”](#prerequisites) * Node.js 18 or later * An OpenAPI 3.1 spec (JSON or YAML). OpenAPI 3.0.x is well-supported in practice: 8 of the 13 showcase specs are 3.0.x and all compile. 3.0-only constructs like `nullable: true` are normalized to the 3.1 null-union form, so nullable fields produce the correct `T | null` types. * TypeScript with `strict: true` recommended (all generated output passes it out of the box) Tip Start with only `openapi-zod-ts`. Get a typed client working, then layer in hooks or a server interface when you need them. ## Next step [Section titled “Next step”](#next-step) Follow the [Quickstart](/getting-started/quickstart) to go from a spec file to a working typed client in a few minutes. # Form error mapping > Map API error responses to form field errors with @codewithagents/api-errors. Framework-agnostic runtime mapping with adapters for React Hook Form, Formik, TanStack Form, and plain records. `@codewithagents/api-errors` maps backend API error responses to form field errors at runtime. It normalizes every common server error format into a flat `{ field, message }[]` list, then wires it to your form library in one call. The validation-to-UX gap: your backend returns structured errors, your form library needs `setError` calls per field, this package bridges the two without any codegen step. ## Overview [Section titled “Overview”](#overview) Three public functions cover all use cases: | Function | What it does | | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `extractErrors(error, options?)` | Parses any error shape. Returns `{ fieldErrors, formErrors, format }`. Includes form-level errors and the matched format. Recommended for new code. | | `extractFieldErrors(error, options?)` | Parses any error shape. Returns normalized `FieldError[]`. Form-level errors fall back to `fallbackField`. Framework-agnostic. | | `mapApiErrors(error, setError, options?)` | Calls `extractFieldErrors` internally, then calls RHF `setError` once per field. React Hook Form adapter. | | `mapApiErrorsToRecord(error, options?)` | Returns a `Record` mapping field names to their first error message. Vanilla / framework-agnostic adapter. | | `mapApiErrorsFormik(error, setErrors, options?)` | Calls Formik’s `setErrors` with the record produced by `mapApiErrorsToRecord`. Formik adapter. | | `mapApiErrorsTanstack(error, form, options?)` | Calls `form.setFieldMeta` for each field to inject server-side errors. TanStack Form v1 adapter. | Key guarantees: * **Runtime, not codegen.** Import and call: no generator step, no config file. * **Zero runtime dependencies.** Pure TypeScript, nothing added to your bundle. * **Never throws.** All functions return empty results for unrecognized or null input. * **Multiple error formats.** Ten built-in parsers cover RFC 7807/9457, Spring Boot, JSON:API, GraphQL, Laravel/DRF, Zod flatten, and more. Detected automatically. * **Response unwrapping.** Handles `ApiError` from `openapi-zod-ts`, Axios-style `error.response.data`, and generic `{ data: ... }` wrappers before parsing. * **Typed.** Ships full TypeScript declarations. No `@types` package needed. ## Install [Section titled “Install”](#install) * npm ```sh npm i @codewithagents/api-errors ``` * pnpm ```sh pnpm add @codewithagents/api-errors ``` * yarn ```sh yarn add @codewithagents/api-errors ``` Note This is a runtime dependency, not a dev dependency. It ships in your production bundle (though it adds zero bytes of third-party code). ## Minimal example [Section titled “Minimal example”](#minimal-example) **React Hook Form: one call at the catch site.** ```tsx import { useForm } from 'react-hook-form' import { mapApiErrors } from '@codewithagents/api-errors' function SignupForm() { const { register, handleSubmit, setError, formState: { errors }, } = useForm() const onSubmit = async (data) => { try { await api.post('/signup', data) } catch (error) { // Maps backend field errors to RHF setError calls automatically mapApiErrors(error, setError) } } return (
{errors.email &&

{errors.email.message}

}
) } ``` **Without React Hook Form: use `extractErrors` directly.** See the [adapters section](#adapters) below for Formik, TanStack Form, and vanilla usage. ## API reference [Section titled “API reference”](#api-reference) ### `extractErrors` [Section titled “extractErrors”](#extracterrors) ```typescript function extractErrors(error: unknown, options?: MapApiErrorsOptions): ExtractResult ``` Parses `error` and returns a rich result. Never throws. ```typescript interface ExtractResult { /** Field-level errors extracted from the response body. */ fieldErrors: FieldError[] /** * Non-field (global/form-level) error messages. * Messages that could not be attributed to a specific field: * RFC 9457 `detail`, JSON:API errors without a source pointer, * violations/invalid-params entries without a field name, and so on. */ formErrors: string[] /** * The format that was recognized. * `null` when no supported format matched (both arrays are empty). * Use this to distinguish "unrecognized shape" from "recognized but genuinely no errors". */ format: ErrorFormat | null } ``` Use `format` to drive conditional rendering: a `null` format means the body shape was not recognized at all, whereas a non-null format with empty arrays means the backend reported no errors. Note `extractErrors` is the recommended entry point for new code. `extractFieldErrors` remains available for backwards compatibility. The only behavioural difference is that `extractErrors` returns form-level errors in a dedicated `formErrors` channel, while `extractFieldErrors` folds them into `fieldErrors` under `fallbackField`. ### `extractFieldErrors` [Section titled “extractFieldErrors”](#extractfielderrors) ```typescript function extractFieldErrors(error: unknown, options?: MapApiErrorsOptions): FieldError[] ``` Parses `error` and returns a normalized list of field errors. Form-level errors (e.g. RFC 9457 `detail`) are returned as a `FieldError` entry using `fallbackField` (`'root'` by default). Returns `[]` for any unrecognized shape. Never throws. For a richer result that includes form-level errors in a separate channel and the matched format, use `extractErrors` instead. ### `mapApiErrors` [Section titled “mapApiErrors”](#mapapierrors) ```typescript function mapApiErrors( error: unknown, setError: (field: string, error: { type: string; message: string }) => void, options?: MapApiErrorsOptions ): void ``` Calls `extractFieldErrors(error, options)` internally, then calls `setError` once per field with `type: 'server'`. The `setError` signature is compatible with React Hook Form’s `UseFormSetError`. No casting needed. Caution When a backend returns multiple errors for the same field, `mapApiErrors` calls `setError` multiple times for that field. React Hook Form keeps only the last message. If you need all messages visible at once, use `extractFieldErrors` directly and group by field before calling `setError`. See [multiple errors per field](#multiple-errors-per-field) below. ### `MapApiErrorsOptions` [Section titled “MapApiErrorsOptions”](#mapapierrorsoptions) ```typescript interface MapApiErrorsOptions { /** * Field name used when no field can be determined from the error. * Defaults to 'root'. */ fallbackField?: string /** * Transform applied to every resolved field name before it is returned. * Useful for mapping backend camelCase names to nested RHF dot-paths, * e.g. "addressCity" to "address.city". * Also applied to fallbackField. */ transformField?: (field: string) => string /** * Restrict extraction to specific HTTP status codes. * If the error carries a detectable status code not in this list, returns [] immediately. * If no status code can be detected on the error, the filter is bypassed and * parsing proceeds normally. * Useful to avoid parsing 404 or 500 bodies as field errors. */ statusCodes?: number[] /** * Optional message resolver for i18n or message reformatting. * Called for every error message (field errors and form errors) before the * message is included in the result. Return a replacement string to translate * or reformat the message. * * `field` is `null` for global/form-level errors. * * @example * resolveMessage: (msg, field) => t(`errors.${msg}`) ?? msg */ resolveMessage?: (message: string, field: string | null) => string /** * Custom parsers tried before the built-in parsers. * Parsers are tried in order; the first one that returns a non-null result wins. * Return `null` to indicate "not recognized by this parser". * * Custom parsers receive the already-unwrapped body (after ApiError / Axios * unwrapping), so they do not need to re-implement body unwrapping. */ parsers?: ReadonlyArray } ``` **`statusCodes` pass-through behaviour:** the filter only fires when a status code is found on the error object (`error.status` or `error.response.status`). If no status is present, parsing always proceeds regardless of the `statusCodes` list. This means a raw fetch body passed directly to `extractFieldErrors` is always parsed. ### `FieldError` [Section titled “FieldError”](#fielderror) ```typescript interface FieldError { field: string message: string } ``` ### Type imports [Section titled “Type imports”](#type-imports) ```typescript import type { FieldError, MapApiErrorsOptions, ExtractResult, ErrorFormat, CustomParser, } from '@codewithagents/api-errors' ``` ## Supported error formats [Section titled “Supported error formats”](#supported-error-formats) The format is detected automatically. No configuration is required. Parsers are applied in this order: **custom parsers** (if any) run first; if the body is an array, only the flat-array parser runs; if the body is an object, the following parsers are tried in sequence until the first match: 1. `violations`: RFC 9457 `violations` array 2. `invalid-params`: RFC 9457 `invalid-params` array 3. `json-api`: JSON:API `errors` array with `source.pointer` 4. `rfc7807-map`: RFC 7807 / RFC 9457 `errors` object map 5. `graphql-extensions`: GraphQL `errors` array with `extensions` 6. `spring-array`: Spring Boot default validation array 7. `laravel-drf`: Laravel / DRF top-level field map 8. `flat-object`: simple `{ field, message }` object 9. `zod-flatten`: Zod `.flatten()` output shape 10. `rfc9457-detail`: RFC 9457 top-level `detail` string (last-resort fallback) The first parser that returns a non-null result wins. The exact ordering is implementation-defined and may change between minor versions if disambiguation requires it; do not write code that depends on one parser being preferred over another for ambiguous shapes. ### RFC 7807 / RFC 9457 Problem Details with `errors` map [Section titled “RFC 7807 / RFC 9457 Problem Details with errors map”](#rfc-7807--rfc-9457-problem-details-with-errors-map) `errors` is an object where each key is a field name and each value is a string or array of strings. Common with Spring Boot 3+ and custom Problem Details implementations. ```json { "type": "https://example.com/errors/validation", "title": "Validation failed", "status": 400, "errors": { "email": ["must not be blank"], "name": ["too short", "must not contain numbers"] } } ``` Multiple messages for the same field each become a separate `FieldError` entry. ### RFC 9457 `violations` array [Section titled “RFC 9457 violations array”](#rfc-9457-violations-array) ```json { "title": "Validation failed", "status": 422, "violations": [ { "field": "email", "message": "must not be blank" }, { "field": "name", "message": "too short" } ] } ``` Entries without a `field` value are surfaced as form-level errors via `extractErrors`. ### RFC 9457 `invalid-params` array [Section titled “RFC 9457 invalid-params array”](#rfc-9457-invalid-params-array) ```json { "title": "Validation failed", "status": 422, "invalid-params": [ { "name": "email", "reason": "must not be blank" }, { "name": "age", "reason": "must be positive" } ] } ``` ### JSON:API errors array [Section titled “JSON:API errors array”](#jsonapi-errors-array) Errors with a `source.pointer` value are mapped to field errors. The pointer is converted to a dot-separated field path (`/data/attributes/email` becomes `email`). Errors without a pointer (or with `/`) are returned as form-level errors via `extractErrors`. ```json { "errors": [ { "source": { "pointer": "/data/attributes/email" }, "detail": "must not be blank" }, { "detail": "You do not have permission to perform this action." } ] } ``` ### GraphQL errors array with `extensions` [Section titled “GraphQL errors array with extensions”](#graphql-errors-array-with-extensions) Field resolution order: `extensions.field` (string) is preferred; `extensions.path` (array joined with `.`) is used as a fallback. Errors with neither are returned as form-level errors. ```json { "errors": [ { "message": "must not be blank", "extensions": { "field": "email" } }, { "message": "Something went wrong." } ] } ``` ### Spring Boot default validation format (pre-3) [Section titled “Spring Boot default validation format (pre-3)”](#spring-boot-default-validation-format-pre-3) `errors` is an array of objects with `field` and `defaultMessage` (or `message`) keys. ```json { "status": 400, "errors": [ { "field": "email", "defaultMessage": "must not be blank" }, { "field": "name", "defaultMessage": "too short" } ] } ``` When an item has neither `defaultMessage` nor `message`, the message is set to `'Unknown error'`. ### Laravel / DRF top-level field map [Section titled “Laravel / DRF top-level field map”](#laravel--drf-top-level-field-map) A plain object where each key is a field name and each value is a string or array of strings. The body must not contain keys used by other parsers (`errors`, `violations`, `field`, `message`, `detail`, etc.). ```json { "email": ["must not be blank", "invalid format"], "name": ["required"] } ``` ### Zod `.flatten()` output [Section titled “Zod .flatten() output”](#zod-flatten-output) The shape produced by Zod’s `zodSchema.safeParse(data).error.flatten()`. ```json { "fieldErrors": { "email": ["Invalid email"], "name": ["Required"] }, "formErrors": ["At least one field must be provided."] } ``` `formErrors` entries are surfaced as form-level errors via `extractErrors`. ### Flat object [Section titled “Flat object”](#flat-object) ```json { "field": "email", "message": "Invalid email" } ``` ### Flat array [Section titled “Flat array”](#flat-array) ```json [ { "field": "email", "message": "Invalid email" }, { "field": "name", "message": "Required" } ] ``` ### RFC 9457 top-level `detail` [Section titled “RFC 9457 top-level detail”](#rfc-9457-top-level-detail) When no other parser matches, the top-level `detail` string is returned as a form-level error. ```json { "title": "Bad Request", "detail": "Email is already taken.", "status": 422 } ``` Via `extractFieldErrors`: `[{ field: 'root', message: 'Email is already taken.' }]`. Via `extractErrors`: `{ fieldErrors: [], formErrors: ['Email is already taken.'], format: 'rfc9457-detail' }`. ### Response wrappers (automatic unwrapping) [Section titled “Response wrappers (automatic unwrapping)”](#response-wrappers-automatic-unwrapping) Before parsing, the following wrappers are stripped: | Wrapper shape | Detected by | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `{ status: number, body: unknown }` | `ApiError` from `openapi-zod-ts` | | `{ response: { data: ... } }` | Axios-style error objects | | `{ data: ... }` | Generic response wrappers (only when `data` is a plain object and `field` / `errors` are absent at the top level) | Caution The `{ data: ... }` wrapper is only unwrapped when `data` is a plain object. If `data` is an array, string, or any other non-object value, the wrapper is not stripped and parsing returns `[]`. Pass the inner value directly if your wrapper does not match this shape. ## Adapters [Section titled “Adapters”](#adapters) ### React Hook Form (`mapApiErrors`) [Section titled “React Hook Form (mapApiErrors)”](#react-hook-form-mapapierrors) ```typescript function mapApiErrors( error: unknown, setError: (field: string, error: { type: string; message: string }) => void, options?: MapApiErrorsOptions ): void ``` ```tsx import { useForm } from 'react-hook-form' import { mapApiErrors } from '@codewithagents/api-errors' type FormValues = { email: string; name: string } function SignupForm() { const { register, handleSubmit, setError, formState: { errors }, } = useForm() const onSubmit = async (data: FormValues) => { try { await createUser(data) } catch (error) { mapApiErrors(error, setError) } } return (
{errors.email &&

{errors.email.message}

} {errors.name &&

{errors.name.message}

}
) } ``` ### Vanilla / record (`mapApiErrorsToRecord`) [Section titled “Vanilla / record (mapApiErrorsToRecord)”](#vanilla--record-mapapierrorstorecord) Returns a `Record` mapping each field to its first error message. Useful for any custom form library or imperative error handling. Only field errors are included; for form-level errors use `extractErrors`. ```typescript function mapApiErrorsToRecord(error: unknown, options?: MapApiErrorsOptions): Record ``` ```typescript import { mapApiErrorsToRecord } from '@codewithagents/api-errors' try { await submitForm(data) } catch (error) { const record = mapApiErrorsToRecord(error) // { email: "must not be blank", name: "required" } for (const [field, message] of Object.entries(record)) { showFieldError(field, message) } } ``` ### Formik (`mapApiErrorsFormik`) [Section titled “Formik (mapApiErrorsFormik)”](#formik-mapapierrorsformik) Calls Formik’s `setErrors` with a `Record`. No Formik import is required: the `setErrors` parameter is typed by shape only (structural typing). ```typescript function mapApiErrorsFormik( error: unknown, setErrors: (errors: Record) => void, options?: MapApiErrorsOptions ): void ``` ```tsx import { useFormik } from 'formik' import { mapApiErrorsFormik } from '@codewithagents/api-errors' function SignupForm() { const formik = useFormik({ initialValues: { email: '', name: '' }, onSubmit: async (values, { setErrors }) => { try { await createUser(values) } catch (error) { mapApiErrorsFormik(error, setErrors) } }, }) return (
{formik.errors.email &&

{formik.errors.email}

}
) } ``` ### TanStack Form v1 (`mapApiErrorsTanstack`) [Section titled “TanStack Form v1 (mapApiErrorsTanstack)”](#tanstack-form-v1-mapapierrorstanstack) Calls `form.setFieldMeta` for each field to inject server-side errors into the form state. No TanStack Form import is required: the `form` parameter is typed by shape only (structural typing). ```typescript function mapApiErrorsTanstack( error: unknown, form: { setFieldMeta: (field: string, updater: (meta: any) => any) => void }, options?: MapApiErrorsOptions ): void ``` ```tsx import { useForm } from '@tanstack/react-form' import { mapApiErrorsTanstack } from '@codewithagents/api-errors' function SignupForm() { const form = useForm({ defaultValues: { email: '', name: '' }, onSubmit: async ({ value }) => { try { await createUser(value) } catch (error) { mapApiErrorsTanstack(error, form) } }, }) return (
{ e.preventDefault() form.handleSubmit() }} > {(field) => ( <> field.handleChange(e.target.value)} /> {field.state.meta.errors.map((e) => (

{e}

))} )}
) } ``` ## Usage examples [Section titled “Usage examples”](#usage-examples) ### Using `extractErrors` for form-level errors [Section titled “Using extractErrors for form-level errors”](#using-extracterrors-for-form-level-errors) `extractErrors` returns field errors and form-level (global) errors in separate channels. Use `formErrors` for messages like “You do not have permission” that cannot be attributed to a specific field: ```typescript import { extractErrors } from '@codewithagents/api-errors' try { await createUser(data) } catch (error) { const { fieldErrors, formErrors, format } = extractErrors(error) if (format === null) { // Unrecognized shape: show a generic banner showBanner('An unexpected error occurred.') return } for (const { field, message } of fieldErrors) { setError(field, { type: 'server', message }) } if (formErrors.length > 0) { setFormBanner(formErrors.join(' ')) } } ``` ### With the generated `ApiError` and `statusCodes` [Section titled “With the generated ApiError and statusCodes”](#with-the-generated-apierror-and-statuscodes) When you use `openapi-zod-ts`, the generated client throws `ApiError` on non-2xx responses. Pass it directly to `extractFieldErrors`. Use `statusCodes` to restrict field-error parsing to validation responses only: ```typescript import { createTask } from './src/api' import { mapApiErrors } from '@codewithagents/api-errors' // In a React Hook Form onSubmit handler: try { await createTask({ title: formData.title }) } catch (error) { // Only parse field errors for 422 Unprocessable Entity. // 404 and 500 errors are returned as-is (setError is not called). mapApiErrors(error, setError, { statusCodes: [422] }) } ``` **With React Query `useMutation`:** ```typescript import { useMutation } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { mapApiErrors } from '@codewithagents/api-errors' function CreateTaskForm() { const { handleSubmit, setError } = useForm() const mutation = useMutation({ mutationFn: createTask, onError: (error) => mapApiErrors(error, setError, { statusCodes: [422] }), }) return
mutation.mutate(data))}>...
} ``` ### With native `fetch` [Section titled “With native fetch”](#with-native-fetch) Pass the parsed response body directly to `extractFieldErrors` when not using a library that throws typed errors: ```typescript import { extractFieldErrors } from '@codewithagents/api-errors' const res = await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) if (!res.ok) { const body = await res.json() const fieldErrors = extractFieldErrors(body) for (const { field, message } of fieldErrors) { setError(field, { type: 'server', message }) } } ``` ### Localizing error messages with `resolveMessage` [Section titled “Localizing error messages with resolveMessage”](#localizing-error-messages-with-resolvemessage) Pass a `resolveMessage` function to translate or reformat every error message before it is included in the result. This is the recommended approach for i18n. ```typescript import { extractErrors } from '@codewithagents/api-errors' import { useTranslation } from 'react-i18next' function useApiErrors() { const { t } = useTranslation() return (error: unknown) => extractErrors(error, { resolveMessage: (msg, field) => t(`errors.${msg}`, { defaultValue: msg }), }) } ``` `field` is `null` for form-level errors, which lets you apply different translation keys by scope if needed: ```typescript resolveMessage: (msg, field) => field === null ? t(`form_errors.${msg}`, { defaultValue: msg }) : t(`field_errors.${msg}`, { defaultValue: msg }) ``` As an alternative, you can apply localization manually after extraction. This is useful when you need fine-grained control over which messages to translate: ```typescript const errors = extractFieldErrors(apiError) const mapped = errors.map((e) => ({ ...e, message: e.message === 'Unknown error' ? t('errors.unknown') : e.message, })) ``` ### Custom parsers [Section titled “Custom parsers”](#custom-parsers) Register custom parsers via `options.parsers` to handle application-specific error shapes. Custom parsers run before all built-in parsers. Return `null` to signal “not recognized” and let the next parser try. ```typescript import { extractErrors } from '@codewithagents/api-errors' import type { CustomParser, ParsedErrors } from '@codewithagents/api-errors' const myParser: CustomParser = (body): ParsedErrors | null => { if ( typeof body !== 'object' || body === null || !('myErrors' in body) || !Array.isArray((body as any).myErrors) ) { return null } const fieldErrors = (body as any).myErrors .filter((e: any) => typeof e.key === 'string' && typeof e.text === 'string') .map((e: any) => ({ field: e.key, message: e.text })) return { fieldErrors, formErrors: [] } } const result = extractErrors(error, { parsers: [myParser] }) ``` ### Transforming field names [Section titled “Transforming field names”](#transforming-field-names) Use `transformField` to map backend camelCase names to nested React Hook Form dot-paths: ```typescript mapApiErrors(error, setError, { transformField: (f) => f.replace(/([A-Z])/g, '.$1').toLowerCase(), // "emailAddress" → "email.address" // "streetCity" → "street.city" }) ``` `transformField` is also applied to `fallbackField`, so the transform is consistent across all field names. Caution The regex above splits on every uppercase letter individually, so it works for simple camelCase names but breaks on acronyms. For example, `ABCField` becomes `a.b.c.field` instead of `abc.field`. Add special-case handling or a more precise regex if your API uses acronyms in field names. ### Multiple errors per field [Section titled “Multiple errors per field”](#multiple-errors-per-field) When a backend returns multiple errors for the same field, `extractFieldErrors` returns all of them as separate entries. If you pass them directly to React Hook Form’s `setError`, the last call wins and only the last message is displayed. To show all messages, group first: ```typescript import { extractFieldErrors } from '@codewithagents/api-errors' const fieldErrors = extractFieldErrors(error) const grouped = Map.groupBy(fieldErrors, (e) => e.field) for (const [field, errs] of grouped) { setError(field, { type: 'server', message: errs.map((e) => e.message).join(', '), }) } ``` Note `Map.groupBy` requires Node 21+ or a modern browser (Chrome 117+, Safari 17.4+). For older targets, replace it with a `reduce` accumulator or a utility like `lodash.groupBy`. ### Framework-agnostic usage [Section titled “Framework-agnostic usage”](#framework-agnostic-usage) `extractErrors` works with any state management approach. Here is a realistic example with a custom form-state hook (no React Hook Form required): ```typescript import { useState } from 'react' import { extractErrors } from '@codewithagents/api-errors' type FieldErrors = Record function useFormErrors() { const [fieldErrors, setFieldErrors] = useState({}) const [formErrors, setFormErrors] = useState([]) function applyApiError(error: unknown) { const result = extractErrors(error) if (result.format === null) return false const next: FieldErrors = {} for (const { field, message } of result.fieldErrors) { next[field] = message } setFieldErrors(next) setFormErrors(result.formErrors) return true } function clearErrors() { setFieldErrors({}) setFormErrors([]) } return { fieldErrors, formErrors, applyApiError, clearErrors } } // Usage in a component: function SignupForm() { const { fieldErrors, formErrors, applyApiError } = useFormErrors() async function handleSubmit(data: FormValues) { try { await api.post('/signup', data) } catch (error) { const handled = applyApiError(error) if (!handled) { // Unrecognized error shape: re-throw or show a generic banner console.error('Unexpected error', error) } } } return (
{formErrors.map((msg) =>

{msg}

)} {fieldErrors.email &&

{fieldErrors.email}

}
) } ``` ## Works with [Section titled “Works with”](#works-with) Types and fetch client `openapi-zod-ts` generates the typed `ApiError` class that `api-errors` recognizes and unwraps automatically. [Learn more](/openapi-zod-ts) React Query hooks `@codewithagents/openapi-react-query` generates typed `useMutation` hooks. Use `mapApiErrors` in mutation `onError` callbacks to wire server validation directly to form fields. [Learn more](/openapi-react-query) Server interface `@codewithagents/openapi-server` generates a framework-agnostic service interface plus an optional router for Fastify, Hono, or Express (`none` by default). The server emits the RFC 7807 / RFC 9457 error shapes that `api-errors` parses on the client. [Learn more](/openapi-server) # Compatibility > OpenAPI version support and the 128-spec compatibility matrix. The generator is tested against 128 real-world public API specs on every relevant PR and on a weekly schedule. Specs include payment processors, cloud platforms, developer tools, social platforms, and government APIs covering a wide range of OpenAPI authoring styles. If your spec uses a pattern that exists in the wild, the generator has almost certainly encountered it. ## OpenAPI version support [Section titled “OpenAPI version support”](#openapi-version-support) | Version | Support level | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | OpenAPI 3.1.x (including 3.1.1) | Primary target. Full support. | | OpenAPI 3.0.x | Supported in practice. 8 of the 13 showcase specs are 3.0.x and all compile under `tsc --strict`. Most 3.0-only constructs (including `nullable: true`) are normalized to their 3.1 equivalents; a few remaining 3.0 edge cases are still being closed. | | OpenAPI 2.0 (Swagger) | Not supported. | OpenAPI 3.1 is the primary target across all packages in the suite. The same spec corpus and the same 3.1-primary support underpin the sibling generators that consume these specs: `openapi-server`, `openapi-react-query`, and `openapi-msw`. ## The compatibility matrix [Section titled “The compatibility matrix”](#the-compatibility-matrix) 128 specs live in `examples/specs/`. Every spec has a corresponding config in `examples/configs/`. The suite splits into two tiers. ### Showcase specs (13 committed) [Section titled “Showcase specs (13 committed)”](#showcase-specs-13-committed) 13 specs have their generated output committed to `examples/generated/` and tracked in git. The Showcase CI workflow regenerates these on every relevant PR and fails the build if the output has drifted (`git diff --exit-code examples/generated/`). All 13 generated clients also pass `tsc --noEmit --strict`. | Spec | OpenAPI version | Paths | Domain | | -------------------- | --------------- | --------------- | ----------------------- | | `redocly-museum` | 3.1.0 | 5 | Reference / museum | | `1password-connect` | 3.0.2 | 11 | Security / secrets | | `petstore-3.0` | 3.0.4 | 13 | Canonical reference | | `adyen-legal-entity` | 3.1.0 | 20 | Fintech / KYC | | `adyen-checkout` | 3.1.0 | 26 | Payments | | `resend` | 3.1.0 | 47 | Email / developer tools | | `devto` | 3.0.3 | 49 | Developer community | | `open-meteo` | 3.0.0 | 1 (deep schema) | Weather | | `spotify` | 3.0.3 | 71 | Music | | `twitter` | 3.0.0 | 67 | Social | | `openai` | 3.0.0 | \~100 | AI | | `exchangerate` | 3.0.2 | 1 | Finance / FX rates | | `canada_holidays` | 3.0.0 | 6 | Public data | Note The `resend` spec was patched from `openapi: "3.1.2"` to `"3.1.0"` because 3.1.2 is not an official OpenAPI release. The committed output reflects the patched version. ### Compatibility matrix specs (115 runtime-only) [Section titled “Compatibility matrix specs (115 runtime-only)”](#compatibility-matrix-specs-115-runtime-only) The remaining 115 specs are generated during `pnpm test` in each package (parameterized tests). Their output is not committed to git. The test suite fails if any spec produces a generator error. Together with the 13 showcase specs, all 128 specs generate without errors. ## What “tested” means [Section titled “What “tested” means”](#what-tested-means) The two tiers provide different guarantees. **Showcase specs (13):** * Output is committed to `examples/generated/` and tracked in git. * The Showcase workflow regenerates them and runs `git diff --exit-code`. Any behavior change that affects output fails CI immediately. * All generated files pass `tsc --noEmit --strict` (TypeScript strict mode, no emit). * Runs on every PR that touches `packages/openapi-zod-ts/**`, `packages/openapi-react-query/**`, `packages/openapi-server/**`, `packages/openapi-msw/**`, or `examples/**`, plus a weekly run on Monday at 6am UTC to catch upstream spec drift. **Compatibility matrix specs (115):** * Generated at test time by parameterized vitest tests in `pnpm test`. * Checked for generator errors (no crashes, no uncaught exceptions). * Output is not type-checked and is not committed. * Runs on every PR via the standard CI workflow. Tip If you want to regenerate the showcase output locally after changing the generator, run `pnpm generate` in the `examples/` directory and commit the result. CI will verify it matches on the next push. ## Edge cases covered [Section titled “Edge cases covered”](#edge-cases-covered) The full 128-spec suite exercises many real-world authoring patterns: * **Dots in operationIds**: `calendar.calendars.insert` becomes `calendarCalendarsInsert` (Google API style) * **Spaces and special characters**: operationIds with whitespace, parentheses, or braces are sanitized to valid identifiers * **Kebab-case operationIds**: `post-applePay-sessions` becomes `postApplePaySessions` * **Hyphenated schema names**: `CapabilityProblemEntity-recursive` becomes `CapabilityProblemEntityRecursive` * **Array query params**: `project_ids[]` maps to TS property `project_ids`, wire name `project_ids[]` * **Dot-notation query params**: `place.fields` maps to TS property `placeFields`, wire name `place.fields` * **Path-item level parameters**: inherited by all operations under that path * **Schema name conflicts with global types**: OpenAI has a schema named `Response` * **100+ query parameters on a single endpoint**: Open-Meteo * **Mixed 3.0.x and 3.1.x specs** in the same suite ## Notable specs in the matrix [Section titled “Notable specs in the matrix”](#notable-specs-in-the-matrix) A sample of the real-world APIs covered: Stripe, GitHub, Google Calendar, Google Drive, Google Sheets, Google Cloud Run, Google Compute, Google GKE, Spotify, Slack, Vercel, Twilio (messaging, verify, video), Plaid, Notion, Jira, Okta, Asana, Bitbucket, Box, Brex, CircleCI, Klarna, Linode, NASA APOD, Pinecone, SendGrid, Square, Webflow, Xero, YouTube, Zoom, Zuora, Adyen, AWS (API Gateway, CloudFormation, Cognito, DynamoDB, IAM, Lambda, RDS, S3, SNS, SQS), Ably, Airflow, Amadeus, Appwrite, BBC, Bigquery, Braze, Bungie, Bunq, ClickSend, ClickUp, Codat, ConfigCat, DigitalOcean, Discourse, Docker, DocuSign, Dracoon, ElevenLabs, eBay (fulfillment, marketing), Flickr, Getty Images, Giphy, Here (positioning, tracking), Lufthansa, Medium, New York Times, Postman, RAWG, Reverb, ShipStation, Shutterstock, Snyk, SoundCloud, StackExchange, Telegram, TomTom (maps, routing), Traccar, Trello, Vimeo, Vonage, Visual Crossing Weather, Wolfram, Wordnik, World Time, and more. # Drift detection in CI > Use --check-drift to gate CI on stale, missing, or extra generated files and catch regeneration lapses before they reach production. ## The problem [Section titled “The problem”](#the-problem) Your OpenAPI spec evolves. A new endpoint is added, a response shape changes, a field is renamed. The generated output (`models.ts`, `client.ts`, and friends) must be regenerated and committed whenever the spec changes. Without a CI gate, a team member can update the spec, forget to regenerate, and push. The discrepancy is invisible until a type error or a runtime mismatch surfaces downstream. `--check-drift` solves this with a single command that fits in any CI pipeline. ## One-command usage [Section titled “One-command usage”](#one-command-usage) ```sh npx openapi-zod-ts --check-drift ``` The command: 1. Loads your config file (`openapi-zod-ts.config.json` by default, or the path you pass with `--config`). 2. Runs the full generator pipeline in memory. No files are written. 3. Applies the same Prettier formatting the write path uses, so the comparison is exact. 4. Reads the committed files from your configured `output` directory. 5. Exits 0 when everything matches. Exits 1 and prints per-file diagnostics when anything is stale, missing, or extra. Pass `--config` when your config file is not in the current directory: ```sh npx openapi-zod-ts --config packages/api/openapi-zod-ts.config.json --check-drift ``` ## What it checks [Section titled “What it checks”](#what-it-checks) | Status | Meaning | | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **STALE** | The file exists on disk but its content differs from what the generator produces today. Usually caused by a spec change that was not followed by a regeneration commit. | | **MISSING** | The generator would produce this file but it is not on disk. Could happen after a first-time setup or after a config change that adds a new output file. | | **EXTRA** | A file is in the output directory that the generator does not produce. Often a stale artifact from a removed endpoint or a file that was manually added to the generated output directory (which is not recommended). | Example output when drift is detected: ```plaintext Drift check failed: generated output does not match committed files. STALE models.ts (content differs from what the generator produces today) MISSING client-config.ts (expected by the generator but not found on disk) EXTRA old-models.ts (on disk but not produced by the generator; delete it or regenerate) Fix: openapi-zod-ts ``` When the check passes: ```plaintext Drift check passed: all generated files are up to date. ``` ## GitHub Actions integration [Section titled “GitHub Actions integration”](#github-actions-integration) Add a dedicated drift check step to your workflow. When `GITHUB_ACTIONS=true`, the command also emits `::error file=...::` workflow commands so GitHub renders inline annotations on the PR, and appends a summary table to the step summary panel. ```yaml name: CI on: pull_request: push: branches: [main] jobs: drift-check: name: Generated output drift check runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 - name: Install dependencies run: npm ci - name: Check generated output is up to date run: npx openapi-zod-ts --check-drift ``` For a monorepo where each package has its own config: ```yaml - name: Check generated output (packages/api) run: npx openapi-zod-ts --config packages/api/openapi-zod-ts.config.json --check-drift ``` ## How this differs from —check [Section titled “How this differs from —check”](#how-this-differs-from-check) The two drift flags guard against different problems: | Flag | What it checks | When it applies | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | | `--check` | Schema drift: your `input_schema` Zod file vs. what a fresh bootstrap from the spec would produce. Ensures your hand-written Zod schemas stay aligned with the contract. | Only when `input_schema` is configured. | | `--check-drift` | Output file drift: the committed generated files (`models.ts`, `client.ts`, etc.) vs. what the generator would produce today from the current spec and config. | Always, for every generator run. | A project can have `--check` pass (the Zod schema is aligned with the spec) and `--check-drift` fail (the TypeScript client is stale because regeneration was not committed). Both flags can be run together in the same CI step: ```sh npx openapi-zod-ts --check --check-drift ``` ## What is NOT checked [Section titled “What is NOT checked”](#what-is-not-checked) The user-owned `input_schema` file (your hand-written Zod schemas, typically `zod.ts`) is intentionally excluded from `--check-drift` scope. The generator bootstraps this file once and never overwrites it: it belongs to you. Use `--check` to verify that your schema file stays aligned with the OpenAPI contract. ## Fixing drift [Section titled “Fixing drift”](#fixing-drift) When the command reports drift, run the generator to regenerate and commit the result: ```sh # Regenerate npx openapi-zod-ts # Commit the updated generated files git add src/api/ git commit -m "chore: regenerate api client" ``` For more on the CLI options, see the [Types and fetch client reference](/openapi-zod-ts). # Validated at runtime, not just typed at compile time. > Generate types, a fetch client, React Query hooks, and Zod schemas from one spec. Opt into Zod and the generated client validates every request and response body at runtime, so bad data fails at the boundary, not three components deep. Install`npm install -D openapi-zod-ts` Tested against **128 real-world specs** (Stripe, GitHub, Spotify, OpenAI) in CI on every commit. [![openapi-zod-ts npm version](https://img.shields.io/npm/v/openapi-zod-ts?label=openapi-zod-ts\&color=10b981\&style=flat-square)](https://www.npmjs.com/package/openapi-zod-ts)[![openapi-react-query npm version](https://img.shields.io/npm/v/@codewithagents/openapi-react-query?label=openapi-react-query\&color=10b981\&style=flat-square)](https://www.npmjs.com/package/@codewithagents/openapi-react-query)[![MIT license](https://img.shields.io/badge/license-MIT-10b981?style=flat-square)](https://github.com/codewithagents/openapi-zod-ts/blob/main/LICENSE) *** ## See it in action [Section titled “See it in action”](#see-it-in-action) Real output from the [Petstore 3.0 spec](https://github.com/codewithagents/openapi-zod-ts/tree/main/examples/generated/petstore-3.0), generated by the toolchain and committed to the repo. This is what CI compiles on every PR. * Fetch client ```typescript // client.ts: auto-generated by openapi-zod-ts import type { Pet, Order } from './models.js' import { getConfig, type ClientConfig } from './client-config.js' import { z } from 'zod' import { PetSchema, OrderSchema } from './schemas.js' export class ApiError extends Error { constructor( public readonly status: number, public readonly body: unknown ) { super(`API error ${status}`) this.name = 'ApiError' } } // Validates both request and response bodies at runtime against Zod schemas. export async function addPet(body: Pet, config?: Partial): Promise { PetSchema.parse(body) const res = await _request('POST', '/pet', { body }, config) return PetSchema.parse(await res.json()) } export async function findPetsByStatus( params: { status: 'available' | 'pending' | 'sold' }, config?: Partial ): Promise { const searchParams = new URLSearchParams() if (params?.status != null) searchParams.set('status', String(params.status)) const res = await _request('GET', '/pet/findByStatus', { searchParams }, config) return z.array(PetSchema).parse(await res.json()) } ``` * Types ```typescript // models.ts: auto-generated by openapi-zod-ts import type { z } from 'zod' import type { OrderSchema, CategorySchema, UserSchema, TagSchema, PetSchema, ApiResponseSchema, } from './schemas.js' export type Order = z.infer export type Category = z.infer export type User = z.infer export type Tag = z.infer export type Pet = z.infer export type ApiResponse = z.infer ``` * Zod schemas ```typescript // schemas.ts: bootstrapped by openapi-zod-ts, then yours to extend. // Re-running the generator will NOT overwrite this file. import { z } from 'zod' export const TagSchema = z .object({ id: z.number().optional(), name: z.string().optional() }) .passthrough() export const PetSchema = z .object({ id: z.number().optional(), name: z.string(), category: z.object({ id: z.number().optional(), name: z.string().optional() }).optional(), photoUrls: z.array(z.string()), tags: z.array(TagSchema).optional(), status: z.enum(['available', 'pending', 'sold']).optional(), }) .passthrough() export const OrderSchema = z .object({ id: z.number().optional(), petId: z.number().optional(), quantity: z.number().optional(), status: z.enum(['placed', 'approved', 'delivered']).optional(), complete: z.boolean().optional(), }) .passthrough() ``` * React Query hooks ```typescript // hooks.ts: auto-generated by @codewithagents/openapi-react-query import { useQuery, type UseQueryOptions, useMutation } from '@tanstack/react-query' import { addPet, findPetsByStatus, getPetById, type ApiError } from './client.js' export const petKeys = { findPetsByStatus: (params: Parameters[0]) => ['pet', 'findPetsByStatus', params] as const, detail: (petId: string) => ['pet', petId] as const, } export function useFindPetsByStatus( params: Parameters[0], options?: Omit< UseQueryOptions>, ApiError>, 'queryKey' | 'queryFn' > ) { return useQuery>, ApiError>({ queryKey: petKeys.findPetsByStatus(params), queryFn: () => findPetsByStatus(params), staleTime: 0, gcTime: 300000, ...options, }) } // Path-param hooks set enabled: false when id is nullish, no boilerplate at call sites. export function useGetPetById( petId: string | undefined | null, options?: Omit< UseQueryOptions>, ApiError>, 'queryKey' | 'queryFn' > ) { return useQuery>, ApiError>({ queryKey: petKeys.detail(petId!), queryFn: () => getPetById(petId!), enabled: petId != null && (options?.enabled ?? true), ...options, }) } ``` * Server interface ```typescript // service.ts: auto-generated by @codewithagents/openapi-server import type { Order, Pet, User } from './models.js' export interface SwaggerPetstoreOpenAPI30Service { /** POST /pet */ addPet(body: Pet): Promise /** PUT /pet */ updatePet(body: Pet): Promise /** GET /pet/findByStatus */ findPetsByStatus(params: { status: string }): Promise /** GET /pet/{petId} */ getPetById(petId: string): Promise /** DELETE /pet/{petId} */ deletePet(petId: string): Promise /** POST /store/order */ placeOrder(body: Order): Promise /** GET /user/{username} */ getUserByName(username: string): Promise } ``` Generates clean, type-safe code from real-world specs like: * Stripe * GitHub * Spotify * OpenAI * Adyen * \+ 123 more in CI *** ## The pipeline [Section titled “The pipeline”](#the-pipeline) Each package is independent, but they compose. A single OpenAPI spec drives everything from server contract to form field error UX. One source of truth. When the spec changes, re-run the generators and the compiler tells you exactly what broke. *** ## Packages [Section titled “Packages”](#packages) openapi-zod-ts Types, fetch client, and Zod schemas from your OpenAPI spec. [Learn more](/openapi-zod-ts) @codewithagents/openapi-react-query React Query v5 hooks, generated. [Learn more](/openapi-react-query) @codewithagents/openapi-server Typed service interface plus an optional router (default: none) for Fastify, Hono, or Express. [Learn more](/openapi-server) @codewithagents/openapi-msw Generate MSW v2 HTTP handlers with seeded Faker mock data from your OpenAPI spec. [Learn more](/openapi-msw) @codewithagents/api-errors Map API errors to form field errors. [Learn more](/api-errors) *** ## Why this toolkit [Section titled “Why this toolkit”](#why-this-toolkit) Validated, not just typed Generate Zod schemas from your spec, then enable runtime validation with one config field (`input_schema`). The fetch client checks both request and response bodies against your schemas, so bugs surface at the boundary, not deep in your UI. Proven on 128 real specs Stripe, GitHub, Spotify, OpenAI, Adyen, AWS, and more. Every spec is generated and compiled in CI on every commit. Not benchmarked once and forgotten. Code you own Output is plain TypeScript files in your repo. No runtime black box, no proprietary client to call into. Adjust, extend, delete. The compiler is your upgrade guide. OpenAPI 3.1 first-class Full support for `$ref`, `allOf`, `anyOf`, `oneOf`, `nullable`, and 3.1.1 keyword changes. 3.0.x is supported in practice: 8 of our 13 showcase specs are 3.0.x and all compile. `nullable: true` is normalized to the 3.1 `T | null` form, so 3.0 nullable fields produce the correct types. A few 3.0-only constructs are still being normalized. No Swagger 2.0. Native fetch. Zero added bundle weight. The generated client uses only native `fetch`. No axios, no wrapper libraries. Nothing from the codegen packages ends up in your bundle. ESM, strict mode, Prettier-ready Every generated file compiles with `strict: true` and `"type": "module"`. Output passes `prettier --check` with your own config. *** ## Up and running in minutes [Section titled “Up and running in minutes”](#up-and-running-in-minutes) 1. **Install the generator.** ```bash npm install -D openapi-zod-ts ``` For React Query hooks, also add: ```bash npm install -D @codewithagents/openapi-react-query && npm install @tanstack/react-query ``` 2. **Create a config file in your project root.** ```json { "input_openapi": "./openapi.json", "output": "./src/api" } ``` Point `input_openapi` at your spec (JSON or YAML, local path or URL). The output directory is created automatically. 3. **Run the generator and import the output.** ```bash npx openapi-zod-ts ``` Then use the generated barrel directly: ```typescript import { configureClient, getPetById, useFindPetsByStatus } from './src/api' configureClient({ baseUrl: 'https://petstore3.swagger.io/api/v3' }) const pet = await getPetById('1') const { data, isLoading } = useFindPetsByStatus({ status: 'available' }) ``` *** ## Is this for you? [Section titled “Is this for you?”](#is-this-for-you) Use it when * **You build in TypeScript** and want end-to-end type safety from a single spec. * **You want to own the generated code**, not call into a runtime black box. * **Your spec is large, real-world, or full of edge cases.** The toolkit is tested against 128 real-world specs (Stripe, GitHub, Spotify, OpenAI, and more) on every PR, so there is a strong chance your edge cases are already covered. The gnarlier your spec, the more that matters. * **You want validation and form-field error UX, not just static types.** Zod schemas plus `api-errors` carry correctness all the way from the spec to the UI. Look elsewhere if * **You need a non-TypeScript target.** Output is TypeScript only. Python, Go, Java, and other language clients are out of scope. * **Your spec is OpenAPI 2.0 / Swagger.** Only OpenAPI 3.x is supported. No Swagger 2.0. * **You want a managed runtime SDK.** The generated code is yours to own, not a library you call into, so there is no built-in retry, pagination, or caching layer. * **You need a hosted GUI or dashboard.** The toolchain is a CLI plus npm packages. *** ## Built with this toolkit A reserved slot for projects and teams using these packages in production. Your project here Your project here Your project here Using this in production and enjoying it? We would love to feature your project here. [Open an issue and become one of the first listed](https://github.com/codewithagents/openapi-zod-ts/issues/new) *** ## Get started [Section titled “Get started”](#get-started) Quickstart Go from a spec file to a working typed client in five minutes. [Read the quickstart](/getting-started/quickstart) GitHub Source code, issues, and the compatibility matrix across 128 specs. [View on GitHub](https://github.com/codewithagents/openapi-zod-ts) # Migrating from hey-api or orval > Switch to the CodeWithAgents OpenAPI toolchain from another TypeScript OpenAPI generator. This guide is for developers who already have a TypeScript project with an OpenAPI generator set up and want to switch to `openapi-zod-ts`. It covers the mental model shift, the step-by-step switch, and what to watch for. Already using openapi-zod-ts v1? If you are upgrading from v1 to v2 rather than switching from another tool, jump straight to the [Upgrading from v1 to v2](#upgrading-from-v1-to-v2) section below. ## The mental model shift [Section titled “The mental model shift”](#the-mental-model-shift) Many OpenAPI generators follow a pattern where the generated code depends on a runtime client library that the generator itself publishes. Your code calls into that runtime, and the generated types describe its API. The generator ships a package to `dependencies`, not just `devDependencies`. This toolchain works differently: * **Generated files are owned code.** `models.ts`, `client.ts`, `client-config.ts`, and (optionally) `schemas.ts` live in your repository. They are plain TypeScript you can read, extend, and commit like any other file. * **Zero runtime footprint.** The generator (`openapi-zod-ts`) is a `devDependency`. The generated `client.ts` uses only native `fetch`. Nothing is added to your production bundle that your project did not already have. * **Runtime validation is opt-in and user-owned.** If you add `input_schema` to your config, the generator bootstraps a Zod `schemas.ts` file once, then never overwrites it. You add error messages and business rules. The generator warns on drift but your schema stays yours. * **Requests and responses are validated at runtime** when Zod integration is enabled: the client calls `Schema.parse(body)` before sending and `Schema.parse(await res.json())` after receiving. ## Switch step by step [Section titled “Switch step by step”](#switch-step-by-step) 1. **Remove the old generator.** Uninstall the previous generator and any runtime packages it required: ```bash # example: remove hey-api packages npm uninstall @hey-api/client-fetch @hey-api/openapi-ts # example: remove orval npm uninstall orval ``` Delete the old generated output directory and any existing generator config files (`orval.config.ts`, `openapi-ts.config.ts`, etc.). 2. **Install `openapi-zod-ts`.** ```bash npm install -D openapi-zod-ts ``` If you also want React Query hooks, add the hooks package: ```bash npm install -D @codewithagents/openapi-react-query ``` If your old setup relied on MSW or Faker mocks (orval and kubb both ship mock generation), add `@codewithagents/openapi-msw`. It generates MSW v2 handlers with seeded Faker data from the same spec, so you do not lose mocking when you switch: ```bash npm install -D @codewithagents/openapi-msw ``` 3. **Create the config file.** Create `openapi-zod-ts.config.json` in your project root: ```json { "input_openapi": "./openapi.json", "output": "./src/api" } ``` Optional fields for a fuller setup: ```json { "input_openapi": "./openapi.json", "output": "./src/api", "input_schema": "./src/api/schemas.ts", "baseUrl": "https://api.example.com", "server_client": true } ``` | Field | Required | Description | | --------------- | -------- | ---------------------------------------------------------------------------------------------- | | `input_openapi` | Yes | Path to your OpenAPI spec (JSON or YAML) | | `output` | Yes | Directory to write the generated files | | `input_schema` | No | Path to a user-owned Zod schema file. Bootstrapped on first run, never overwritten after that. | | `baseUrl` | No | Default base URL embedded in `client-config.ts` | | `server_client` | No | When `true`, also generates `server.ts` with a `createServerClient()` factory | 4. **Run the generator.** ```bash npx openapi-zod-ts ``` This writes the following files to your `output` directory: | File | Contents | | ------------------ | -------------------------------------------- | | `models.ts` | TypeScript interfaces for every schema | | `client.ts` | One typed `async function` per API operation | | `client-config.ts` | `configureClient()` and `getConfig()` | | `index.ts` | Barrel re-export of all three files above | 5. **Update your imports.** Replace imports from the old generator’s runtime with imports from your generated barrel: ```typescript // Before: importing from a runtime package import { createClient } from '@hey-api/client-fetch' // After: importing from your own generated code import { configureClient, listTasks, ApiError } from './src/api' ``` Call `configureClient` once at app startup: ```typescript import { configureClient } from './src/api' configureClient({ baseUrl: 'https://api.example.com', token: () => getAccessToken(), // sync or async, called per request }) ``` 6. **Add a generate script to `package.json`.** ```json { "scripts": { "generate": "openapi-zod-ts" } } ``` ## Before and after: config comparison [Section titled “Before and after: config comparison”](#before-and-after-config-comparison) * Before (illustrative) ```ts // orval.config.ts (illustrative, your config may differ) import { defineConfig } from 'orval' export default defineConfig({ myApi: { input: './openapi.json', output: { target: './src/api/client.ts', client: 'react-query', }, }, }) ``` ```json // package.json dependencies (illustrative) { "devDependencies": { "orval": "^7.x" }, "dependencies": { "axios": "^1.x" } } ``` * After openapi-zod-ts.config.json ```json { "input_openapi": "./openapi.json", "output": "./src/api" } ``` package.json ```json { "scripts": { "generate": "openapi-zod-ts" }, "devDependencies": { "openapi-zod-ts": "^2.0.0" } } ``` ## What gets simpler, what to watch for [Section titled “What gets simpler, what to watch for”](#what-gets-simpler-what-to-watch-for) What gets simpler **Owned code.** The generated files live in your repo. You can read them, debug them, and search them like any other TypeScript file. **No runtime wrapper dependency.** The generated client uses only `fetch`. Nothing is added to your production bundle. **Runtime validation.** Add `input_schema` to get Zod validation on every request and response, with schemas you control and can extend with business rules. **Predictable re-generation.** Running `npx openapi-zod-ts` again overwrites `models.ts`, `client.ts`, `client-config.ts`, and `index.ts`. Your `schemas.ts` is never overwritten. What to watch for **TypeScript-only output.** The generator produces `.ts` files and requires a TypeScript project. There is no JavaScript-only output mode. **`schemas.ts` is yours after bootstrap.** The first run with `input_schema` writes the file. Every subsequent run skips it. If the spec changes and the schema drifts, the generator prints a warning, but does not update the file automatically. **OpenAPI 3.0.x is supported in practice.** OpenAPI 3.1 (including 3.1.1) is the primary target, and 3.0.x works too: 8 of the 13 showcase specs are 3.0.x and all compile under `tsc --strict`. For 3.0.x specs, `nullable: true` is normalized to the 3.1 null-union form (`type: [..., "null"]`) before generation runs, so nullable fields produce the correct `T | null` types. A few 3.0-only constructs are still being normalized to 3.1 semantics. **ESM only.** The generated files use `.js` extensions in imports (NodeNext resolution). Your project must use `"type": "module"` or be configured for ESM. **Zod v4 required** when using `input_schema`. The bootstrapped `schemas.ts` uses Zod v4 APIs. Zod v3 is not supported. ## Upgrading from v1 to v2 [Section titled “Upgrading from v1 to v2”](#upgrading-from-v1-to-v2) ### Breaking change: `int64` fields are now `number`, not `bigint` [Section titled “Breaking change: int64 fields are now number, not bigint”](#breaking-change-int64-fields-are-now-number-not-bigint) In v1, fields with `format: int64` generated `bigint` in TypeScript and `z.bigint()` in Zod schemas. In v2 they generate `number` and `z.number()`. The rationale: `JSON.stringify` throws when a value contains a `bigint`, and `JSON.parse` never produces a `bigint`. This makes `bigint` unworkable for any fetch-based API client. Precision is therefore limited to `Number.MAX_SAFE_INTEGER` (2^53-1), and the generated TypeScript type carries an inline comment to make that explicit: generated/models.ts ```typescript export interface Order { id: number /* int64, precision limited to 2^53-1 */ } ``` **How to fix your `schemas.ts`:** The generator never overwrites `schemas.ts`, so any `z.bigint()` you wrote (or that v1 bootstrapped) for an int64 property will produce a TypeScript compile error against the regenerated `number` types. Find those fields and replace `z.bigint()` with `z.number()`, removing any `BigInt(...)` conversions at call sites. ```typescript // Before (v1) export const OrderSchema = z.object({ id: z.bigint(), }) // After (v2) export const OrderSchema = z.object({ id: z.number(), }) ``` ### Non-breaking improvements worth regenerating for [Section titled “Non-breaking improvements worth regenerating for”](#non-breaking-improvements-worth-regenerating-for) Re-running the generator after upgrading picks up several typing improvements: * **Inline object responses typed correctly** (#298): response schemas without a `$ref` are now typed as their actual inline object shape instead of `Record`. * **Integer path params typed `number`** (#300): path parameters with `type: integer` or `type: number` are now `number` in the generated function signature instead of `string`. * **Bracket-notation array query params typed correctly** (#299): parameters like `ids[]` are typed `number[]` or `string[]` depending on the item schema, instead of always `string`. * **`Accept: application/json` header on all requests**: every generated fetch call now sends `Accept: application/json` automatically. None of these change the runtime behavior of existing working calls, but regenerating will improve type safety and remove unnecessary casts. ## Next steps [Section titled “Next steps”](#next-steps) Quickstart Full walkthrough of installing and running the generator from scratch. [Getting Started: Quickstart](/getting-started/quickstart) Types and fetch client Complete configuration reference, generated file details, and advanced usage. [openapi-zod-ts guide](/openapi-zod-ts) React Query hooks Generate typed `useQuery` and `useMutation` hooks on top of the generated client. [openapi-react-query guide](/openapi-react-query) Mock data with MSW Generate MSW v2 handlers with seeded Faker data from the same spec. The replacement for orval or kubb mock generation. [openapi-msw guide](/openapi-msw) # Migrating from openapi-fetch (openapi-typescript) > Drop-in migration guide for openapi-fetch and openapi-react-query users. Both packages entered maintenance mode in 2025. Switch to an actively maintained alternative with Zod v4 runtime validation included. openapi-fetch and openapi-react-query are in maintenance mode In mid-2025 the openapi-typescript project placed both `openapi-fetch` and `openapi-react-query` into maintenance mode (see [GitHub Discussion #2559](https://github.com/openapi-ts/openapi-typescript/discussions/2559)). The packages remain available on npm, but active development has stopped. No new features, and bug fixes only for critical issues. If you are starting a new project, or want runtime Zod v4 validation with your types, this page shows the switch. ## Why switch? [Section titled “Why switch?”](#why-switch) `openapi-fetch` solved a real problem: a type-safe client that wraps native `fetch` without adding axios. If you used it, you know the value. The reasons to migrate now are: 1. **Maintenance mode.** Discussion #2559 makes it explicit: the package is winding down. You are on your own for new OpenAPI 3.1 constructs and future React Query v5 compatibility. 2. **No runtime validation.** `openapi-fetch` types your responses at compile time but does not check them at runtime. If an API returns an unexpected shape, you get a silent type error at runtime, not a clean failure at the boundary. 3. **Zod v4 as a free upgrade.** The `openapi-zod-ts` toolchain generates Zod v4 schemas from the same spec and wires them into every fetch call. Runtime validation comes for free when you add one config line. 4. **Server generation is missing from the ecosystem.** `openapi-fetch` has no server-side counterpart. `openapi-zod-ts` ships `@codewithagents/openapi-server` which generates a typed service interface (and optional Fastify, Hono, or Express router) from the same spec, types both ends of the wire. ## API mapping: side by side [Section titled “API mapping: side by side”](#api-mapping-side-by-side) ### Setting up the client [Section titled “Setting up the client”](#setting-up-the-client) * Before (openapi-fetch) ```typescript // Install: npm install openapi-fetch // Manual type definition file (or generated by openapi-typescript): import createClient from 'openapi-fetch' import type { paths } from './openapi-types' // generated by openapi-typescript const client = createClient({ baseUrl: 'https://api.example.com' }) // To attach auth you wrap each call or use middleware: client.use({ onRequest({ request }) { request.headers.set('Authorization', `Bearer ${getToken()}`) return request }, }) ``` * After (openapi-zod-ts) ```typescript // Install: npm install -D openapi-zod-ts // Run: npx openapi-zod-ts (generates client.ts, models.ts, etc.) import { configureClient } from './src/api/client-config' // Call once at app startup. token can be sync or async, called per request. configureClient({ baseUrl: 'https://api.example.com', token: () => getToken(), }) ``` No middleware pattern needed. The `token` field accepts a function called before every request, so the bearer token stays fresh without any per-call boilerplate. ### Making a GET request [Section titled “Making a GET request”](#making-a-get-request) * Before (openapi-fetch) ```typescript // openapi-fetch style: path string, generic runtime object const { data, error } = await client.GET('/tasks/{taskId}', { params: { path: { taskId: '123' } }, }) // `data` is typed but NOT validated at runtime. // If the server returns an unexpected shape, TypeScript does not know. if (error) throw error console.log(data.title) ``` * After (openapi-zod-ts) ```typescript // Generated function, fully typed, no path strings: import { getTask } from './src/api' const task = await getTask('123') // Throws ApiError on non-2xx, throws ZodError if the response shape is wrong // (when input_schema is set in config). console.log(task.title) ``` The generated function name mirrors the `operationId`. Paths and parameter types come from the spec, not from a generic path string. ### Making a POST / mutation [Section titled “Making a POST / mutation”](#making-a-post--mutation) * Before (openapi-fetch) ```typescript const { data, error } = await client.POST('/tasks', { body: { title: 'New task', status: 'open' }, }) if (error) throw error ``` * After (openapi-zod-ts) ```typescript import { createTask } from './src/api' const task = await createTask({ title: 'New task', status: 'open' }) // With input_schema: request body is validated against TaskSchema before the fetch. // Bad data throws immediately, before any network call. ``` ### React Query hooks [Section titled “React Query hooks”](#react-query-hooks) * Before (openapi-react-query) ```typescript // Install: npm install @openapi-ts/react-query (maintenance mode) import { useQuery } from '@openapi-ts/react-query' // Hooks wrap the openapi-fetch client, path strings again: function TaskList() { const { data, isPending } = useQuery({ client, path: '/tasks', method: 'GET', }) // ... } ``` * After (openapi-react-query) ```typescript // Install: npm install -D @codewithagents/openapi-react-query // Run: npx openapi-react-query (generates hooks.ts) import { useListTasks } from './src/api/hooks' function TaskList() { const { data, isPending } = useListTasks() // Typed from the generated client, no path strings, no wrapper needed. } ``` Hooks are generated once from the spec. Every hook name, parameter type, and return type is derived from your operations, not from a generic path string at runtime. ### React Query mutation hooks [Section titled “React Query mutation hooks”](#react-query-mutation-hooks) * Before (openapi-react-query) ```typescript import { useMutation } from '@openapi-ts/react-query' function CreateTaskButton() { const { mutate } = useMutation({ client, path: '/tasks', method: 'POST', }) // ... } ``` * After (openapi-react-query) ```typescript import { useCreateTask } from './src/api/hooks' function CreateTaskButton() { const { mutate } = useCreateTask({ onSuccess: (data) => console.log('created', data.id), }) // mutate({ title: 'New task', status: 'open' }) -- fully typed } ``` ## Zod v4 runtime validation: the free upgrade [Section titled “Zod v4 runtime validation: the free upgrade”](#zod-v4-runtime-validation-the-free-upgrade) `openapi-fetch` has no runtime validation story. Types are erased at runtime, so a server that returns an extra field, a missing required field, or a wrong type goes undetected until it surfaces as a UI bug three components deep. With `openapi-zod-ts`, adding one field to your config bootstraps a Zod v4 schema file: ```json { "input_openapi": "./openapi.json", "output": "./src/api", "input_schema": "./src/api/schemas.ts" } ``` The first `npx openapi-zod-ts` run writes `schemas.ts`. Every subsequent run skips it, so the file is yours to extend. The generated `client.ts` already calls `Schema.parse(body)` before every request and `Schema.parse(await res.json())` after every response: ```typescript // client.ts (auto-generated when input_schema is set) export async function getTask(id: string): Promise { const res = await _request('GET', `/tasks/${id}`) return TaskSchema.parse(await res.json()) // throws ZodError on bad shape } export async function createTask(body: TaskWritable): Promise { TaskWritableSchema.parse(body) // validates before sending const res = await _request('POST', '/tasks', { body }) return TaskSchema.parse(await res.json()) } ``` Once you own `schemas.ts`, you can add refinements that no spec can express: ```typescript // src/api/schemas.ts (yours, never overwritten) export const TaskSchema = z.object({ id: z.string().uuid(), title: z.string().min(1, 'Title is required'), status: z.enum(['open', 'done']), due_date: z.string().datetime().optional(), }) ``` ## Step-by-step switch [Section titled “Step-by-step switch”](#step-by-step-switch) 1. **Remove `openapi-fetch` and `openapi-typescript`.** ```bash npm uninstall openapi-fetch openapi-typescript @openapi-ts/react-query ``` Delete any generated `openapi-types.ts` and `paths.d.ts` files. 2. **Install the new generator.** ```bash npm install -D openapi-zod-ts ``` For React Query hooks: ```bash npm install -D @codewithagents/openapi-react-query npm install @tanstack/react-query # already installed if you used openapi-react-query ``` 3. **Create the config file.** ```json { "input_openapi": "./openapi.json", "output": "./src/api", "input_schema": "./src/api/schemas.ts" } ``` `input_schema` is optional, but strongly recommended. It gives you Zod v4 runtime validation on every request and response, with a schema file you control and can extend. 4. **Run the generators.** ```bash npx openapi-zod-ts npx openapi-react-query ``` Or add to `package.json`: ```json { "scripts": { "generate": "openapi-zod-ts && openapi-react-query" } } ``` 5. **Replace the client setup.** Remove the `createClient` call and replace with `configureClient`: ```typescript // Remove: // import createClient from 'openapi-fetch' // const client = createClient({ baseUrl: ... }) // Add (src/main.ts or App.tsx): import { configureClient } from './src/api/client-config' configureClient({ baseUrl: 'https://api.example.com', token: () => getToken() }) ``` 6. **Replace path-string calls with generated functions.** Find every `client.GET(...)`, `client.POST(...)`, etc. and replace with the corresponding generated function. The function name matches the `operationId` from the spec. ```typescript // Before: const { data } = await client.GET('/tasks/{taskId}', { params: { path: { taskId } } }) // After: const data = await getTask(taskId) ``` 7. **Replace hook usage.** If you used `@openapi-ts/react-query`, replace each hook call with the generated equivalent. The generated hook names follow the `operationId` pattern: `GET /tasks` with `operationId: listTasks` becomes `useListTasks()`. ```typescript // Before: const { data } = useQuery({ client, path: '/tasks', method: 'GET' }) // After: const { data } = useListTasks() ``` ## What stays the same [Section titled “What stays the same”](#what-stays-the-same) * **React Query v5** is still the engine. `useQuery`, `useMutation`, `QueryClient`, `HydrationBoundary`, and everything else you already know still work exactly as before. * **Native `fetch`** is still the transport. No axios, no wrapper libraries. * **TypeScript strict mode** is required by both. Generated output passes `strict: true`. * **OpenAPI 3.0.x and 3.1.x** are both supported. 3.1 is the primary target; 3.0.x works in practice (8 of 13 showcase specs are 3.0.x and all compile under `tsc --strict`). ## What is different [Section titled “What is different”](#what-is-different) * **Generated functions instead of path strings.** `client.GET('/tasks/{id}', ...)` becomes `getTask(id)`. Operations are named, not addressed by path. * **No client singleton to pass around.** `configureClient` sets a module-level config. The generated functions read it per call. No need to thread a client through your component tree. * **Runtime validation is explicit.** With `input_schema` set, every request and response is validated. You see `ZodError` at the boundary, not a type error three levels down. * **`schemas.ts` is yours.** The generator writes it once. Add error messages, custom refinements, and cross-field rules. They survive every regeneration. Note The API surface of `openapi-fetch` and the generated functions is intentionally different. There is no mechanical codemod available. The switch is straightforward (one function per operation) but requires touching every call site. Budget a few hours for a medium-sized project. ## Where to go next [Section titled “Where to go next”](#where-to-go-next) Quickstart Full install-to-first-request walkthrough for greenfield projects. [Getting Started: Quickstart](/getting-started/quickstart) Types and fetch client Full reference: config options, generated file details, SSR server client, and advanced usage. [openapi-zod-ts guide](/openapi-zod-ts) React Query hooks Generated `useQuery` and `useMutation` hooks, key factories, suspense variants, and queryOptions for Next.js App Router prefetching. [openapi-react-query guide](/openapi-react-query) Full-stack tutorial Petstore-fastify walkthrough: spec to typed client, hooks, Fastify router, and a cross-field validation error that round-trips onto the right form field. [Full-stack tutorial](/tutorial/full-stack) # Mock handlers > Generate MSW v2 HTTP handlers with seeded Faker mock data from your OpenAPI spec with @codewithagents/openapi-msw. `@codewithagents/openapi-msw` reads your OpenAPI 3.x spec and writes a single `handlers.ts` file of [Mock Service Worker](https://mswjs.io) v2 HTTP handlers, one per operation, each returning a [Faker](https://fakerjs.dev)-generated body for the operation’s 2xx JSON response. Point it at the same spec your client and server use, drop the handlers into `setupWorker` (browser) or `setupServer` (Node and tests), and you have realistic, type-shaped mock responses without hand-writing a single fixture. Output is deterministic: a `faker.seed(...)` call is written at the top of the file, so the same spec and seed always produce identical values. ## Overview [Section titled “Overview”](#overview) Running the generator produces one file in your output directory: | File | Contents | | ------------- | -------------------------------------------------------------------------------------------------- | | `handlers.ts` | One `http.(path, resolver)` handler per operation, wrapped in `export const handlers = []` | Key guarantees: * **One handler per operation.** Every `get`, `post`, `put`, `patch`, and `delete` operation in the spec becomes an `http.` handler. Operations missing `responses` are skipped. * **Faker-shaped bodies.** Each handler returns a body built from the operation’s 2xx JSON schema, with values generated by Faker according to each field’s type and format (`email`, `uuid`, `date-time`, `uri`, enums, and so on). * **Deterministic by default.** A `faker.seed()` call (default `42`) is emitted at the top of the file, so re-running with the same spec and seed produces identical mock values. Change the `seed` and re-generate to get a different fixed set. * **MSW v2 path syntax.** OpenAPI `{id}` path parameters are rewritten to MSW’s colon form `:id`. Every parameter in a path is converted, so `/orgs/{orgId}/repos/{repoId}` becomes `/orgs/:orgId/repos/:repoId`. * **Prettier-clean output.** The generated `handlers.ts` passes `prettier --check` with your project’s own Prettier config. * **OpenAPI 3.1.x** is the primary target (including 3.1.1). OpenAPI 3.0.x is supported in practice: 8 of the 13 showcase specs are 3.0.x and all compile under `tsc --strict`. Supports `$ref`, `allOf`, `anyOf`, `oneOf`, and enums, with depth and circular-reference guards. ## Install [Section titled “Install”](#install) * npm ```sh npm i -D @codewithagents/openapi-msw ``` * pnpm ```sh pnpm add -D @codewithagents/openapi-msw ``` * yarn ```sh yarn add -D @codewithagents/openapi-msw ``` The generated `handlers.ts` imports `http`/`HttpResponse` from `msw` and `faker` from `@faker-js/faker`. Both are peer dependencies, so install them as regular dependencies in the consumer project: * npm ```sh npm i msw @faker-js/faker ``` * pnpm ```sh pnpm add msw @faker-js/faker ``` * yarn ```sh yarn add msw @faker-js/faker ``` Note `msw` `^2.0.0` and `@faker-js/faker` `^9.0.0` are peer dependencies, not bundled. If either is missing from your project the generated `handlers.ts` will not compile or run. ## Minimal example [Section titled “Minimal example”](#minimal-example) **1. Create `openapi-msw.config.json` in your project root:** ```json { "input_openapi": "./spec/api.json", "output": "./src/mocks" } ``` **2. Run the generator:** ```bash npx openapi-msw ``` Or add a script to `package.json`: ```json { "scripts": { "generate:mocks": "openapi-msw" } } ``` This writes `./src/mocks/handlers.ts` (the `output` directory is created recursively if missing). **3. Wire the handlers into MSW:** * Browser src/mocks/browser.ts ```typescript import { setupWorker } from 'msw/browser' import { handlers } from './handlers' export const worker = setupWorker(...handlers) ``` * Node and tests src/mocks/server.ts ```typescript import { setupServer } from 'msw/node' import { handlers } from './handlers' export const server = setupServer(...handlers) ``` ## Configuration [Section titled “Configuration”](#configuration) The config file must be valid JSON and must have a `.json` extension. By default the generator looks for `openapi-msw.config.json` in the current working directory. Use `--config ` to point at a different file. ```bash npx openapi-msw --config ./config/openapi-msw.config.json ``` Note When `--config` is used, all relative paths inside the file resolve from the config file’s directory, not from the shell’s working directory. ### Options [Section titled “Options”](#options) | Field | Type | Required | Default | Description | | ----------------- | -------- | -------- | ------- | ---------------------------------------------------------------------- | | `input_openapi` | `string` | Yes | n/a | Path to your OpenAPI spec (JSON or YAML) | | `output` | `string` | Yes | n/a | Directory to write `handlers.ts` | | `seed` | `number` | No | `42` | Faker seed written into the generated file. Integer `>= 0` | | `max_array_items` | `number` | No | `3` | Number of items generated for array schemas. Integer `>= 1` | | `depth_cap` | `number` | No | `30` | Maximum schema-resolution depth before emitting `null`. Integer `>= 1` | Only `input_openapi` and `output` are required. The three optional keys are validated only when present, and their defaults are applied at generation time if omitted. **Full config example:** ```json { "input_openapi": "./spec/api.json", "output": "./src/mocks", "seed": 42, "max_array_items": 3, "depth_cap": 30 } ``` ### Multiple specs [Section titled “Multiple specs”](#multiple-specs) To generate handlers for more than one spec from a single config, use a top-level `projects` array. Each entry takes its own `input_openapi`, `output`, and optional keys: ```json { "projects": [ { "input_openapi": "./spec/orders.json", "output": "./src/mocks/orders" }, { "input_openapi": "./spec/billing.json", "output": "./src/mocks/billing", "seed": 7 } ] } ``` Caution A config cannot mix top-level `input_openapi`/`output` with a `projects` array. Use one or the other. ## CLI [Section titled “CLI”](#cli) The CLI takes no positional arguments, only flags: | Flag | Description | | ----------------- | ------------------------------------------------------------------------- | | `--config ` | Path to the config file. Defaults to `openapi-msw.config.json` in the cwd | | `--help`, `-h` | Print usage and exit | | `--version`, `-v` | Print the installed version and exit | ```bash # Use openapi-msw.config.json in the current directory npx openapi-msw # Point at a config elsewhere npx openapi-msw --config ./config/openapi-msw.config.json ``` ## Generated output [Section titled “Generated output”](#generated-output) Given a task API with `GET /api/v1/tasks` (returns a paged list) and `DELETE /api/v1/tasks/{id}` (returns `204`), the generator writes: ```typescript // This file is auto-generated by @codewithagents/openapi-msw, do not edit import { http, HttpResponse } from 'msw' import { faker } from '@faker-js/faker' faker.seed(42) export const handlers = [ http.get('/api/v1/tasks', () => HttpResponse.json({ items: Array.from({ length: 3 }, () => ({ id: faker.string.uuid(), title: faker.lorem.word(), status: faker.helpers.arrayElement(['pending', 'in_progress', 'done']), priority: faker.number.int({ min: 1, max: 1000 }), assigneeEmail: faker.internet.email(), createdAt: faker.date.recent().toISOString(), })), total: faker.number.int({ min: 1, max: 1000 }), }) ), http.delete('/api/v1/tasks/:id', () => HttpResponse.json(null, { status: 204 })), ] ``` The `{id}` path parameter is rewritten to MSW’s `:id`, and each field maps to a Faker call chosen from its schema type and format. ### Faker mapping [Section titled “Faker mapping”](#faker-mapping) | Schema | Generated value | | ------------------------------ | -------------------------------------------------------------- | | `string`, format `email` | `faker.internet.email()` | | `string`, format `uuid` | `faker.string.uuid()` | | `string`, format `date-time` | `faker.date.recent().toISOString()` | | `string`, format `date` | `faker.date.recent().toISOString().slice(0, 10)` | | `string`, format `uri` / `url` | `faker.internet.url()` | | `string` (other) | `faker.lorem.word()` | | `integer`, `int32`, `int64` | `faker.number.int({ min: 1, max: 1000 })` | | `number` (other) | `faker.number.float({ min: 0, max: 1000, fractionDigits: 2 })` | | `boolean` | `faker.datatype.boolean()` | | `enum` | `faker.helpers.arrayElement([...literals])` | | `array` | `Array.from({ length: maxArrayItems }, () => item)` | | `null` / unresolvable | `null` | ## Programmatic use [Section titled “Programmatic use”](#programmatic-use) The package also exposes a programmatic API for use inside your own scripts: ```typescript import { generateHandlers } from '@codewithagents/openapi-msw' const { filename, content } = generateHandlers(spec, { seed: 42, maxArrayItems: 3, depthCap: 30, }) // filename === 'handlers.ts' ``` `generate`, `loadConfig`, and `loadConfigs` are also exported for driving the full config-to-file flow, along with the `MswConfig`, `HandlerGenOptions`, and `GeneratedFile` types. ## Behaviors to know [Section titled “Behaviors to know”](#behaviors-to-know) These rules are not obvious from the config alone, so they are worth keeping in mind: * **2xx selection order.** The generator prefers `200`, then `201`, then the first 2xx code with an `application/json` body. A `200` emits `HttpResponse.json(body)` with no status argument; any other 2xx code (for example `201`) emits `HttpResponse.json(body, { status: N })`. * **Empty and non-JSON responses.** A 2xx response with no `application/json` schema emits `HttpResponse.json(null, { status: N })` (status from the first 2xx code, defaulting to `204`). If that response instead declares a non-JSON content type (`text/plain`, `octet-stream`, image, and so on) it emits `new HttpResponse(null, { status: N })`, so it never falsely claims `application/json`. Only `application/json` responses get a Faker body. * **`allOf` is merged; `anyOf`/`oneOf` pick the first.** Properties of all `allOf` members are merged into one object and their `required` arrays concatenated. For `anyOf` and `oneOf` the generator picks the first concrete (non-`$ref`) member; if all members are refs it resolves the first one. * **`depth_cap` bounds recursion.** The depth counter increments only on `$ref` resolution and on `allOf`/`anyOf`/`oneOf` resolution. Array nesting and plain property access do not increment it. When depth exceeds the cap, the generator emits `null /* depth cap reached */`. * **Circular refs terminate cleanly.** A `$ref` back to an already-visited schema name emits `null /* circular ref: depth cap reached */`, distinct from the depth-cap comment, so self-referential schemas do not loop forever. * **Type unions use the first element.** OpenAPI 3.1 lets `type` be an array; the generator uses only the first element. Schemas with no `type` fall back to object generation when `properties` is present, otherwise emit `null`. * **`operationId` is not required.** A handler is driven by its path and method, so operations without an `operationId` still generate. Caution `output` and `input_openapi` paths that resolve into system directories (`/etc`, `/usr`, `/bin`, `C:\Windows`, and similar) are rejected as likely misconfiguration. This is a guard rail, not a security sandbox. ## Works with [Section titled “Works with”](#works-with) Types and fetch client `openapi-zod-ts` generates the typed `models.ts` and fetch client your app calls. `openapi-msw` mocks those same calls in the browser and in tests. [Learn more](/openapi-zod-ts) React Query hooks Add `@codewithagents/openapi-react-query` to generate typed `useQuery` and `useMutation` hooks, then mock their requests with the generated handlers during development and tests. [Learn more](/openapi-react-query) Server interface Add `@codewithagents/openapi-server` to implement the same spec on the server with a typed service interface and an optional `fastify`, `hono`, or `express` router. [Learn more](/openapi-server) # React Query hooks > Generate typed React Query v5 hooks from your OpenAPI spec with @codewithagents/openapi-react-query. `@codewithagents/openapi-react-query` reads your OpenAPI 3.x spec and writes a fully typed `hooks.ts` file alongside the client that [`openapi-zod-ts`](/openapi-zod-ts) produces. Every GET endpoint becomes a `useQuery` hook, every write operation becomes a `useMutation` hook. The package runs on top of [React Query v5](https://tanstack.com/query/v5) and derives all types directly from the generated client: no duplicate type declarations, no maintenance. ## Overview [Section titled “Overview”](#overview) Running the generator produces two files in your output directory: | File | Contents | | --------------- | ------------------------------------------------------------------------------------------------------------------------- | | `hooks.ts` | One typed `useQuery` hook per GET, one `useMutation` per POST/PUT/PATCH/DELETE, and a structured key factory per resource | | `test-utils.ts` | `createTestQueryClient()` and `createWrapper()` helpers: drop-in boilerplate eliminators for your test suites | Key guarantees: * **One hook per operation.** Every GET maps to a `useQuery` variant; every write maps to a `useMutation`. Types are derived directly from the generated client: `Awaited>` for data, `Parameters[N]` for variables. * **Smart detail hooks.** Path-parameter hooks widen the parameter type to allow `undefined | null` (the base type is derived from the client function) and set `enabled: false` automatically when the value is nullish. No `enabled: !!id` at every call site. * **Key factories included.** Every resource gets a structured `resourceKeys` object: `all()`, `list(params)`, `detail(id)`. Use these directly with `queryClient.invalidateQueries` for consistent cache management. * **Auto-invalidate on mutation.** Set `auto_invalidate: true` and mutation hooks call `queryClient.invalidateQueries` on success with no boilerplate at the call site. * **Suspense variants.** Set `suspense: true` to generate a `useSuspense*` hook alongside every query hook. Data is never `undefined` inside a Suspense boundary. * **Prettier-clean output.** Every generated file passes `prettier --check` with your project’s own config. ## Install [Section titled “Install”](#install) * npm ```sh npm i -D @codewithagents/openapi-react-query ``` * pnpm ```sh pnpm add -D @codewithagents/openapi-react-query ``` * yarn ```sh yarn add -D @codewithagents/openapi-react-query ``` You also need `openapi-zod-ts` (the client generator this package builds on) and `@tanstack/react-query` as a runtime peer dependency: * npm ```sh npm i -D openapi-zod-ts ``` * pnpm ```sh pnpm add -D openapi-zod-ts ``` * yarn ```sh yarn add -D openapi-zod-ts ``` - npm ```sh npm i @tanstack/react-query ``` - pnpm ```sh pnpm add @tanstack/react-query ``` - yarn ```sh yarn add @tanstack/react-query ``` Caution **React Query v5 is required.** The peer dependency is `@tanstack/react-query ^5.0.0`. Version 4 is not supported. React Query v5 itself requires **React 18 or later**. ## Minimal example [Section titled “Minimal example”](#minimal-example) **1. Create `openapi-react-query.config.json` in your project root:** ```json { "input_openapi": "./openapi.json", "output": "./src/api" } ``` **2. Run both generators (`openapi-zod-ts` first):** ```bash npx openapi-zod-ts npx openapi-react-query ``` Or wire them together in `package.json`: ```json { "scripts": { "generate": "openapi-zod-ts && openapi-react-query" } } ``` **3. Import from the generated hooks file:** ```tsx import { useListTasks, useCreateTask } from './src/api/hooks' function TaskList() { const { data, isLoading } = useListTasks({ status: 'open' }) const create = useCreateTask() if (isLoading) return return ( <> {data?.items.map((task) => ( ))} ) } ``` ## Configuration [Section titled “Configuration”](#configuration) The config file must be valid JSON. By default the generator looks for `openapi-react-query.config.json` in the current working directory. Use `--config ` to point at a different file: ```bash npx openapi-react-query --config ./config/openapi-react-query.config.json ``` Note The `--config` path must end in `.json`. When `--config` is used, all relative paths inside the file resolve from the config file’s directory, not from the shell’s working directory. ### Options [Section titled “Options”](#options) | Field | Type | Required | Default | Description | | ----------------- | ------------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `input_openapi` | `string` | Yes | n/a | Path to your OpenAPI spec (JSON or YAML) | | `output` | `string` | Yes | n/a | Directory to write generated files (should match your `openapi-zod-ts` output) | | `stale_time` | `number` | No | `0` | `staleTime` in ms applied to all `useQuery` hooks | | `gc_time` | `number` | No | `300000` | `gcTime` in ms applied to all `useQuery` hooks | | `suspense` | `boolean` | No | `false` | When `true`, generates a `useSuspense*` variant alongside every query hook | | `auto_invalidate` | `boolean` | No | `false` | When `true`, mutation hooks auto-invalidate related resource queries on success | | `infinite_query` | `boolean \| 'auto'` | No | `'auto'` | Controls `useXxxInfinite` hook generation: `'auto'` emits one when a recognised pagination param is detected, `true` forces generation for all list GET ops, `false` disables it entirely | | `overrides` | `object` | No | none | Per-resource cache timing overrides (see [Per-resource cache timing](#per-resource-cache-timing)) | Note When `infinite_query` is `'auto'` or `true`, paginated list endpoints (those with a recognised pagination param such as `page`, `cursor`, or `offset`) also get a generated `useXxxInfinite` hook powered by React Query’s `useInfiniteQuery`. Provide `getNextPageParam` and `initialPageParam` via `options` to activate real pagination. Shipped in 3.11.0 (#189); current release is 3.12.1. See [Infinite query hooks](#infinite-query-hooks) for a worked example. **Full config example:** ```json { "input_openapi": "./openapi.json", "output": "./src/api", "stale_time": 30000, "gc_time": 300000, "suspense": true, "auto_invalidate": true, "overrides": { "platforms": { "stale_time": 86400000 }, "settings": { "stale_time": 5000, "gc_time": 60000 } } } ``` ## Generated files in depth [Section titled “Generated files in depth”](#generated-files-in-depth) ### `hooks.ts` [Section titled “hooks.ts”](#hooksts) The main output file. For a spec with a `/tasks` resource the generated file contains three sections. **Key factory**: one per resource, used for consistent cache invalidation: ```ts // src/api/hooks.ts (auto-generated) export const taskKeys = { all: () => ['tasks'] as const, list: (params?: Parameters[0]) => ['tasks', 'list', params] as const, detail: (id: Parameters[0]) => ['tasks', id] as const, } ``` **Query hooks**: one per GET operation: ```ts // List hook: params are optional, passed directly to the query key and fetch function export function useListTasks( params?: Parameters[0], options?: Omit< UseQueryOptions>, ApiError>, 'queryKey' | 'queryFn' > ) { return useQuery>, ApiError>({ queryKey: taskKeys.list(params), queryFn: () => listTasks(params), staleTime: 30000, gcTime: 300000, ...options, }) } // Detail hook: id widened to allow undefined/null, auto-disabled until id is set export function useGetTask( id: Parameters[0] | undefined | null, options?: Omit< UseQueryOptions>, ApiError>, 'queryKey' | 'queryFn' > ) { return useQuery>, ApiError>({ queryKey: taskKeys.detail(id!), queryFn: () => getTask(id!), staleTime: 30000, gcTime: 300000, enabled: id != null && (options?.enabled ?? true), ...options, }) } ``` **Mutation hooks**: one per POST/PUT/PATCH/DELETE: ```ts export function useCreateTask( options?: Omit< UseMutationOptions< Awaited>, ApiError, Parameters[0] >, 'mutationFn' > ) { return useMutation< Awaited>, ApiError, Parameters[0] >({ mutationFn: (vars) => createTask(vars), ...options, }) } // Update/patch: variables are destructured into { id, body } to match the client signature export function useUpdateTask( options?: Omit< UseMutationOptions< Awaited>, ApiError, { id: Parameters[0]; body: Parameters[1] } >, 'mutationFn' > ) { return useMutation< Awaited>, ApiError, { id: Parameters[0]; body: Parameters[1] } >({ mutationFn: ({ id, body }) => updateTask(id, body), ...options, }) } // Delete with a single path param: variables type is the path param type (Parameters[0]) export function useDeleteTask( options?: Omit< UseMutationOptions< Awaited>, ApiError, Parameters[0] >, 'mutationFn' > ) { return useMutation< Awaited>, ApiError, Parameters[0] >({ mutationFn: (id) => deleteTask(id), ...options, }) } ``` ### `test-utils.ts` [Section titled “test-utils.ts”](#test-utilsts) A testing helper file generated alongside `hooks.ts`. It exports `createTestQueryClient()` and `createWrapper()` with no new dependencies: everything it imports (`@tanstack/react-query`, `react`) is already a peer dependency you have installed. See [Testing your hooks](#testing-your-hooks) for full copy-pasteable examples. ## Usage examples [Section titled “Usage examples”](#usage-examples) ### Query hook with optional filter params [Section titled “Query hook with optional filter params”](#query-hook-with-optional-filter-params) ```tsx import { useListTasks } from './src/api/hooks' function TaskBoard() { // params are optional; omitting them fetches all tasks const { data, isLoading, isError, error } = useListTasks({ status: 'pending', page: 1 }) if (isLoading) return if (isError) return

Error: {error.message}

return (
    {data?.items.map((task) => (
  • {task.title}
  • ))}
) } ``` ### Detail hook with auto-disable [Section titled “Detail hook with auto-disable”](#detail-hook-with-auto-disable) The path-parameter hook (`useGetTask`) widens `id` to allow `undefined | null` (the base type is derived from the client function) and sets `enabled: false` automatically when the value is nullish. No `enabled: !!id` required at the call site: ```tsx import { useGetTask } from './src/api/hooks' function TaskDetail({ taskId }: { taskId: string | null }) { // Hook does not fire while taskId is null; no boilerplate needed const { data, isLoading } = useGetTask(taskId) if (isLoading) return return

{data?.title}

} ``` ### Mutation hook [Section titled “Mutation hook”](#mutation-hook) ```tsx import { useCreateTask } from './src/api/hooks' function CreateTaskForm() { const create = useCreateTask({ onSuccess: (task) => { console.log('Created task', task.id) }, onError: (err) => { console.error(err.status, err.body) }, }) return (
{ e.preventDefault() create.mutate({ title: 'New task' }) }} >
) } ``` ### Suspense variant [Section titled “Suspense variant”](#suspense-variant) When `suspense: true` is set in config, a `useSuspense*` hook is generated alongside every query hook. The data type is never `undefined` inside a Suspense boundary: ```tsx import { Suspense } from 'react' import { useSuspenseGetTask } from './src/api/hooks' function TaskDetail({ taskId }: { taskId: string }) { // data is guaranteed non-undefined: parent must wrap in const { data } = useSuspenseGetTask(taskId) return

{data.title}

} function TaskPage({ taskId }: { taskId: string }) { return ( }> ) } ``` Note Suspense hooks require a non-nullish path parameter. They do not support the `enabled` option and do not widen the parameter type. Wrap the parent in a `` boundary and a React error boundary for production use. ### Infinite query hooks [Section titled “Infinite query hooks”](#infinite-query-hooks) When `infinite_query` is `'auto'` (the default) or `true`, paginated list endpoints get a `useXxxInfinite` hook on top of React Query’s `useInfiniteQuery`. The hook is generated, but you supply `getNextPageParam` and `initialPageParam` through `options` to drive real pagination, because only your API knows how its cursor or page field is named: ```tsx import { useListTasksInfinite } from './src/api/hooks' function TaskFeed() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useListTasksInfinite( { status: 'open' }, { initialPageParam: 1, getNextPageParam: (lastPage, allPages) => lastPage.items.length > 0 ? allPages.length + 1 : undefined, } ) return ( <> {data?.pages .flatMap((page) => page.items) .map((task) => ( ))} ) } ``` The `params` argument feeds the first page. React Query passes `pageParam` to subsequent fetches, so your `getNextPageParam` return value flows back into the page key. Set `infinite_query: false` to turn this generation off entirely. ### Auto-invalidate on mutation success [Section titled “Auto-invalidate on mutation success”](#auto-invalidate-on-mutation-success) Set `auto_invalidate: true` in config and mutation hooks automatically call `queryClient.invalidateQueries` on success. No `useQueryClient` boilerplate at the call site: ```ts // With auto_invalidate: true in config const create = useCreateTask() create.mutate({ title: 'New task' }) // taskKeys.all() is invalidated automatically on success const update = useUpdateTask() update.mutate({ id: 'task-1', body: { title: 'Updated' } }) // taskKeys.all() AND taskKeys.detail('task-1') are both invalidated on success ``` Invalidation scope: | HTTP method | Queries invalidated | | --------------- | ------------------------------------------------ | | `POST` | `resourceKeys.all()` | | `PUT` / `PATCH` | `resourceKeys.all()` + `resourceKeys.detail(id)` | | `DELETE` | `resourceKeys.all()` | Note DELETE invalidates `all()` only. Because React Query uses prefix matching for cache invalidation, this also evicts any `detail(id)` entries for that resource. Explicit detail invalidation is only generated for `PUT` and `PATCH`, where the updated resource should be re-fetched by ID. Your own `onSuccess` callback (if provided in `options`) is called **after** auto-invalidation runs. The generated `onSuccess` composes your callback rather than replacing it. The emitted object spreads `...options` first, then declares the `onSuccess` key immediately after, so the generated `onSuccess` intentionally overwrites any caller-provided `onSuccess` while still invoking it internally via `options?.onSuccess?.(...args)`: ```ts // The generated mutation hook (simplified, auto_invalidate: true) // ...options is spread first; onSuccess follows it, overriding any onSuccess in options onSuccess: (...args) => { queryClient.invalidateQueries({ queryKey: taskKeys.all() }) options?.onSuccess?.(...args) // your callback runs after invalidation } ``` Tip `auto_invalidate` only adds invalidation for resources that also have at least one GET operation in the spec. Mutation-only resources are unaffected. ### Per-resource cache timing [Section titled “Per-resource cache timing”](#per-resource-cache-timing) Use `overrides` in config to set different `staleTime` and `gcTime` per resource. The key is the first static path segment (e.g. `tasks` for `/api/v1/tasks/{id}`): ```json { "input_openapi": "./openapi.json", "output": "./src/api", "stale_time": 30000, "gc_time": 300000, "overrides": { "platforms": { "stale_time": 86400000 }, "settings": { "stale_time": 5000, "gc_time": 60000 } } } ``` Non-overridden resources use the global `stale_time` and `gc_time` values. ### Multiple specs [Section titled “Multiple specs”](#multiple-specs) Use `--config` to point at different config files per vendor API. Both generators accept the same flag and resolve relative paths from the config file’s directory: ```bash npx openapi-zod-ts --config ./config/payments.config.json npx openapi-react-query --config ./config/payments.config.json npx openapi-zod-ts --config ./config/inventory.config.json npx openapi-react-query --config ./config/inventory.config.json ``` ## Key factory shapes in depth [Section titled “Key factory shapes in depth”](#key-factory-shapes-in-depth) ### Single-detail resources [Section titled “Single-detail resources”](#single-detail-resources) When a resource has exactly one GET with path parameters, the key factory uses the canonical `detail(id)` shape: ```ts // Spec: GET /tasks/{id} only export const taskKeys = { all: () => ['tasks'] as const, list: (params?) => ['tasks', 'list', params] as const, detail: (id: Parameters[0]) => ['tasks', id] as const, } ``` ### Multi-detail resources [Section titled “Multi-detail resources”](#multi-detail-resources) When a resource has **more than one** GET with path parameters (e.g. `/items/{id}` and `/items/{id}/usage`), the key factory uses operation-name-derived segments to prevent cache key collisions: ```ts // Spec: GET /items/{id} operationId: getItemById // GET /items/{id}/usage operationId: getItemUsage export const itemKeys = { all: () => ['items'] as const, getItemById: (id: Parameters[0]) => ['items', 'getItemById', id] as const, getItemUsage: (id: Parameters[0]) => ['items', 'getItemUsage', id] as const, } ``` Without the operation-name segment both keys would resolve to `['items', id]`, causing React Query to treat them as the same cache entry. Note When multiple detail ops exist, the key factory does **not** have a `detail` key. Auto-invalidation also skips per-detail invalidation for these resources (only `all()` is invalidated), because there is no single unambiguous detail key to target. ### Required vs optional query params [Section titled “Required vs optional query params”](#required-vs-optional-query-params) Query params become **required** (no `?`) in the generated hook signature when the spec marks any query parameter `required: true`. Optional params keep the `params?` form. ```ts // Spec marks 'cursor' as required: true export function useListItems( params: Parameters[0], // required, no ? options?: ... ) ``` ## Error handling [Section titled “Error handling”](#error-handling) Every generated hook types errors as `ApiError`. This class is generated by `openapi-zod-ts` into your `client.ts` output file: ```ts export class ApiError extends Error { constructor( public readonly status: number, // HTTP status code, e.g. 422 public readonly body: unknown, // parsed JSON response body (or null) ) { ... } } ``` ### Handling errors in query hooks [Section titled “Handling errors in query hooks”](#handling-errors-in-query-hooks) ```tsx const { data, isError, error } = useGetTask(taskId) if (isError) { // error is typed ApiError console.error(error.status, error.body) return

Failed to load task ({error.status})

} ``` ### Handling errors in mutation hooks [Section titled “Handling errors in mutation hooks”](#handling-errors-in-mutation-hooks) Pass an `onError` callback in `options`: ```tsx const create = useCreateTask({ onError: (err) => { // err is typed ApiError console.error('Status:', err.status) console.error('Body:', err.body) }, }) ``` ### Connecting to `@codewithagents/api-errors` [Section titled “Connecting to @codewithagents/api-errors”](#connecting-to-codewithagentsapi-errors) `ApiError` is the direct input to [`@codewithagents/api-errors`](/api-errors), which maps backend validation errors to form field errors. Mutation errors are already typed correctly: ```ts import { extractFieldErrors } from '@codewithagents/api-errors' const create = useCreateTask({ onError: (err) => { // err is ApiError; pass directly to extractFieldErrors const fieldErrors = extractFieldErrors(err, { statusCodes: [422] }) fieldErrors.forEach(({ field, message }) => form.setError(field, { message })) }, }) ``` ## Testing your hooks [Section titled “Testing your hooks”](#testing-your-hooks) The generator produces `test-utils.ts` alongside `hooks.ts` to remove the boilerplate every hook test needs: a pre-configured `QueryClient` and a `QueryClientProvider` wrapper component. You import directly from the generated file, so there are no extra packages to install beyond your test runner and any mocking library you already use. | Export | What it does | | ---------------------------- | --------------------------------------------------------------------------------------------------------- | | `createTestQueryClient()` | Returns a `QueryClient` with `retry: false` and `gcTime: 0`, the right defaults for deterministic tests | | `createWrapper(queryClient)` | Returns a React component suitable for the `wrapper` option in `renderHook` from `@testing-library/react` | ### Query hook test [Section titled “Query hook test”](#query-hook-test) The example below uses [Vitest](https://vitest.dev/), [React Testing Library](https://testing-library.com/docs/react-testing-library/api/#renderhook), and [MSW](https://mswjs.io/) for HTTP mocking. Only the last two are new additions if you are not already using them. Tip The MSW handlers below are hand-written for one endpoint, which is fine for a focused test. To back a whole hook suite, [`@codewithagents/openapi-msw`](/openapi-msw) generates MSW v2 handlers with seeded Faker data from the same OpenAPI spec, so your mocks stay in sync with the contract the hooks are derived from. src/tasks/\_\_tests\_\_/useListTasks.test.ts ```ts import { renderHook, waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { createTestQueryClient, createWrapper } from '../api/test-utils' import { useListTasks } from '../api/hooks' import { configureClient } from '../api/client-config' const server = setupServer() beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) beforeEach(() => configureClient({ baseUrl: 'https://api.example.com' })) it('returns tasks on success', async () => { server.use( http.get('https://api.example.com/api/v1/tasks', () => HttpResponse.json({ items: [{ id: '1', title: 'Ship it', status: 'pending' }], total: 1, }) ) ) const queryClient = createTestQueryClient() const wrapper = createWrapper(queryClient) const { result } = renderHook(() => useListTasks(), { wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data?.items).toHaveLength(1) expect(result.current.data?.items[0]?.title).toBe('Ship it') }) ``` ### Mutation hook test [Section titled “Mutation hook test”](#mutation-hook-test) src/tasks/\_\_tests\_\_/useCreateTask.test.ts ```ts import { act, renderHook, waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { createTestQueryClient, createWrapper } from '../api/test-utils' import { useCreateTask } from '../api/hooks' import { configureClient } from '../api/client-config' const server = setupServer() beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) beforeEach(() => configureClient({ baseUrl: 'https://api.example.com' })) it('creates a task and returns the new record', async () => { const created = { id: 'new-1', title: 'New task', status: 'pending' } server.use( http.post('https://api.example.com/api/v1/tasks', () => HttpResponse.json(created, { status: 201 }) ) ) const queryClient = createTestQueryClient() const wrapper = createWrapper(queryClient) const { result } = renderHook(() => useCreateTask(), { wrapper }) await act(async () => { result.current.mutate({ title: 'New task' }) }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) expect(result.current.data?.id).toBe('new-1') }) ``` Tip Create a fresh `queryClient` per test using `createTestQueryClient()`. Sharing one instance across tests causes stale-cache contamination and can produce flaky results. ## SSR and Next.js App Router [Section titled “SSR and Next.js App Router”](#ssr-and-nextjs-app-router) The generated hooks are **client-side only**. They call React Query’s `useQuery` and `useMutation`, which require a browser context and a `QueryClientProvider` in the component tree. They will not work in React Server Components. For SSR prefetching, use the exported key factories together with React Query’s standard `HydrationBoundary` approach: ```tsx // app/tasks/page.tsx (Server Component) import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query' import { listTasks } from '@/api/client' import { taskKeys } from '@/api/hooks' import TaskList from './TaskList' export default async function TasksPage() { const queryClient = new QueryClient() await queryClient.prefetchQuery({ queryKey: taskKeys.list(), queryFn: () => listTasks(), }) // If the endpoint has required query params, pass them to both taskKeys.list(params) and listTasks(params). return ( ) } ``` ```tsx // app/tasks/TaskList.tsx ('use client') 'use client' import { useListTasks } from '@/api/hooks' export default function TaskList() { const { data } = useListTasks() return (
    {data?.items.map((t) => (
  • {t.title}
  • ))}
) } ``` The key factories ensure that the prefetched server data matches the key that the client-side hook will look up. See the [TanStack Query SSR docs](https://tanstack.com/query/v5/docs/framework/react/guides/advanced-ssr) for the full setup. ## Deprecated operations [Section titled “Deprecated operations”](#deprecated-operations) When your spec marks an operation as `deprecated: true`, the generated hook gets a `/** @deprecated */` JSDoc comment. TypeScript and most IDEs will show a strikethrough at the call site: ```ts /** @deprecated */ export function useGetLegacyTask(id: Parameters[0] | undefined | null, options?: ...) { ... } ``` ## Works with [Section titled “Works with”](#works-with) Types and fetch client `openapi-zod-ts` generates the typed fetch client and models that these hooks wrap. Run it first. [Learn more](/openapi-zod-ts) Server interface `@codewithagents/openapi-server` generates a typed service interface and optional router (default: none) for Fastify, Hono, or Express from the same spec. [Learn more](/openapi-server) MSW mock handlers `@codewithagents/openapi-msw` generates seeded MSW v2 handlers from the same spec to back the hook tests on this page. [Learn more](/openapi-msw) Form error mapping `@codewithagents/api-errors` maps `ApiError` responses from mutation hooks directly to form field errors. [Learn more](/api-errors) ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### Cache not invalidating after a mutation [Section titled “Cache not invalidating after a mutation”](#cache-not-invalidating-after-a-mutation) Check two things: 1. `auto_invalidate` must be set to `true` in your config file. 2. Auto-invalidation only fires for resources that have at least one GET operation in the spec. A mutation-only resource has no key factory, so no invalidation is generated. ### Detail hook fires even with a nullish id [Section titled “Detail hook fires even with a nullish id”](#detail-hook-fires-even-with-a-nullish-id) The generated hook emits `enabled: id != null && (options?.enabled ?? true)` and then `...options`. Because `...options` is spread after the built-in `enabled` line, any `enabled` key you pass inside `options` overwrites the entire guard expression via the spread. Omit `enabled` from `options` (or pass `enabled: undefined`) to keep the null guard in effect. ### Hooks do not work in React Server Components [Section titled “Hooks do not work in React Server Components”](#hooks-do-not-work-in-react-server-components) All generated hooks use `useQuery` or `useMutation` internally and require a browser context plus a `QueryClientProvider` ancestor. Add `'use client'` to the component file, or prefetch on the server using the key factories and `HydrationBoundary` (see [SSR and Next.js App Router](#ssr-and-nextjs-app-router) above). # Server interface > Generate a typed service interface and optional Fastify, Hono, or Express router from your OpenAPI spec with @codewithagents/openapi-server. `@codewithagents/openapi-server` reads your OpenAPI 3.x spec and writes a typed service interface into your project. The interface is plain TypeScript with no framework imports: implement it however you want and wire it to Fastify, Hono, Express, or any router you already use. Set `"framework"` to `"fastify"`, `"hono"`, or `"express"` (default `"none"`) and the generator also scaffolds a ready-to-mount router, with optional Zod validation at the boundary so invalid requests never reach your service implementation. Fastify is the framework the project ships its canonical full-stack app on; the Hono and Express routers are first-class alternatives. ## Overview [Section titled “Overview”](#overview) Running the generator produces up to two files in your output directory: | File | Contents | | ------------ | ----------------------------------------------------------------------------------------------- | | `service.ts` | TypeScript interface with one typed `async` method per API operation. No framework imports. | | `router.ts` | Router factory, generated only when `framework` is set to `"hono"`, `"express"`, or `"fastify"` | Key guarantees: * **Framework-agnostic service interface.** `service.ts` has zero framework dependencies. The TypeScript compiler enforces the contract: add an endpoint in the spec and forget to implement it, and the build fails. * **Optional router scaffolding.** Choose `"fastify"`, `"hono"`, or `"express"` to get a ready-to-mount router as a starting point. Use `"none"` (the default) to wire the interface yourself with any framework. * **Supports GET, POST, PUT, PATCH, DELETE.** All five HTTP methods are generated when present in the spec. * **Zod validation at the boundary.** Point `input_schema` at the same Zod schema file you use with `openapi-zod-ts`. Hono and Express get `safeParse` calls that return a structured `422` before the call ever reaches your service. Fastify wires the same schemas through `fastify-type-provider-zod` and rejects invalid requests with its native `400` (`FST_ERR_VALIDATION`). * **Prettier-clean output.** Every generated file passes `prettier --check` with your project’s own Prettier config. * **`strict: true`.** All output compiles cleanly with TypeScript strict mode. * **OpenAPI 3.1.x** is the primary target (including 3.1.1). OpenAPI 3.0.x is supported in practice: 8 of the 13 showcase specs are 3.0.x and all compile under `tsc --strict`. Full support for `$ref`, `allOf`, `anyOf`, `oneOf`, and `nullable`. ## Install [Section titled “Install”](#install) * npm ```sh npm i -D @codewithagents/openapi-server ``` * pnpm ```sh pnpm add -D @codewithagents/openapi-server ``` * yarn ```sh yarn add -D @codewithagents/openapi-server ``` Note `@codewithagents/openapi-server` requires `openapi-zod-ts`. Run `openapi-zod-ts` first so `models.ts` exists before `service.ts` imports from it. Note The generated service examples use `node:crypto` (`randomUUID`). Node.js 18 or later is required. ## Minimal example [Section titled “Minimal example”](#minimal-example) **1. Create `openapi-server.config.json` in your project root:** ```json { "input_openapi": "./spec/api.json", "output": "./generated", "framework": "hono" } ``` **2. Run both generators:** ```bash npx openapi-zod-ts && npx openapi-server ``` Or add a combined script to `package.json`: ```json { "scripts": { "generate": "openapi-zod-ts && openapi-server" } } ``` **3. Implement the generated service interface:** src/server/petService.ts ```typescript import { randomUUID } from 'node:crypto' import type { PetstoreService } from '../generated/service.js' import type { Pet } from '../generated/models.js' const pets = new Map() export const petService: PetstoreService = { async listPets(params) { const all = Array.from(pets.values()) if (params?.species) { return all.filter((p) => p.species.toLowerCase() === params.species!.toLowerCase()) } return all }, async createPet(body) { const pet: Pet = { id: randomUUID(), ...body } pets.set(pet.id, pet) return pet }, async getPet(id) { const pet = pets.get(id) if (!pet) throw new Error(`Pet ${id} not found`) return pet }, async deletePet(id) { pets.delete(id) }, } ``` **4. Mount the generated router and serve:** src/server/index.ts ```typescript import { serve } from '@hono/node-server' import { Hono } from 'hono' import { createRouter } from '../generated/router.js' import { petService } from './petService.js' const app = new Hono() // Mount API routes at /api const apiRouter = createRouter(petService) app.route('/api', apiRouter) serve({ fetch: app.fetch, port: 3001 }) ``` ## Configuration [Section titled “Configuration”](#configuration) The config file must be valid JSON and must have a `.json` extension. By default the generator looks for `openapi-server.config.json` in the current working directory. Use `--config ` to point at a different file. ```bash npx openapi-server --config ./config/openapi-server.config.json ``` Note When `--config` is used, all relative paths inside the file resolve from the config file’s directory, not from the shell’s working directory. ### Options [Section titled “Options”](#options) | Field | Type | Required | Default | Description | | -------------------------- | -------------------------------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `input_openapi` | `string` | Yes | n/a | Path to your OpenAPI spec (JSON or YAML) | | `output` | `string` | Yes | n/a | Directory to write the generated files | | `framework` | `"hono" \| "express" \| "fastify" \| "none"` | No | `"none"` | Router framework to generate. Use `"none"` to generate only `service.ts` | | `input_schema` | `string` | No | n/a | Path to user-owned Zod schema file. Enables server-side request validation (never overwritten) | | `context_type` | `string` | No | n/a | TypeScript type name threaded through every service method as a final `ctx` argument. On Fastify this becomes a required `createContext(req)` auth seam. See [request-scoped context](#request-scoped-context-context_type). | | `emit_response_validation` | `boolean` | No | `false` | Fastify only. Synthesize inline Zod response schemas for flat inline response shapes. See [response validation](#response-validation-opt-in-emit_response_validation-fastify-only). | | `shared_output` | `string` | No | derived | Override directory for the shared `_shared/errors.ts` file. Defaults to a location derived from the output paths. | **Full config example:** ```json { "input_openapi": "./spec/api.json", "output": "./generated", "framework": "hono", "input_schema": "./generated/schemas.ts" } ``` ## Generated files in depth [Section titled “Generated files in depth”](#generated-files-in-depth) ### `service.ts` [Section titled “service.ts”](#servicets) Given a petstore spec (`GET /pets`, `POST /pets`, `GET /pets/{id}`, `DELETE /pets/{id}`), the generator produces a plain TypeScript interface with no framework dependencies: ```typescript // generated/service.ts (auto-generated) import type { CreatePetRequest, Pet } from './models.js' export interface PetstoreService { /** GET /pets */ listPets(params?: { species?: string; limit?: number }): Promise /** POST /pets */ createPet(body: CreatePetRequest): Promise /** GET /pets/{id} */ getPet(id: string): Promise /** DELETE /pets/{id} */ deletePet(id: string): Promise } ``` The interface is regenerated every time the spec changes. If you add an endpoint in the spec and forget to implement it, TypeScript will fail the build. **Method name derivation:** method names come from `operationId` in your spec (sanitized to camelCase). When `operationId` is absent, the generator falls back to a `verb + path` heuristic: `GET /pets/{id}` becomes `getPetsById`, `POST /users` becomes `createUsers`. Any leading `/api/v{n}/` prefix (for example `/api/v1/`) is stripped before this heuristic runs, so `/api/v1/pets/{id}` and `/pets/{id}` both produce `getPetsById`. ### `router.ts` [Section titled “router.ts”](#routerts) * Hono `createRouter(service)` returns a `Hono` instance. Mount it anywhere in your app: ```typescript // generated/router.ts (auto-generated, framework: "hono") import { Hono } from 'hono' import type { CreatePetRequest } from './models.js' import type { PetstoreService } from './service.js' export function createRouter(service: PetstoreService): Hono { const app = new Hono() app.get('/pets', async (c) => { const params = { species: c.req.query('species') ?? undefined, limit: c.req.query('limit') !== undefined ? Number(c.req.query('limit')) : undefined, } return c.json(await service.listPets(params)) }) app.post('/pets', async (c) => { const body = await c.req.json() return c.json(await service.createPet(body), 201) }) app.get('/pets/:id', async (c) => { return c.json(await service.getPet(c.req.param('id'))) }) app.delete('/pets/:id', async (c) => { await service.deletePet(c.req.param('id')) return new Response(null, { status: 204 }) }) return app } ``` * Express `createRouter(service)` returns an Express `Router`. Apply `express.json()` before mounting so `req.body` is populated: ```typescript // generated/router.ts (auto-generated, framework: "express") import { Router } from 'express' import type { Request, Response } from 'express' import type { CreatePetRequest } from './models.js' import type { PetstoreService } from './service.js' export function createRouter(service: PetstoreService): Router { const router = Router() router.get('/pets', async (req: Request, res: Response) => { const params = { species: req.query['species'] as string | undefined, limit: Number(req.query['limit'] as string), } res.json(await service.listPets(params)) }) router.post('/pets', async (req: Request, res: Response) => { const body = req.body as CreatePetRequest res.status(201).json(await service.createPet(body)) }) router.get('/pets/:id', async (req: Request, res: Response) => { res.json(await service.getPet(req.params['id']!)) }) router.delete('/pets/:id', async (req: Request, res: Response) => { await service.deletePet(req.params['id']!) res.status(204).end() }) return router } ``` * Fastify `createRouter(service)` returns a `FastifyPluginAsyncZod` factory. Mount it with `fastify.register` and apply a path prefix in the register options. The router sets up `fastify-type-provider-zod` and an `HttpError` `setErrorHandler` inside the plugin scope: ```typescript // generated/router.ts (auto-generated, framework: "fastify") import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import type { CreatePetRequest } from './models.js' import type { PetstoreService } from './service.js' import { HttpError } from './_shared/errors.js' export { HttpError } from './_shared/errors.js' export function createRouter(service: PetstoreService): FastifyPluginAsyncZod { return async (app) => { // Validation, serialization, and the HttpError handler are wired here, // inside the plugin scope, before any routes are registered. app.get('/pets', async (req) => { return service.listPets(req.query) }) app.post('/pets', async (req, reply) => { reply.status(201) return service.createPet(req.body) }) app.get('/pets/:id', async (req) => { return service.getPet(req.params.id) }) app.delete('/pets/:id', async (req, reply) => { await service.deletePet(req.params.id) reply.status(204).send() }) } } ``` The router handles: * Path params: `{id}` becomes `:id`, extracted via the framework’s native param accessor * Query params: extracted and typed (`string`, `number`, `boolean`) * Request bodies: parsed and cast to the correct model type. On Fastify, `req.body`, `req.query`, and `req.params` are inferred from the attached Zod schemas, so handlers are fully typed with no manual generics * Response status: spec-driven. The generator reads your spec’s response codes first (checking for `201`, then `204`, then `200`). When no explicit code is declared, it falls back to `204` for DELETE and `200` for all other methods. ## Usage examples [Section titled “Usage examples”](#usage-examples) ### Hono: mounting at a prefix [Section titled “Hono: mounting at a prefix”](#hono-mounting-at-a-prefix) `createRouter` returns a plain `Hono` instance. Mount it at any path prefix, add middleware, or nest it inside a larger app: src/server/index.ts ```typescript import { serve } from '@hono/node-server' import { Hono } from 'hono' import { createRouter } from '../generated/router.js' import { petService } from './petService.js' const app = new Hono() app.route('/api', createRouter(petService)) serve({ fetch: app.fetch, port: 3001 }) ``` ### Express and Fastify [Section titled “Express and Fastify”](#express-and-fastify) * Express Set `"framework": "express"` and the generator produces a `createRouter` that returns an Express `Router`. Apply `express.json()` middleware before mounting so `req.body` is populated: src/server/index.ts ```typescript import express from 'express' import { createRouter } from '../generated/router.js' import { petService } from './petService.js' const app = express() app.use(express.json()) app.use('/api', createRouter(petService)) app.listen(3001) ``` * Fastify Set `"framework": "fastify"` and `createRouter(service)` returns a `FastifyPluginAsyncZod`. Mount it with `fastify.register` and pass the prefix in the register options. Add `fastify` and `fastify-type-provider-zod` to your own `dependencies`: src/server/index.ts ```typescript import Fastify from 'fastify' import { createRouter } from '../generated/router.js' import { petService } from './petService.js' const fastify = Fastify() fastify.register(createRouter(petService), { prefix: '/api' }) fastify.listen({ port: 3001 }) ``` The plugin installs `fastify-type-provider-zod` and an `HttpError` `setErrorHandler` inside its own scope, so request and response validation are native and stay encapsulated to the mounted routes. ### Framework-agnostic: `"framework": "none"` [Section titled “Framework-agnostic: "framework": "none"”](#framework-agnostic-framework-none) Use `"none"` when you want to own the routing layer entirely. The generator produces only `service.ts` and adds no runtime dependencies: ```json { "input_openapi": "./spec/api.json", "output": "./generated", "framework": "none" } ``` Implement the interface and wire it to any router manually: ```typescript import { createServer } from 'node:http' import type { PetstoreService } from '../generated/service.js' import { petService } from './petService.js' // Wire PetstoreService however you like: vanilla http, Koa, Bun, Deno, or anything else. ``` ### Zod validation (`input_schema`) [Section titled “Zod validation (input\_schema)”](#zod-validation-input_schema) Point `input_schema` at the same `schemas.ts` you use with `openapi-zod-ts`. For the Hono and Express routers the generator adds `safeParse` calls to every route that receives a request body. Invalid requests get a structured `422` response and never reach your service implementation. The Fastify router validates differently: it wires the same Zod schemas through `fastify-type-provider-zod` and rejects with Fastify’s native `400` (see the Fastify note below). **Config:** ```json { "input_openapi": "./spec/api.json", "output": "./generated", "framework": "hono", "input_schema": "./generated/schemas.ts" } ``` **Generated router with validation (two-pass output):** ```typescript app.post('/pets', async (c) => { const body = await c.req.json() // Validate request body: returns 422 with Zod issues on failure const parseResult = CreatePetRequestSchema.safeParse(body) if (!parseResult.success) { return c.json({ error: 'Invalid request body', issues: parseResult.error.issues }, 422) } const validatedBody = parseResult.data return c.json(await service.createPet(validatedBody), 201) }) ``` Invalid requests receive a structured `422` response: ```json { "error": "Invalid request body", "issues": [{ "code": "too_small", "path": ["name"], "message": "Name is required" }] } ``` Tip The schema naming convention is `${TypeName}Schema`. For a request body type `CreatePetRequest`, the generator looks for `CreatePetRequestSchema` in the schema file. Unmatched schemas trigger a warning to stderr; builds still succeed. Note **Same schemas, both sides of the wire.** `openapi-zod-ts` validates outgoing requests in the browser. `@codewithagents/openapi-server` validates incoming requests on the server. One `schemas.ts`, one source of truth. Note **Fastify validation is native.** The Fastify router does not hand-roll the `safeParse` block shown above. It registers `fastify-type-provider-zod` once and attaches each operation’s Zod schemas to the route (`schema: { body, querystring, params, headers, response }`), so Fastify validates and types requests through its own pipeline. Validation failures return Fastify’s native `400` (`FST_ERR_VALIDATION`), not the `422 { error, issues }` envelope that Hono and Express emit. Cookie params (`in: cookie`) are the exception: Fastify has no native cookie schema, so those keep a `_ckv` safeParse block and still return `422 { error: 'Invalid request cookies', issues }`. The generator runs in two passes: first it writes `router.ts` without any Zod imports, then it rewrites `router.ts` with validation for every route whose body type has a matching schema in `input_schema`. For Hono and Express this means `safeParse` calls; for Fastify it means schemas threaded through `fastify-type-provider-zod`. If the schema file does not exist yet at generation time, the second pass is skipped and validation is added on the next run. ## Middleware and authentication [Section titled “Middleware and authentication”](#middleware-and-authentication) The generated router is a plain framework object. Add any middleware before mounting it: * Hono ```typescript import { Hono } from 'hono' import { bearerAuth } from 'hono/bearer-auth' import { cors } from 'hono/cors' import { createRouter } from '../generated/router.js' import { petService } from './petService.js' const app = new Hono() // Auth and CORS apply to all /api routes app.use('/api/*', cors()) app.use('/api/*', bearerAuth({ token: process.env.API_TOKEN! })) app.route('/api', createRouter(petService)) ``` * Express ```typescript import express from 'express' import { createRouter } from '../generated/router.js' import { petService } from './petService.js' const app = express() app.use(express.json()) // Auth middleware runs before the generated router app.use('/api', (req, res, next) => { if (req.headers.authorization !== `Bearer ${process.env.API_TOKEN}`) { return res.status(401).json({ error: 'Unauthorized' }) } next() }) app.use('/api', createRouter(petService)) ``` * Fastify ```typescript import Fastify from 'fastify' import { createRouter } from '../generated/router.js' import { petService } from './petService.js' const app = Fastify() // The Fastify router accepts plugin-scoped lifecycle hooks directly. // onRequest fires before validation: ideal for auth. app.register( createRouter(petService, { onRequest: async (req, reply) => { if (req.headers.authorization !== `Bearer ${process.env.API_TOKEN}`) { return reply.status(401).send({ error: 'Unauthorized' }) } }, }), { prefix: '/api' } ) ``` The Fastify router takes `onRequest`, `preHandler`, `onSend`, and `onError` hooks in its `CreateRouterOptions`, each accepting a single handler or an array. They are plugin-scoped: they cover the generated routes (and any `registerCustomRoutes`) without leaking to the parent instance. ## Error handling [Section titled “Error handling”](#error-handling) The generated router maps thrown errors in two cases. Throw `HttpError` (re-exported from `router.ts`) from a service method to return a structured response at a chosen status; any other error propagates to your app-level handler. Hono and Express implement this with a per-route `try/catch`; Fastify registers a single `HttpError` `setErrorHandler` inside its plugin scope. Add a framework-level error handler to shape responses for the errors that propagate: ```typescript import { HttpError } from '../generated/router.js' // Inside a service method: if (!pet) throw new HttpError(404, 'Pet not found') ``` * Hono ```typescript import { Hono } from 'hono' import { createRouter } from '../generated/router.js' import { petService } from './petService.js' const app = new Hono() app.route('/api', createRouter(petService)) app.onError((err, c) => { console.error(err) return c.json({ error: err.message }, 500) }) ``` * Express ```typescript import express, { NextFunction, Request, Response } from 'express' import { createRouter } from '../generated/router.js' import { petService } from './petService.js' const app = express() app.use(express.json()) app.use('/api', createRouter(petService)) // Error middleware must have four parameters app.use((err: Error, req: Request, res: Response, next: NextFunction) => { console.error(err) res.status(500).json({ error: err.message }) }) ``` * Fastify ```typescript import Fastify from 'fastify' import { createRouter } from '../generated/router.js' import { petService } from './petService.js' const app = Fastify() // The router maps HttpError itself. This app-level handler covers // every other error the router re-throws. app.setErrorHandler((err, req, reply) => { console.error(err) reply.status(500).send({ error: 'Internal server error' }) }) app.register(createRouter(petService), { prefix: '/api' }) ``` ## Request-scoped context (`context_type`) [Section titled “Request-scoped context (context\_type)”](#request-scoped-context-context_type) Set `context_type` to thread a typed caller context through every generated service method. Use it to pass an authentication principal, a tenant ID, or any per-request metadata without coupling service code to framework types. Each method gains a final `ctx` argument, and the interface becomes generic with a `Ctx = never` default so existing implementations keep compiling. ```json { "input_openapi": "./spec/api.json", "output": "./generated", "framework": "fastify", "context_type": "RequestContext" } ``` For Hono and Express the router passes the framework’s native request or context object as `ctx`. For Fastify, `context_type` is a runtime auth seam, not just a type: `createRouter` becomes generic over `Ctx` and **requires** an options object with a `createContext(req)` hook. The hook runs first inside every handler, so it is the place to authenticate the request and build the typed principal. Throw `HttpError(401)` to reject before any handler work runs; the return value is passed as `ctx` to every service method. ```typescript import Fastify from 'fastify' import { createRouter, HttpError } from '../generated/router.js' import type { PetstoreService } from '../generated/service.js' interface RequestContext { userId: string scopes: string[] } const petService: PetstoreService = { async listPets(params, ctx) { return db.listPets({ userId: ctx.userId, ...params }) }, // ... other operations receive ctx as their final argument } const app = Fastify() app.register( createRouter(petService, { createContext: (req) => { const userId = req.headers['x-user-id'] if (typeof userId !== 'string') throw new HttpError(401, 'Unauthorized') return { userId, scopes: [] } }, }), { prefix: '/v1' } ) ``` Operation-level `security` from the spec (falling back to the global `security`) is surfaced on each route at `req.routeOptions.config.security` as `Array<{ scheme, scopes }>`, and as a `@security` JSDoc tag on each service method. Security is metadata only: `createContext` (or a `preHandler` hook) is where you enforce it. Tip The canonical full-stack reference, [`petstore-fastify`](https://github.com/codewithagents/openapi-zod-ts/tree/main/packages/petstore-fastify), is a live demonstration of this seam: Fastify with `createContext` auth, a cross-field validation rule, and a React/react-query frontend that round-trips a cross-field error onto a form field. ## Custom routes and lifecycle hooks (Fastify) [Section titled “Custom routes and lifecycle hooks (Fastify)”](#custom-routes-and-lifecycle-hooks-fastify) The Fastify `createRouter` accepts a `CreateRouterOptions` object with several Fastify-only escape hatches: * **`registerCustomRoutes(app)`**: register non-spec routes (health checks, metrics) that inherit the ZodTypeProvider context and the `HttpError` handler. Runs after the compilers and parsers are set up, before the spec routes. * **`onRequest`, `preHandler`, `onSend`, `onError`**: plugin-scoped lifecycle hooks, each a single handler or an array. They cover generated and custom routes but do not propagate to the parent instance. Execution order per request: `onRequest` then `preHandler` then handler then `onSend`. * **`registerParsers: false`**: skip auto-registering `@fastify/formbody` and `@fastify/multipart` so you can register them yourself with custom options. `onError` hooks fire for observability only; the registered `HttpError` handler (or your `setErrorHandler`) remains the single response producer. ```typescript import { createRouter, HttpError } from '../generated/router.js' fastify.register( createRouter(petService, { registerCustomRoutes: async (app) => { app.get('/health', async (_req, reply) => reply.send({ status: 'ok' })) }, onRequest: authHook, onError: [metricsHook, loggingHook], }), { prefix: '/api' } ) ``` ## Non-JSON request bodies (Fastify) [Section titled “Non-JSON request bodies (Fastify)”](#non-json-request-bodies-fastify) Fastify 5 natively parses only `application/json` and `text/plain`. For other content types declared in your spec, the Fastify router registers the required plugins automatically inside its plugin scope: | Content type | What the router does | You install | | ----------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------- | | `application/x-www-form-urlencoded` | Auto-registers `@fastify/formbody` | `@fastify/formbody` | | `multipart/form-data` | Auto-registers `@fastify/multipart` with `attachFieldsToBody: true` | `@fastify/multipart` | | `application/octet-stream` | Emits an `addContentTypeParser` call; the body is forwarded to your service as a `Buffer` | nothing (core Fastify) | Pass `registerParsers: false` to register the multipart plugin yourself when you need custom options such as upload size limits. ## Response validation opt-in (`emit_response_validation`, Fastify only) [Section titled “Response validation opt-in (emit\_response\_validation, Fastify only)”](#response-validation-opt-in-emit_response_validation-fastify-only) Set `emit_response_validation: true` to have the Fastify emitter synthesize inline Zod expressions for `schema.response` on routes that have no named schema from `input_schema`. The synthesizer is intentionally minimal: it handles flat objects with scalar properties, scalar top-level responses, and arrays of scalars, and falls back to `z.unknown()` for `$ref`, `allOf`, `oneOf`, `anyOf`, or nested objects. For complete, type-safe response validation, point `input_schema` at the `schemas.ts` from `openapi-zod-ts` instead. ## What is out of scope [Section titled “What is out of scope”](#what-is-out-of-scope) Coverage differs by framework. The Fastify router validates the most through `fastify-type-provider-zod`; the Hono and Express routers cover bodies via `safeParse` and leave the rest to you. | Feature | Fastify | Hono / Express | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Request body validation | Native via `fastify-type-provider-zod`. Rejects with `400 FST_ERR_VALIDATION`. | `safeParse` when `input_schema` is set. Rejects with `422 { error, issues }`. | | Query and path validation | Native, against the Zod schemas attached to each route. | Not validated. Path params reach your service as raw strings. | | Request header and cookie validation | Headers validated natively; cookie params validated via a `_ckv` safeParse block (`422`). Requires `@fastify/cookie`. | Not extracted or validated. | | Response-body validation | Available. Point `input_schema` at your `schemas.ts`, or set `emit_response_validation` for simple inline shapes. | Not generated. Validate in your service if needed. | | Security scheme enforcement | Surfaced as metadata: `req.routeOptions.config.security` and a `@security` JSDoc tag. Enforce it in `createContext` or a `preHandler`. | Not generated. Add auth middleware manually (see above). | | `multipart/form-data` bodies | Auto-registers `@fastify/multipart` with `attachFieldsToBody: true` so `req.body` is populated. | Wire file upload routes manually. | ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) **Requests are not validated; invalid bodies reach my service.** Check that `input_schema` is set in your config and the schema file exists. If the schema file is missing at generation time, the Zod pass is skipped and validation is not added. Also confirm that the schema name matches the convention: for a body type `CreatePetRequest`, the schema must be exported as `CreatePetRequestSchema`. A mismatch triggers a warning to stderr. **`Cannot find module './models.js'` at runtime.** `service.ts` imports from `models.ts` which is generated by `openapi-zod-ts`. Run `openapi-zod-ts` before `openapi-server`. A combined script (`"generate": "openapi-zod-ts && openapi-server"`) prevents this. **My service throws but the client sees `500` with no details.** The generated router maps `HttpError` to its status, but re-throws every other error to your app-level handler, which typically returns a bare `500`. Throw `HttpError(status, message)` for responses you control, and add a framework error handler as shown in the [Error handling](#error-handling) section for everything else. ## Works with [Section titled “Works with”](#works-with) `@codewithagents/openapi-server` is one of five published packages driven from the same OpenAPI 3.x spec: four generators (`openapi-zod-ts`, `openapi-server`, `openapi-react-query`, `openapi-msw`) plus `api-errors`, a runtime helper used in app code. Types and fetch client `openapi-zod-ts` generates the `models.ts` that `service.ts` imports from. Run both generators together against the same spec. [Learn more](/openapi-zod-ts) React Query hooks Add `@codewithagents/openapi-react-query` to generate typed `useQuery` and `useMutation` hooks for the same spec your server implements. [Learn more](/openapi-react-query) MSW mock handlers Add `@codewithagents/openapi-msw` to generate MSW v2 HTTP handlers with seeded Faker mock data, so the frontend can develop against the same spec before the server is live. [Learn more](/openapi-msw) Form error mapping Add `@codewithagents/api-errors` to map API error responses from the generated client directly to form field errors. [Learn more](/api-errors) See the [`petstore-fastify` demo](https://github.com/codewithagents/openapi-zod-ts/tree/main/packages/petstore-fastify) for the canonical full-stack example: Fastify with `createContext` auth, a cross-field validation rule, and a React/react-query frontend, with these packages working together. # Types and fetch client > Generate TypeScript types, a native fetch client, and Zod schemas from your OpenAPI spec with openapi-zod-ts. `openapi-zod-ts` reads your OpenAPI 3.x spec and writes a fully typed native `fetch` client, TypeScript interfaces, and optional Zod schemas into your project. Every generated file passes `prettier --check` and TypeScript `strict: true` out of the box, with zero runtime dependencies beyond `fetch`. The package is tested against 128 real-world specs (Stripe, GitHub, Spotify, OpenAI, and more) on every PR. The generated output for 13 of those (the showcase specs) is committed to the repo and drift-checked plus typechecked under `tsc --noEmit --strict` on every relevant PR, so regressions surface as a failing diff. ## Overview [Section titled “Overview”](#overview) Running the generator produces up to five files in your output directory: | File | Contents | | ------------------ | -------------------------------------------------------------------------------------- | | `models.ts` | TypeScript interfaces and union types for every schema in `components.schemas` | | `client.ts` | One typed `async function` per API operation, using native `fetch` | | `client-config.ts` | `configureClient()` and `getConfig()`: set base URL, auth, and headers once at startup | | `index.ts` | Barrel re-export of all three files above | | `server.ts` | `createServerClient()` factory (generated only when `server_client: true`) | When `input_schema` is set, the generator also bootstraps a `schemas.ts` file at the path you specify on the first run. Key guarantees: * **Zero runtime footprint.** The generated client uses only `fetch`. No axios, no wrapper libraries. * **Prettier-clean.** Every file passes `prettier --check` with your project’s own Prettier config. * **`strict: true`.** All output compiles cleanly with TypeScript strict mode. * **SSR-safe.** Every generated function accepts an optional per-request config override. No global singleton mutation, safe for concurrent server requests. * **OpenAPI 3.1.x** is the primary target (including 3.1.1). OpenAPI 3.0.x is supported in practice: 8 of the 13 showcase specs are 3.0.x and all compile under `tsc --strict`. Full support for `$ref`, `allOf`, `anyOf`, `oneOf`, and `nullable` (3.0.x `nullable: true` is normalized to the 3.1 null-union form, so nullable fields produce the correct `T | null` types). ## Install [Section titled “Install”](#install) * npm ```sh npm i -D openapi-zod-ts ``` * pnpm ```sh pnpm add -D openapi-zod-ts ``` * yarn ```sh yarn add -D openapi-zod-ts ``` ## Minimal example [Section titled “Minimal example”](#minimal-example) **1. Create `openapi-zod-ts.config.json` in your project root:** ```json { "input_openapi": "./openapi.json", "output": "./src/api" } ``` **2. Run the generator:** ```bash npx openapi-zod-ts ``` **3. Import from the generated barrel:** ```typescript import { configureClient } from './src/api' import { listTasks, createTask, ApiError } from './src/api' // Call once at app startup configureClient({ baseUrl: 'https://api.example.com', token: () => getAccessToken(), }) // Every function is fully typed const page = await listTasks({ status: 'pending', page: 1 }) try { const task = await createTask({ title: 'Ship it' }) console.log(task.id) } catch (err) { if (err instanceof ApiError) { console.error(err.status, err.body) } } ``` ## Configuration [Section titled “Configuration”](#configuration) The config file may be a JSON file (`.json`) or a JS/ESM module (`.js`, `.mjs`, `.cjs`). By default the generator looks for `openapi-zod-ts.config.json` in the current working directory. JS config files can import the `defineConfig` helper from `openapi-zod-ts` for autocomplete and type safety: openapi-zod-ts.config.mjs ```js import { defineConfig } from 'openapi-zod-ts' export default defineConfig({ input_openapi: './openapi.json', output: './src/api', }) ``` ### Full configuration reference [Section titled “Full configuration reference”](#full-configuration-reference) | Field | Type | Required | Default | Description | | --------------- | --------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | | `input_openapi` | `string` | Yes | none | Path to your OpenAPI spec (JSON or YAML) | | `output` | `string` | Yes | none | Directory to write the generated files | | `input_schema` | `string` | No | none | Path to a user-owned Zod schema file. Bootstrapped on first run if the file does not exist; never overwritten after that | | `baseUrl` | `string` | No | `""` | Default base URL embedded in the generated `client-config.ts` | | `server_client` | `boolean` | No | `false` | When `true`, also generates `server.ts` with a `createServerClient()` factory | **Full config example:** ```json { "input_openapi": "./openapi.json", "output": "./src/api", "input_schema": "./src/api/schemas.ts", "baseUrl": "https://api.example.com", "server_client": true } ``` ### Multi-spec projects array [Section titled “Multi-spec projects array”](#multi-spec-projects-array) When a single config file needs to drive generation for several OpenAPI specs, use a top-level `projects` array. Each entry is a full config object. The generator runs them sequentially. ```json { "projects": [ { "input_openapi": "./services/users.json", "output": "./src/users" }, { "input_openapi": "./services/orders.json", "output": "./src/orders" } ] } ``` JS config files can use the `defineProjects` helper for the same result with type safety: openapi-zod-ts.config.mjs ```js import { defineProjects } from 'openapi-zod-ts' export default defineProjects([ { input_openapi: './services/users.json', output: './src/users' }, { input_openapi: './services/orders.json', output: './src/orders' }, ]) ``` Caution `projects` and top-level `input_openapi`/`output` are mutually exclusive. Mixing them in the same config file is a validation error. Use one form or the other. ## CLI reference [Section titled “CLI reference”](#cli-reference) ```bash npx openapi-zod-ts # uses openapi-zod-ts.config.json in CWD npx openapi-zod-ts --config ./config/openapi-zod-ts.config.json # explicit path ``` | Flag | Argument | Description | | ---------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--config` | `` | Path to a config file (`.json`, `.js`, `.mjs`, or `.cjs`). Relative paths inside the config file resolve from the config file’s directory, not from the shell’s working directory. | | `--input` | `` | Path to the OpenAPI spec file. Overrides `input_openapi` from the config file for this run. | | `--output` | `` | Output directory. Overrides `output` from the config file for this run. | | `--watch` | *(none)* | Re-run generation whenever the spec file changes. Press Ctrl-C to exit. | Note The `--config` flag accepts `.json`, `.js`, `.mjs`, and `.cjs` files. Any other extension is rejected with an error. When `--config` is used, all relative paths inside the config file resolve from the config file’s directory, not from the shell’s working directory. ## Generated files in depth [Section titled “Generated files in depth”](#generated-files-in-depth) ### `client-config.ts` [Section titled “client-config.ts”](#client-configts) The generated `ClientConfig` interface covers auth, credentials, and global error handling: ```typescript // src/api/client-config.ts (auto-generated) export interface ClientConfig { baseUrl: string; token?: string | (() => string | Promise); credentials?: RequestCredentials; headers?: Record; onError?: (err: Error) => void; } export function configureClient(config: ClientConfig): void { ... } export function getConfig(): Readonly { ... } ``` The default `credentials` is `'same-origin'`. When the spec declares a cookie-based `apiKey` security scheme, the generated default is `'include'` instead. Call `configureClient` once at startup. Calling it a second time merges into the existing config rather than replacing it: fields not provided in the later call are inherited from the earlier call, not reset to defaults. The `token` field accepts a string or an async function, called per request, so JWT refresh flows work automatically. ### `models.ts` [Section titled “models.ts”](#modelsts) Each schema in `components.schemas` becomes a TypeScript interface or type alias. String enums also produce a `const` array for runtime iteration: ```typescript // src/api/models.ts (auto-generated) export type TaskStatus = 'pending' | 'in_progress' | 'done' export const TaskStatusValues = ['pending', 'in_progress', 'done'] as const export interface Task { id: string title: string description?: string | null status: TaskStatus priority?: number assigneeEmail?: string | null createdAt: string /* date-time */ } export interface CreateTaskRequest { title: string description?: string | null priority?: number assigneeEmail?: string | null } ``` ### `client.ts` [Section titled “client.ts”](#clientts) One typed `async function` per operation. Query parameters, path parameters, and request bodies are all typed. Each function accepts an optional `config` override as its final parameter: ```typescript // src/api/client.ts (auto-generated) export async function listTasks( params?: { status?: "pending" | "in_progress" | "done"; page?: number; pageSize?: number; }, config?: Partial, ): Promise { ... } export async function createTask( body: CreateTaskRequest, config?: Partial, ): Promise { ... } ``` ## Error handling [Section titled “Error handling”](#error-handling) Every generated function throws `ApiError` for non-2xx responses. The class is emitted directly into `client.ts`: ```typescript // src/api/client.ts (auto-generated) export class ApiError extends Error { constructor( public readonly status: number, // HTTP status code, e.g. 422 public readonly body: unknown // parsed JSON body, or null if parsing failed ) { super(`API error ${status}`) this.name = 'ApiError' } } ``` Use `instanceof` to narrow and read `err.status` and `err.body`: ```typescript try { const task = await createTask({ title: 'Ship it' }) } catch (err) { if (err instanceof ApiError) { console.error(err.status, err.body) // err.body is unknown, narrow it before use: if (typeof err.body === 'object' && err.body !== null && 'message' in err.body) { console.error((err.body as { message: string }).message) } } } ``` `ApiError` is also the direct input to [`@codewithagents/api-errors`](/api-errors), which maps validation errors to form field errors. ## Usage examples [Section titled “Usage examples”](#usage-examples) ### Bearer token auth [Section titled “Bearer token auth”](#bearer-token-auth) ```typescript import { configureClient } from './src/api' configureClient({ baseUrl: 'https://api.example.com', token: () => getAccessToken(), // sync or async, called per request credentials: 'omit', }) ``` ### Cookie-based auth [Section titled “Cookie-based auth”](#cookie-based-auth) ```typescript configureClient({ baseUrl: 'https://api.example.com', credentials: 'include', // sends HttpOnly cookies automatically }) ``` ### Global error hook [Section titled “Global error hook”](#global-error-hook) ```typescript configureClient({ baseUrl: 'https://api.example.com', onError: (err) => { // called before every ApiError is thrown, use for logging or monitoring Sentry.captureException(err) }, }) ``` ### SSR with per-request config override [Section titled “SSR with per-request config override”](#ssr-with-per-request-config-override) Every generated function merges a `config` override into the global config for that single call. No singleton mutation, safe for concurrent server requests: ```typescript // app/tasks/page.tsx (Next.js Server Component) import { listTasks } from '@/api/client'; import { getServerSession } from 'next-auth'; export default async function TasksPage() { const session = await getServerSession(); const page = await listTasks( { status: 'pending' }, { baseUrl: process.env.API_URL, // absolute URL required on server token: session.accessToken, credentials: 'omit', }, ); return ; } ``` ### Server client factory (`server_client: true`) [Section titled “Server client factory (server\_client: true)”](#server-client-factory-server_client-true) When `server_client: true` is set in config, the generator also writes `server.ts`. It exports `createServerClient()`, which pre-binds a per-request `ClientConfig` to every function so you only pass auth headers once per request: ```typescript // src/api/server.ts (auto-generated) export function createServerClient(config: Partial) { return { listTasks: (params?: Parameters[0]) => listTasks(params, config), createTask: (body: Parameters[0]) => createTask(body, config), getTask: (id: Parameters[0]) => getTask(id, config), // ... all operations } } ``` Usage in a Next.js App Router page: app/tasks/page.tsx ```typescript import { createServerClient } from '@/api/server'; export default async function TasksPage() { const session = await getServerSession(); const api = createServerClient({ baseUrl: process.env.API_URL, token: session.accessToken, }); const page = await api.listTasks({ status: 'pending' }); const task = await api.getTask('featured-id'); return ; } ``` ### Zod validation (`input_schema`) [Section titled “Zod validation (input\_schema)”](#zod-validation-input_schema) Point `input_schema` at a file path and the generator bootstraps a Zod schema file on first run. The file is never overwritten. Re-running the generator updates `models.ts` to use `z.infer<>` types and adds runtime validation to `client.ts`. **Step 1: add `input_schema` to config** ```json { "input_openapi": "./openapi.json", "output": "./src/api", "input_schema": "./src/api/schemas.ts" } ``` **Step 2: run the generator once** to bootstrap `schemas.ts`. Then edit it freely: ```typescript // src/api/schemas.ts (bootstrapped, then yours) import { z } from 'zod' export const TaskSchema = z .object({ id: z.string(), title: z.string(), status: z.enum(['pending', 'in_progress', 'done']), }) .passthrough() // forward-compatible: unknown server fields are preserved export const CreateTaskRequestSchema = z.object({ title: z.string().min(1, 'Title is required'), }) ``` **Step 3: re-run the generator.** `models.ts` switches to `z.infer<>` types, `client.ts` adds parse calls: ```typescript // models.ts (regenerated with Zod integration) import type { z } from 'zod' import type { TaskSchema, CreateTaskRequestSchema } from './schemas.js' export type Task = z.infer export type CreateTaskRequest = z.infer ``` ```typescript // client.ts (regenerated with Zod integration) export async function createTask( body: CreateTaskRequest, config?: Partial ): Promise { CreateTaskRequestSchema.parse(body) // validates request body before sending const res = await _request('POST', '/api/v1/tasks', { body }, config) return TaskSchema.parse(await res.json()) // throws ZodError on bad response } ``` Tip The `.passthrough()` call on object schemas keeps unknown server fields in the parsed output, making schemas forward-compatible when the server adds new optional fields. Note If a schema exists in `schemas.ts` but has no matching component in the spec (or vice versa), the generator prints a drift warning to stderr. Your build still succeeds; the warning is advisory. ## Edge cases and gotchas [Section titled “Edge cases and gotchas”](#edge-cases-and-gotchas) ### `input_schema` is bootstrapped once and never overwritten [Section titled “input\_schema is bootstrapped once and never overwritten”](#input_schema-is-bootstrapped-once-and-never-overwritten) The first time you run the generator with `input_schema` set, the file is created at the path you specified. Every subsequent run skips that write entirely. Delete the file and re-run to get a fresh bootstrap from the current spec. ### `--reset-schema` does not exist [Section titled “--reset-schema does not exist”](#--reset-schema-does-not-exist) The drift warning printed to stderr mentions “Run with —reset-schema to re-bootstrap.” That flag does not exist in the CLI. The only way to reset the schema file is to delete it manually and re-run the generator. ### Zod v4 is required [Section titled “Zod v4 is required”](#zod-v4-is-required) The bootstrapped `schemas.ts` uses `z.record(z.string(), ...)` (two-argument form) and `z.lazy()` for circular references. Both require Zod v4. Zod v3 does not support the two-argument `z.record` signature. ```bash pnpm add zod@^4 ``` ### Request body validation uses `.parse()`, not `.strip().parse()` [Section titled “Request body validation uses .parse(), not .strip().parse()”](#request-body-validation-uses-parse-not-stripparse) The generated client calls `Schema.parse(body)` before sending, not `.strip().parse()`. This is intentional: not all generated schemas are `ZodObject` (for example `allOf` maps to `ZodIntersection` and `oneOf` maps to `ZodUnion`, neither of which has `.strip()`). Use a wrapping schema to strip UI-only fields: schemas.ts ```typescript export const CreateTaskRequestSchema = z.object({ title: z.string().min(1), }) // For multi-step forms: extend with UI-only fields export const CreateTaskFormSchema = CreateTaskRequestSchema.extend({ currentStep: z.number(), termsAccepted: z.boolean(), }) // Use CreateTaskFormSchema for React Hook Form. // Strip UI fields before calling the API client: const apiBody = CreateTaskRequestSchema.parse(formValues) await createTask(apiBody) ``` ### Path safety restrictions [Section titled “Path safety restrictions”](#path-safety-restrictions) The generator rejects `output` and `input_openapi` paths that resolve to system directories (`/etc`, `/usr`, `/bin`, `/sys`, `/proc`, `/dev`, `C:\Windows`, and similar). This is a security check that prevents accidental misconfiguration. ## Version and runtime requirements [Section titled “Version and runtime requirements”](#version-and-runtime-requirements) | Requirement | Minimum | | -------------- | ---------------------------------------------------------------------------------------------- | | Node.js | 18 (native `fetch` is required at runtime; the generator itself uses `fs/promises` and `path`) | | TypeScript | 6.x (the version the monorepo runs); output targets `strict: true` | | Module format | ESM only. The generated files use `.js` extensions in all imports (NodeNext resolution) | | Prettier | Any version in your project. The generator reads your config to format output | | Zod (optional) | v4 required when `input_schema` is used | The package itself is published as ESM. It must be used from an ESM context (`"type": "module"` in `package.json` or a `.mjs` file). ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) ### ”Config file not found” [Section titled “”Config file not found””](#config-file-not-found) The generator looks for `openapi-zod-ts.config.json` in the current working directory. If you run the CLI from a subdirectory or use a different file name, pass `--config ` explicitly. The path argument must end in `.json`, `.js`, `.mjs`, or `.cjs`. ### Output files are not regenerating [Section titled “Output files are not regenerating”](#output-files-are-not-regenerating) The generator always overwrites `models.ts`, `client.ts`, `client-config.ts`, `index.ts`, and (if enabled) `server.ts` on every run. However, `schemas.ts` (the `input_schema` file) is never overwritten after the first bootstrap. If you want a fresh Zod schema file, delete it and re-run. ### Zod validation is not running at runtime [Section titled “Zod validation is not running at runtime”](#zod-validation-is-not-running-at-runtime) Ensure your config includes `input_schema` pointing at an existing `schemas.ts` file. On first run with `input_schema` set but no file present, the generator bootstraps the schema file only; run the generator a second time to get the Zod-enhanced `models.ts` and `client.ts`. ## Works with [Section titled “Works with”](#works-with) `openapi-zod-ts` is one of four generators that emit code from the same spec (`openapi-zod-ts`, `openapi-server`, `openapi-react-query`, `openapi-msw`), plus `api-errors`, a runtime helper used in app code. React Query hooks Add `@codewithagents/openapi-react-query` to generate typed `useQuery` and `useMutation` hooks on top of the client this package produces. [Learn more](/openapi-react-query) Server interface Add `@codewithagents/openapi-server` to generate a typed service interface and an optional router targeting `hono | express | fastify | none` (default `none`) from the same spec. [Learn more](/openapi-server) MSW mock handlers Add `@codewithagents/openapi-msw` to generate MSW v2 HTTP handlers with seeded Faker mock data from the same spec. [Learn more](/openapi-msw) Form error mapping Add `@codewithagents/api-errors` to map `ApiError` responses from the generated client directly to form field errors. [Learn more](/api-errors) # Roadmap > What is shipped, what is in progress, and what is planned for the CodeWithAgents OpenAPI toolchain. The mission of this toolchain is to be a zero-footprint bridge between OpenAPI and Zod for both sides of the wire. Every package is a `devDependency` or a peer-dep-only tool. Nothing published here adds bytes to a production bundle that are not already there. This page mirrors the [`ROADMAP.md`](https://github.com/codewithagents/openapi-zod-ts/blob/main/ROADMAP.md) in the repo. The five published packages are four generators (`openapi-zod-ts`, `@codewithagents/openapi-server`, `@codewithagents/openapi-react-query`, `@codewithagents/openapi-msw`) plus one runtime helper (`@codewithagents/api-errors`). | Package | Version | One-liner | | ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------ | | `openapi-zod-ts` | v2.1.0 | Core generator: TypeScript models, native fetch client, and Zod schemas from an OpenAPI 3.1 spec. CLI: `openapi-zod-ts`. | | `@codewithagents/openapi-server` | v2.0.0 | Framework-agnostic service interface plus an optional router. CLI: `openapi-server`. | | `@codewithagents/openapi-react-query` | v3.12.1 | Typed React Query v5 hooks over the generated client. | | `@codewithagents/openapi-msw` | v0.2.0 | MSW v2 handlers with seeded Faker mock data. CLI: `openapi-msw`. | | `@codewithagents/api-errors` | v1.2.0 | Maps backend API error responses to form-field errors. Runtime helper, not a codegen step. | ## Now: shipped [Section titled “Now: shipped”](#now-shipped) ### `@codewithagents/api-errors` (v1.2.0) [Section titled “@codewithagents/api-errors (v1.2.0)”](#codewithagentsapi-errors-v120) Maps backend API error responses to form field errors. Framework-agnostic core with adapters for React Hook Form, Formik, TanStack Form, and vanilla JavaScript. Supported parsers: RFC 7807 / RFC 9457 Problem Details, Spring Boot validation errors, violations / invalid-params shapes, JSON:API error objects, Laravel and DRF error formats, flat field/message objects, GraphQL error extensions, Zod flatten output, and Axios response wrappers. ### `openapi-zod-ts` (v2.1.0) [Section titled “openapi-zod-ts (v2.1.0)”](#openapi-zod-ts-v210) A CLI `devDependency` that reads an OpenAPI 3.1 spec and an optional user-owned Zod schema, then generates self-contained TypeScript. No runtime package required. The generated client uses only native `fetch`. Ships the `openapi-zod-ts` CLI. Core features shipped: * `models.ts`: TypeScript interfaces for every schema in `components.schemas` * `client.ts`: one typed `async function` per API operation * `client-config.ts`: `configureClient()` and `getConfig()` for base URL and auth * `index.ts`: barrel re-export of all three files * Optional Zod integration via `input_schema`: bootstraps `schemas.ts` on first run, then validates requests and responses on every call * Optional `server.ts` via `server_client: true`: `createServerClient()` factory for SSR * Multi-spec `projects` array in config: drive N separate generations from one config file ### `@codewithagents/openapi-react-query` (v3.12.1) [Section titled “@codewithagents/openapi-react-query (v3.12.1)”](#codewithagentsopenapi-react-query-v3121) A separate package layered on top of `openapi-zod-ts`. Reads the same `openapi.json` and the output of the generator, then produces React Query v5 hooks. * `queryOptions` factories for GET endpoints * `useMutation` hooks for write operations * `useInfiniteQuery` hooks for paginated endpoints * Imports from `openapi-zod-ts`’s `client.ts` output * Peer dependency: `@tanstack/react-query` ### `@codewithagents/openapi-server` (v2.0.0) [Section titled “@codewithagents/openapi-server (v2.0.0)”](#codewithagentsopenapi-server-v200) Server-side counterpart to `openapi-zod-ts`. Reads the same `openapi.json` and generates a framework-agnostic service interface plus an optional router selected by `framework`: `"hono" | "express" | "fastify" | "none"` (default `"none"`, which emits only the service interface and lets you wire it to any framework yourself). Fastify is the framework the project ships its canonical full-stack app on. Ships the `openapi-server` CLI. Peer dependencies: `hono`, `express`, or `fastify` depending on the chosen framework. ### `@codewithagents/openapi-msw` (v0.2.0) [Section titled “@codewithagents/openapi-msw (v0.2.0)”](#codewithagentsopenapi-msw-v020) Generates MSW v2 HTTP handlers with seeded Faker mock data from an OpenAPI 3.1 spec. Depends on `openapi-zod-ts`. Produces a single `handlers.ts` file ready to drop into a Storybook or Vitest browser setup. Ships the `openapi-msw` CLI. * One `http.(path, resolver)` handler per operation * Faker-seeded responses derived from response schemas * Peer dependencies: `msw` (`^2`) and `@faker-js/faker` (`^9`) ## The full-stack story [Section titled “The full-stack story”](#the-full-stack-story) One spec drives four generators. The shared `schemas.ts` is the single source of validation truth across the wire. ```plaintext openapi.json + schemas.ts ├── openapi-zod-ts → client validates responses with the user's Zod schemas ├── @codewithagents/openapi-server → server validates requests with the same Zod schemas ├── @codewithagents/openapi-react-query → React Query v5 hooks over the generated client └── @codewithagents/openapi-msw → MSW v2 handlers with seeded Faker mock data ``` `@codewithagents/api-errors` then maps server validation failures back onto form fields in app code. The canonical end-to-end reference wiring all of this together is [`packages/petstore-fastify`](https://github.com/codewithagents/openapi-zod-ts/tree/main/packages/petstore-fastify): Fastify plus `createContext` auth, a cross-field validation rule, and a React / react-query frontend with browser e2e that round-trips a cross-field error onto a form field. `@codewithagents/petstore-hono` is a retained legacy Hono variant. Note This roadmap reflects the current state of the project. Priorities shift as feedback comes in. Open an issue on [GitHub](https://github.com/codewithagents/openapi-zod-ts) if something here matters to your project. # Full-stack tutorial: OpenAPI spec to typed full-stack app in one command > End-to-end walkthrough of the petstore-fastify demo. One OpenAPI spec generates a typed fetch client, Zod v4 schemas, React Query v5 hooks, and a Fastify router. Run pnpm generate and get a full-stack typed app with cross-field validation. One `pnpm generate` command reads an OpenAPI spec and writes a typed fetch client, Zod v4 schemas, React Query v5 hooks, AND a Fastify router. Every file in `generated/` and `generated-auth/` comes from codegen. The only TypeScript you write by hand is the part the spec cannot express: business logic, auth, and the one cross-field validation rule. This tutorial walks through the [`petstore-fastify` demo](https://github.com/codewithagents/openapi-zod-ts/tree/main/packages/petstore-fastify), the one canonical full-stack reference app for the toolchain. One OpenAPI spec drives TypeScript types, a native fetch client, a Fastify service interface and router with Zod validation, and React Query v5 hooks. A React frontend logs in for a bearer token, then submits a secured form whose cross-field validation rule round-trips its error onto the exact field that failed. Nothing in `generated/` or `generated-auth/` is written by hand. The codegen surface here is four generators (`openapi-zod-ts`, `openapi-server`, `openapi-react-query`, `openapi-msw`) plus `@codewithagents/api-errors`, a runtime helper for mapping API errors to form fields. This app exercises the first three generators across two specs and shows where the other two fit. Note A retained legacy Hono variant, `petstore-hono`, keeps Hono router coverage alive but is no longer the canonical reference. Fastify is the framework this project ships its reference app on. ## What you will build [Section titled “What you will build”](#what-you-will-build) The Auth Lab slice of the app: a login screen that exchanges credentials for a bearer token, then a secured contact form. The contact form has a conditional cross-field rule. When the contact method is `email`, the email field is required; when it is `phone`, the phone field is required. The full round-trip looks like this: ```plaintext React form -> ContactRequestSchema.parse(body) (generated client, before the request) -> superRefine fails: issue { path: ["email"], message: "Email is required when method is email" } -> ZodError thrown client-side, caught in useContact onError -> React renders the message on the email field ``` The same shared schema also guards the server: an inject test proves a missing-field payload is rejected before any service code runs. One Zod `superRefine`, written once in `src/auth-schemas.ts`, protects both ends. | Layer | File | Generated? | | ------------------------------- | -------------------------------- | ----------------------------- | | TypeScript types | `generated-auth/schema-types.ts` | Yes, by `openapi-zod-ts` | | Fetch client | `generated-auth/client.ts` | Yes, by `openapi-zod-ts` | | Zod schemas + cross-field rule | `src/auth-schemas.ts` | Bootstrapped once, then yours | | Server interface | `generated-auth/service.ts` | Yes, by `openapi-server` | | Fastify router + Zod validation | `generated-auth/router.ts` | Yes, by `openapi-server` | | React Query hooks | `generated-auth/hooks.ts` | Yes, by `openapi-react-query` | | Auth wiring + business logic | `src/server/authApp.ts` | You write this | | React UI | `src/client/App.tsx` | You write this | **The key insight:** everything in `generated/` and `generated-auth/` is disposable. Change a spec, run `pnpm generate`, and the types, client, hooks, and router regenerate. Your business logic in `src/` is untouched because it implements a stable TypeScript interface. `src/auth-schemas.ts` is the one exception inside the generated flow: the generator bootstraps it once, then never overwrites it, so the cross-field rule you add survives every regeneration. ## Quick start [Section titled “Quick start”](#quick-start) Prerequisites: Node.js 22+, pnpm 10+. ```bash git clone https://github.com/codewithagents/openapi-zod-ts.git cd openapi-zod-ts pnpm install pnpm build cd packages/petstore-fastify pnpm dev ``` `pnpm dev` regenerates from the specs, then starts two processes via `concurrently`: * **Vite** on `http://localhost:5173`: the React frontend with hot reload * **Fastify** on `http://localhost:3004` (`fullstackServer.ts`): serves the API plus the built frontend; Vite proxies `/api` requests to it Open `http://localhost:5173`, log in with any username and password, and you will land on the secured contact form. There is also a backend-only server: `pnpm start` boots the pet API alone on port 3003 (`src/server/index.ts`). *** ## 1. The OpenAPI spec [Section titled “1. The OpenAPI spec”](#1-the-openapi-spec) The Auth Lab slice starts from `spec/auth-lab.json`, an OpenAPI 3.1 document with three operations: a public `login`, a secured `getMe`, and a secured `contact`. Bearer auth is declared at the top level, and `login` opts out with `security: []`: ```json { "openapi": "3.1.0", "info": { "title": "Auth Lab", "version": "1.0.0" }, "components": { "securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer" } }, "schemas": { "ContactRequest": { "type": "object", "required": ["method", "message"], "properties": { "method": { "type": "string", "enum": ["email", "phone"] }, "email": { "type": "string" }, "phone": { "type": "string" }, "message": { "type": "string" } } } } }, "security": [{ "bearerAuth": [] }], "paths": { "/login": { "post": { "operationId": "login", "security": [] } }, "/me": { "get": { "operationId": "getMe", "security": [{ "bearerAuth": ["profile:read"] }] } }, "/contact": { "post": { "operationId": "contact", "security": [{ "bearerAuth": ["profile:read"] }] } } } } ``` The spec describes shape and security, but not the cross-field rule. The spec cannot express “email is required only when method is email”; that is conditional logic, and it belongs in a Zod schema you own. The spec is the source of truth for every generated file; the cross-field rule is the source of truth for the one thing the spec cannot say. Note This app actually generates from two specs. The Pet API spec lives in the shared package at `../petstore-shared/spec/api.json` and produces plain CRUD into `generated/`. The Auth Lab spec, `spec/auth-lab.json`, produces the secured surface into `generated-auth/`. This tutorial follows the Auth Lab target because the cross-field round-trip is the headline feature. ## 2. Generate types, client, and Zod schemas [Section titled “2. Generate types, client, and Zod schemas”](#2-generate-types-client-and-zod-schemas) The first generator to run is `openapi-zod-ts`. It reads the spec and writes `schema-types.ts`, `client.ts`, `client-config.ts`, and `index.ts`. When `input_schema` points at a file, it bootstraps that file with a plain `z.object()` per schema on the first run, then never touches it again. **`openapi-zod-ts.auth.config.json`:** ```json { "input_openapi": "spec/auth-lab.json", "output": "generated-auth/", "input_schema": "src/auth-schemas.ts" } ``` The `input_schema` field is the key. Because `src/auth-schemas.ts` already exists, the generator leaves it alone and derives the response types in `schema-types.ts` from it. This is where the cross-field rule lives: ```typescript // src/auth-schemas.ts (bootstrapped by openapi-zod-ts, then yours) import { z } from 'zod' // ContactRequest carries a conditional cross-field rule: the contact field named by // `method` is required. export const ContactRequestSchema = z .object({ method: z.enum(['email', 'phone']), email: z.string().optional(), phone: z.string().optional(), message: z.string().min(1, 'Message is required'), }) .superRefine((data, ctx) => { if (data.method === 'email' && !data.email?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['email'], message: 'Email is required when method is email', }) } if (data.method === 'phone' && !data.phone?.trim()) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['phone'], message: 'Phone is required when method is phone', }) } }) ``` The `path: ['email']` and `path: ['phone']` on each issue are what makes the round-trip land on the right form field later. The generated `client.ts` calls `ContactRequestSchema.parse(body)` before sending `POST /contact`, so this rule runs client-side, before the network, on every submit: ```typescript // generated-auth/client.ts (auto-generated, excerpt) export async function contact( body: ContactRequest, config?: RequestConfig ): Promise { ContactRequestSchema.parse(body) // ...fetch POST /contact... return ContactResponseSchema.parse(await res.json()) } ``` Note `openapi-zod-ts` is a prerequisite for both `openapi-server` and `openapi-react-query`. Run it first so `schema-types.ts` exists before the other generators import from it. ## 3. Generate the server interface and Fastify router [Section titled “3. Generate the server interface and Fastify router”](#3-generate-the-server-interface-and-fastify-router) `openapi-server` reads the same spec and writes two files: a plain TypeScript interface (`service.ts`) and a ready-to-mount Fastify plugin (`router.ts`). **`openapi-server.auth.config.json`:** ```json { "input_openapi": "spec/auth-lab.json", "output": "generated-auth/", "framework": "fastify", "context_type": "AuthContext", "input_schema": "src/auth-schemas.ts" } ``` `framework` accepts `hono | express | fastify | none`, defaulting to `none` (a framework-agnostic interface with no router). Here it is `fastify`. `context_type: "AuthContext"` makes the generated interface generic over a context type you define, threaded into every secured method. `input_schema` points at the same `auth-schemas.ts`, so the router validates against the shared Zod schemas. **Generated `generated-auth/service.ts`:** ```typescript // generated-auth/service.ts (auto-generated) import type { ContactRequest, ContactResponse, LoginRequest, LoginResponse, User, } from './schema-types.js' export interface AuthLabService { /** * GET /me * @security bearerAuth profile:read */ getMe(ctx: Ctx): Promise /** POST /login */ login(body: LoginRequest, ctx: Ctx): Promise /** * POST /contact * @security bearerAuth profile:read */ contact(body: ContactRequest, ctx: Ctx): Promise } ``` Every secured method receives `ctx: Ctx` as its final argument. The interface is regenerated whenever the spec changes. Add an operation and forget to implement it, and TypeScript fails the build. **Generated `generated-auth/router.ts` (Fastify plugin):** The router is a `FastifyPluginAsyncZod`. It uses `fastify-type-provider-zod` so the request body and response are validated against the Zod schemas, registers an `HttpError` error handler, surfaces operation security metadata on the route config, and calls a `createContext` callback you supply before each handler: ```typescript // generated-auth/router.ts (auto-generated, excerpt) export function createRouter( service: AuthLabService, options: CreateRouterOptions ): FastifyPluginAsyncZod { return async (app) => { app.setValidatorCompiler(validatorCompiler) app.setSerializerCompiler(serializerCompiler) // Default HttpError handler maps HttpError(status, message) to a JSON error response. app.post( '/contact', { schema: { body: ContactRequestSchema, response: { 200: ContactResponseSchema } }, config: { operationId: 'contact', security: [{ scheme: 'bearerAuth', scopes: ['profile:read'] }], }, }, async (req, reply) => { const ctx = await options.createContext(req) return reply.send(await service.contact(req.body, ctx)) } ) // ...login and me routes... } } ``` Two things are wired automatically. The route `schema.body` is `ContactRequestSchema`, so Fastify validates the body (including the `superRefine` cross-field rule) before your handler runs and rejects a bad payload with a 400. And `config.security` carries the required scopes, which your `createContext` can inspect to decide whether a route is public or needs a token. Tip The `service.ts` interface has zero framework imports. Set `framework` to `none` for a framework-agnostic interface, or `hono` / `express` / `fastify` to also emit a router. See the [openapi-server guide](/openapi-server) for the full router surface. ## 4. Implement the service and the createContext auth seam [Section titled “4. Implement the service and the createContext auth seam”](#4-implement-the-service-and-the-createcontext-auth-seam) `src/server/authApp.ts` does three things: defines the `AuthContext` shape, implements `AuthLabService`, and registers the generated router with a `createContext` that enforces auth. ```typescript // src/server/authApp.ts (you write this) import fastifyStatic from '@fastify/static' import Fastify from 'fastify' import { createRouter, HttpError } from '../../generated-auth/router.js' import type { AuthLabService } from '../../generated-auth/service.js' // The principal threaded through all secured service methods. export interface AuthContext { userId: string scopes: string[] } export const authLabService: AuthLabService = { async getMe(ctx) { return { id: ctx.userId, name: `user-${ctx.userId}` } }, async login(body) { // Issue a token equal to the username so it can be echoed back in tests. return { token: body.username, user: { id: body.username, name: `user-${body.username}` } } }, async contact(body, ctx) { void ctx return { accepted: true, method: body.method } }, } export function buildAuthApp(options: { serveStatic?: string } = {}) { const app = Fastify() app.register( createRouter(authLabService, { createContext: (req) => { const security = req.routeOptions.config.security // Routes with no security metadata (login overrides with security: []) are public. if (security === undefined || security.length === 0) { return { userId: 'anonymous', scopes: [] } } // All other routes require a Bearer token. const auth = req.headers['authorization'] if (typeof auth !== 'string' || !auth.startsWith('Bearer ')) { throw new HttpError(401, 'Unauthorized') } return { userId: auth.slice('Bearer '.length), scopes: ['profile:read'] } }, }), { prefix: '/api' } ) if (options.serveStatic !== undefined) { app.register(fastifyStatic, { root: options.serveStatic }) } return app } ``` The `createContext` callback is the auth seam. It reads the operation’s security metadata that the generated router placed on `req.routeOptions.config.security`, treats a missing or empty list as public, and otherwise requires a Bearer token. Throwing `HttpError(401)` here rejects the request before any service method runs. The returned `AuthContext` is threaded into `getMe` and `contact` as their `ctx` argument. The router is mounted with `app.register(createRouter(...), { prefix: '/api' })`, so the generated routes live under `/api`. `fullstackServer.ts` calls `buildAuthApp({ serveStatic: dist })` to serve the built React frontend alongside the API on port 3004. Caution The reference `createContext` is deliberately minimal: it checks for a Bearer token but does not verify it. The point of the seam is to show where real auth plugs in, not to ship a token validator. Replace the body of `createContext` with your own verification. ## 5. Generate React Query hooks [Section titled “5. Generate React Query hooks”](#5-generate-react-query-hooks) `openapi-react-query` reads the auth-lab spec and writes `hooks.ts` plus a `test-utils.ts` helper. It derives all types from the generated client functions, so there are no duplicate type declarations. **`openapi-react-query.auth.config.json`:** ```json { "input_openapi": "spec/auth-lab.json", "output": "generated-auth/", "stale_time": 0, "gc_time": 300000 } ``` After running, `generated-auth/hooks.ts` contains: * `useGetMe(options?)`: a `useQuery` hook for the authenticated user * `useLogin(options?)`: a `useMutation` hook that returns a token * `useContact(options?)`: a `useMutation` hook for the secured contact form * `meKeys`: a structured key factory (`meKeys.all()`, `meKeys.list()`) for cache invalidation Each hook is typed against the generated client, so `useContact` already knows its variables are a `ContactRequest` and its error is an `ApiError`. ## 6. Use the hooks in React: login, then the round-trip [Section titled “6. Use the hooks in React: login, then the round-trip”](#6-use-the-hooks-in-react-login-then-the-round-trip) `src/client/App.tsx` logs in with `useLogin`, stores the token in a small module (`token.ts`) that the client reads on every request, then renders the secured contact form with `useContact`. The frontend reads `issue.path[0]` from a thrown `ZodError` and places the message on the matching field: ```tsx // src/client/App.tsx (you write this, excerpt) import { useState, type FormEvent } from 'react' import { useContact, useLogin } from '../../generated-auth/hooks.js' import { setToken } from './token.js' type FieldErrors = { email?: string; phone?: string } type ZodIssue = { path: (string | number)[]; message: string } // Place a message on the email or phone slot; ignore any other field. function assignField(errors: FieldErrors, field: unknown, message: string): FieldErrors { if (field === 'email') return { ...errors, email: message } if (field === 'phone') return { ...errors, phone: message } return errors } function isZodLike(error: unknown): error is { issues: ZodIssue[] } { return ( error != null && typeof error === 'object' && 'issues' in error && Array.isArray((error as { issues: unknown }).issues) ) } // The generated client runs ContactRequestSchema.parse(body) before the request, so a // cross-field failure throws a ZodError with path-tagged issues client-side. function parseContactErrors(error: unknown): FieldErrors { if (!isZodLike(error)) return {} return error.issues.reduce((acc, issue) => assignField(acc, issue.path[0], issue.message), {}) } function ContactView() { const [method, setMethod] = useState<'email' | 'phone'>('email') const [email, setEmail] = useState('') const [phone, setPhone] = useState('') const [message, setMessage] = useState('') const [fieldErrors, setFieldErrors] = useState({}) const [success, setSuccess] = useState(false) const contact = useContact({ onSuccess: () => { setSuccess(true) setFieldErrors({}) }, onError: (error) => setFieldErrors(parseContactErrors(error)), }) const handleSubmit = (e: FormEvent) => { e.preventDefault() setFieldErrors({}) setSuccess(false) contact.mutate({ method, email: email.trim() || undefined, phone: phone.trim() || undefined, message: message.trim(), }) } // ...form with email/phone/message fields, each rendering fieldErrors[field]... } ``` The login token is held in `token.ts` and read by the client on every request, so secured calls carry `Authorization: Bearer ` without the components touching headers: ```tsx // src/client/main.tsx (you write this, excerpt) import { configureClient } from '../../generated-auth/client-config.js' import { getToken } from './token.js' // Every request carries the current bearer token (empty until login). configureClient({ baseUrl: '/api', token: getToken }) ``` When the user submits `method: 'email'` with an empty email, `useContact`’s `mutate` calls the generated `contact` client function, which runs `ContactRequestSchema.parse(body)`. The `superRefine` adds an issue at `path: ['email']`, `parse` throws a `ZodError`, `useContact`’s `onError` receives it, `parseContactErrors` reads `issue.path[0]`, and the message renders on the email field. The cross-field error has round-tripped onto the exact field that failed, before a single byte hit the network. Tip This demo maps the error inline because the shape is its own. When you want a library that normalizes many backend error formats and wires them to React Hook Form in one call, reach for `@codewithagents/api-errors` and its `mapApiErrors` adapter. See the [api-errors guide](/api-errors). ## 7. Regenerating from spec [Section titled “7. Regenerating from spec”](#7-regenerating-from-spec) `package.json` runs five generator CLI invocations across the two specs and three generators, in dependency order: ```json { "scripts": { "generate": "openapi-zod-ts && openapi-zod-ts --config openapi-zod-ts.auth.config.json && openapi-server && openapi-server --config openapi-server.auth.config.json && openapi-react-query --config openapi-react-query.auth.config.json" } } ``` Read top to bottom: `openapi-zod-ts` on the pet spec, then on the auth-lab spec; `openapi-server` on each; then `openapi-react-query` on the auth-lab spec. Run it whenever you change a spec or `src/auth-schemas.ts`: ```bash pnpm generate ``` `src/auth-schemas.ts` is never overwritten, so the cross-field rule survives. Everything else in `generated/` and `generated-auth/` is safe to delete and regenerate. Note A fourth generator, `@codewithagents/openapi-msw`, reads the same spec and emits MSW v2 HTTP handlers with seeded Faker mock data (CLI: `openapi-msw`). This reference app does not run it; MSW handler generation is exercised in the `integration` package. Add a step like `openapi-msw --config openapi-msw.config.json` to a generate script when you want browser or test mocks driven from the same contract. See the [openapi-msw guide](/openapi-msw). ## 8. Run the tests [Section titled “8. Run the tests”](#8-run-the-tests) Two layers cover the round-trip. Vitest inject tests hit the Fastify app in-process, including the server-side guard; a Playwright suite drives a real browser through login and the cross-field round-trip. The inject test proves the shared schema rejects a missing field server-side, before any service code runs: ```typescript // src/__tests__/auth-routes.test.ts (excerpt) it('rejects a missing email when method is email (400)', async () => { const app = buildAuthApp() const res = await app.inject({ method: 'POST', url: '/api/contact', headers: { 'content-type': 'application/json', authorization: 'Bearer x' }, payload: JSON.stringify({ method: 'email', message: 'hi' }), }) expect(res.statusCode).toBe(400) await app.close() }) ``` The browser e2e logs in, submits `method: 'email'` with no email, asserts the error lands on the email field, then provides a valid email and asserts success: ```typescript // e2e-auth/auth.spec.ts (excerpt) test('login, cross-field validation error round-trip, then success', async ({ page }) => { await page.goto('/') await page.getByTestId('login-username').fill('alice') await page.getByTestId('login-password').fill('pw') await page.getByTestId('login-submit').click() // method=email but no email: the cross-field rule fails and the error lands on the email field. await page.getByTestId('contact-method').selectOption('email') await page.getByTestId('contact-message').fill('hello there') await page.getByTestId('contact-submit').click() const emailError = page.getByTestId('contact-email-error') await expect(emailError).toBeVisible() await expect(emailError).toContainText('email') // Provide a valid email and resubmit: accepted. await page.getByTestId('contact-email').fill('alice@example.com') await page.getByTestId('contact-submit').click() await expect(page.getByTestId('contact-success')).toBeVisible() }) ``` Run them: ```bash pnpm test # Vitest inject tests (in-process Fastify, including the 400 guard) pnpm test:e2e:auth # build, then Playwright on port 3004: login, round-trip, success ``` `pnpm test:e2e:auth` runs `pnpm build` first so `./dist` exists, then boots `fullstackServer.ts` on port 3004 and drives Chromium against it. These Playwright suites run in CI as jobs within the standard pipeline, not as a separate top-level workflow. There is no dedicated “E2E” workflow file. *** ## File structure [Section titled “File structure”](#file-structure) ```plaintext spec/ auth-lab.json Auth Lab spec: login, secured /me and /contact ../petstore-shared/ Shared Pet API contract (spec + hand-written Zod), reused by every petstore app spec/api.json schemas.ts src/ auth-schemas.ts User-owned Zod: the cross-field superRefine lives here, never overwritten server/ authApp.ts buildAuthApp: createContext auth seam + AuthLabService impl fullstackServer.ts Serves the built frontend plus the auth API on port 3004 index.ts Pet API server only, on port 3003 petService.ts Implements the Pet API service client/ App.tsx React UI: login then the secured contact form main.tsx configureClient + QueryClientProvider token.ts Module-level bearer token holder generated/ Pet API output (gitignored, regenerated) generated-auth/ Auth Lab output (gitignored, regenerated) schema-types.ts TypeScript types client.ts Typed fetch functions, ApiError class client-config.ts configureClient(): base URL and token setup service.ts AuthLabService interface router.ts createRouter(service, { createContext }): Fastify plugin hooks.ts useGetMe, useLogin, useContact, meKeys e2e-auth/ auth.spec.ts Playwright: login, cross-field round-trip, success openapi-zod-ts.auth.config.json Generator config (auth client + reads auth-schemas) openapi-server.auth.config.json Generator config (Fastify router, context_type: AuthContext) openapi-react-query.auth.config.json Generator config (React Query hooks) ``` *** ## Where to go next [Section titled “Where to go next”](#where-to-go-next) Types and fetch client Full reference for `openapi-zod-ts`: all config options, Zod integration, SSR server client, and more. [openapi-zod-ts guide](/openapi-zod-ts) Server interface Full reference for `@codewithagents/openapi-server`: the `hono | express | fastify | none` targets, createContext, and Zod validation. [openapi-server guide](/openapi-server) React Query hooks Full reference for `@codewithagents/openapi-react-query`: suspense variants, auto-invalidate, per-resource cache tuning, and test utilities. [openapi-react-query guide](/openapi-react-query) MSW mock handlers `@codewithagents/openapi-msw` generates MSW v2 handlers with seeded Faker data from the same spec. [openapi-msw guide](/openapi-msw) Form error mapping `@codewithagents/api-errors` normalizes common server error formats and wires them to React Hook Form in one call. [api-errors guide](/api-errors)