@polygonlabs/zod-to-openapi-heyapi
v1.1.1
Published
hey-api/openapi-ts plugin that sources Zod schemas (with codecs) from a zod-to-openapi OpenAPIRegistry instead of regenerating them from the spec — so generated clients use your actual Zod runtime types.
Keywords
Readme
@polygonlabs/zod-to-openapi-heyapi
A @hey-api/openapi-ts plugin for end-to-end Zod-schema-first API development.
Generated clients import the actual Zod schemas the backend uses to
validate the wire — the same schemas a @asteasolutions/zod-to-openapi
OpenAPIRegistry composed the OpenAPI spec from — instead of re-deriving
them at codegen time.
Why this package exists
The team writes APIs Zod-schema-first using @asteasolutions/zod-to-openapi's
OpenAPIRegistry: Zod schemas describe the wire shape, the registry composes
them into an OpenAPI spec, and the spec drives client codegen. The promise is
end-to-end — backend services validate requests and responses against the
same Zod schemas the spec was generated from, and clients should validate
against those same schemas in turn.
The standard tooling breaks that promise. @hey-api/zod produces Zod schemas
by walking the OpenAPI spec — round-tripping Zod → JSON Schema → Zod. That
trip is lossy: codecs, refinements, branded types, non-trivial constraints,
and custom error messages don't survive it. The regenerated schemas are
strictly wider than the originals, so the client validates a superset of what
the backend actually accepts. Schema-first becomes schema-twice, and the two
copies drift the moment a constraint changes.
This plugin fixes that by sourcing the actual Zod schemas — the ones that
generated the spec — for the generated client to import. The client and the
service validate identical shapes because they share the schema, not a
reconstruction of it. The schemasFrom option is
the knob that wires this together — point it at the same module your backend
imports the registered schemas from.
A natural consequence: z.codec(...) schemas work correctly. When wire
format and runtime value differ (Int64Codec: wire string, runtime
bigint), the plugin emits z.output<typeof Schema> response types and a
runtime parseAsync transformer; the fetch client runs the transformer on
every response, so codec decode ("1500" → 1500n, ISO string → Date, …)
happens before the value reaches the caller and the type and runtime agree.
@polygonlabs/zod-codecs ships the off-the-shelf codecs the team
reaches for (Int64Codec, BigIntegerCodec, DecimalStringCodec,
IsoDateCodec); this plugin works with any z.codec(...) schema, whether
imported or defined inline.
Usage
import { OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import { $, defineConfig } from '@hey-api/openapi-ts';
import { registryPlugin } from '@polygonlabs/zod-to-openapi-heyapi';
import { myRegistry } from './schemas/registry.ts';
export default defineConfig({
input: './openapi.json',
output: { path: './src/generated', clean: true },
plugins: [
// Must be listed BEFORE @hey-api/typescript so the plugin's response-type
// symbols are registered first; the SDK plugin queries by metadata key
// and takes the first registered match.
(await registryPlugin({
registry: myRegistry,
schemasFrom: '@my-org/api-schemas',
generatorClass: OpenApiGeneratorV3,
$
})) as never,
'@hey-api/typescript',
'@hey-api/client-fetch',
// `includeInEntry: false` is required: this plugin owns the public
// SDK surface and emits a wrapper per operation under the canonical
// name. Without it, `@hey-api/sdk`'s same-named raw functions would
// collide with the wrappers in the auto-generated `index.ts`. The
// plugin's pre-flight check throws with the exact config to write
// if you forget.
{ name: '@hey-api/sdk', transformer: true, includeInEntry: false }
]
});Why as never
registryPlugin is async so it can dynamic-import schemasFrom for the
codegen-time audit. defineConfig's plugin slot is typed as
PluginNames | PluginConfig, and that union doesn't accept the
Promise<…>-derived shape exposed here. The cast is safe — at the runtime
hey-api evaluates plugins, it has already been awaited. We could chase the
upstream type to remove the cast, but the surface is unstable enough that
pinning it would break on every minor hey-api bump. The cast is the
deliberately boring pin point.
Add src/generated/ to .gitattributes
Once src/generated/ is committed (recommended — see
Codegen drift below), keep GitHub from counting the
snapshot in language stats and from rendering the diff line-by-line in PRs:
# .gitattributes
src/generated/** linguist-generated=trueOutput extension and import paths
@hey-api/openapi-ts emits .js-extension imports inside the generated
directory regardless of source language — those resolve correctly under
TypeScript's module: "nodenext" / bundler resolution and the Apps Team
template's rewriteRelativeImportExtensions: true. No module.extension
config required.
After pnpm exec openapi-ts runs, you'll find a generated
registry-validator.gen.ts alongside the rest of the client. Each operation's
response type is z.output<typeof <Schema>>, and each operation has a
matching <opId>Transformer function the SDK wires as the
responseTransformer.
The schemasFrom option
schemasFrom is the option that delivers schema-first end-to-end. It is the
module specifier baked into the generated transformer file's import
statement for every Zod schema the plugin references:
// Generated registry-validator.gen.ts
import { Foo, Bar } from '@my-org/api-schemas'; // ← this `from` value is `schemasFrom`The same string has to resolve at two points: at codegen time, when the plugin's audit dynamic-imports it from the developer's machine, and at runtime / bundle time, when the generated client imports schemas. So it must be a specifier with a single unambiguous meaning regardless of who's importing it.
When schemas live in a separate package — use the package name
This is the canonical Apps Team setup: a dedicated schemas package
(e.g. @my-org/api-schemas) imported by both the service and the client
package. Just pass the package name:
schemasFrom: '@my-org/api-schemas'
// or, if schemas are exposed under a subpath export:
schemasFrom: '@my-org/api-schemas/zod'Inside a monorepo, this works for both published consumers (npm resolves the
name) and workspace consumers (pnpm/yarn/npm linking handle it). For codegen,
make sure the schemas package's runtime entrypoint resolves before invoking
openapi-ts — typically by running pnpm -r --if-present run build first
(workspace topological order builds schemas before the client) or by using a
custom export condition like @polygonlabs/source to read source .ts
directly.
When schemas live inside the client codegen package — use a #imports alias
Sometimes a project doesn't justify a separate schemas package: the schemas,
the registry, and the client codegen all live together. Relative paths
won't work — they mean different things to the plugin's audit (resolved
from the plugin's install location) and the generated client (resolved from
the output dir). Use a package.json#imports alias:
// package.json
{
"imports": {
"#schemas": "./src/schemas/index.ts"
}
}// openapi-ts.config.ts
schemasFrom: '#schemas'The alias resolves identically from any module within the package, so both
the audit (running from your openapi-ts.config.ts) and the generated
transformer (sitting under ./src/generated/) reach the same file.
#importsaliases only work inside the package that declares them. They do not cross package boundaries. If your schemas live in a sibling package or another workspace package, use the package name as shown above; a#schemasalias in the consumer's package.json will not be visible to the plugin or to a generated client that resolves the import from outside.
What's not supported
- Relative paths (
'../schemas') — different meaning to the plugin and the generated client. - Default or namespace imports — the plugin always emits
import { Name }. - Renames — the export's binding name must equal the registry name; see Naming convention below.
Resolution table
| Setup | schemasFrom |
|---|---|
| Schemas published to npm (root export) | '@my-org/api-schemas' |
| Schemas published as a subpath export | '@my-org/api-schemas/zod' |
| Schemas in the same monorepo (workspace package) | '@my-org/api-schemas' |
| Schemas in the same package as the codegen | '#schemas' (via package.json#imports) |
Schemas must be named exports, and the export name must match the registry name
The plugin discovers response schemas by walking the OpenAPIRegistry and
emits import { <registeredName> } from '<schemasFrom>'; using the name you
passed to register(). That name has to be the same string as the schema's
exported binding — there is no rename layer:
// schemas/index.ts
export const Trade = z.object({ /* ... */ });
export const Trades = z.array(Trade);
// schemas/registry.ts
registry.register('Trade', Trade); // ✓ registry name === export name
registry.register('Trades', Trades); // ✓registry.register('Trade', tradeSchema) combined with
export const tradeSchema = ... will fail the
codegen-time audit — the plugin would emit
import { Trade } from '...' and that wouldn't resolve. Default or namespace
exports don't work either; the generated code is always
import { name }.
The schemas package is a real runtime dependency
This trips people up: the schemas package isn't a build-time artifact, it's
runtime code. The generated transformer calls Schema.parseAsync(data) on
every response — that call runs in the consumer's process, against the
imported Schema value, every time a response comes back.
What that means for the consumer:
- Bundled clients (Vite, webpack, esbuild, etc.) — the bundler resolves
schemasFromat bundle time and inlines the schemas into the output. The deployed bundle is self-contained, sonode_modulesdoesn't need to ride along to production. But the schemas are still executing at runtime; they're just embedded in the bundle rather than resolved from disk. - Unbundled Node consumers (server-side code, CLI tools, etc.) — the
schemas package must be installed in
node_modulesfor every cold boot. Declare it underdependencies, notdevDependencies.
If you're bringing the schemas in from another package — typical setup, since that's the whole point of this plugin — make sure that package is in your client's runtime dependency list, not just a build/dev dep.
The mechanism is deliberately boring: same module specifier, same Zod schemas, same validation on both sides of the wire.
Codegen-time audit
The schemasFrom ↔ registered-name agreement is enforced at codegen. The
plugin dynamic-imports schemasFrom and, for every schema referenced as a
$ref from a route response, verifies:
- The module has a named export with that exact name.
- The export is a Zod schema (duck-typed: has
_defor aparseAsyncmethod).
Mismatches fail the codegen step with an aggregated error listing every issue at once, so a typo or forgotten export surfaces immediately on the developer's machine instead of as a confusing import error in a downstream consumer's build.
[zod-to-openapi-heyapi] codegen-time audit found 2 issues:
- 'Customer' is referenced as a response schema but is not a named export
of '@my-org/my-schemas'. The plugin emits `import { Customer } from
'@my-org/my-schemas'`, which will fail at consumer build time. Either
export Customer from that module under that exact name, or unregister it.
- 'Order' is exported from '@my-org/my-schemas' but does not appear to be
a Zod schema (got string). The plugin emits `Order.parseAsync(data)`,
which will fail at consumer runtime.What the audit ignores
The audit walks only schemas that appear as a $ref in a route response.
Schemas registered for other purposes never reach the generated client and
don't need a matching Zod export:
- Path / query / header parameters registered via
registry.registerParameter(...). zod-to-openapi v8'sOpenApiGeneratorV3lifts parameter schemas intocomponents.schemasas well ascomponents.parameters— the audit ignores them because the plugin doesn't emit any import for them. - Request body schemas (the SDK plugin generates request types from the spec; no Zod transformer is involved on the request side).
- Internal building blocks composed into other schemas (e.g. an
AddressSchemafactored out of a larger object) — they only need the outer registered schema's export.
Resolution failures (ERR_MODULE_NOT_FOUND)
If the dynamic import itself fails the plugin throws a different error mentioning the most common causes:
- The schemas package isn't installed in the consumer's
node_modules. - The package is installed but resolves to a
dist/that hasn't been built yet — common in monorepos using a custom export condition for build-free dev (e.g.@polygonlabs/source). Either runnode --conditions=<your-condition> ./node_modules/.bin/openapi-tsor build the schemas package before regenerating the client. - A relative path was passed for
schemasFrom. Use a package specifier or a#importsalias instead.
Caveat: .openapi() cannot be chained onto imported codecs
Zod v4 wires methods onto subclass prototypes at construction time rather
than relying on the prototype chain. A codec defined inside another
package (e.g. @polygonlabs/zod-codecs's Int64Codec) was constructed
before extendZodWithOpenApi(z) ran in your schemas file — the
prototype patch never reaches it, and chaining .openapi(...) directly on
the imported codec throws at module load:
import { Int64Codec } from '@polygonlabs/zod-codecs';
// ✗ TypeError: Int64Codec.openapi is not a function
const BlockNumber = Int64Codec.openapi({ description: 'Block number' });Use the codec as-is and put per-field metadata on the surrounding object's
.openapi() call:
extendZodWithOpenApi(z);
// ✓ Works — z.object() is constructed after the patch and has .openapi()
export const BlockNumberResponse = z
.object({
blockNumber: Int64Codec // codec used inline, no chained .openapi(...)
})
.openapi('BlockNumberResponse', {
description: 'Latest block number from the configured RPC endpoint'
});Codecs you define inline in the same file as extendZodWithOpenApi(z) can
chain .openapi(...) because the construction happens after the patch.
Only imported codecs hit this constraint.
Generated client uses fetch globals
@hey-api/openapi-ts's vendored fetch client references BodyInit,
HeadersInit, RequestInit, and ResponseInit — DOM globals. If your
consumer's tsconfig doesn't include the DOM lib (typical for Node
services that didn't previously consume browser APIs), typecheck fails
with Cannot find name 'BodyInit'.
Two ways to fix it. Pick one:
Option A — add DOM lib to the consumer's tsconfig. Simplest; one line:
// tsconfig.json
{
"compilerOptions": {
"lib": ["es2024", "DOM", "DOM.Iterable"]
}
}The DOM types are type-only — no runtime impact — but they pollute global
scope with browser identifiers (Window, Document, localStorage, …).
For most services that's harmless; if you'd rather keep DOM out, use
Option B.
Option B — declare the fetch types globally from undici-types. Drop
this file anywhere your tsconfig's include covers (e.g.
src/types/fetch-globals.d.ts):
import type * as undici from 'undici-types';
declare global {
type BodyInit = undici.BodyInit;
type HeadersInit = undici.HeadersInit;
type RequestInit = undici.RequestInit;
type ResponseInit = undici.ResponseInit;
}
export {};undici-types ships transitively with @types/node ≥ 18, so no extra
install needed. The declarations are type-only; no runtime cost.
This is upstream's choice — the plugin itself doesn't emit any DOM references — so the workaround sits in the consumer.
Don't install @hey-api/client-fetch separately
The deprecation message on
npm/@hey-api/client-fetch
("Starting with v0.73.0, this package is bundled directly inside
@hey-api/openapi-ts") is informational, not a request to install another
package. Starting from @hey-api/[email protected], the fetch client's source
is vendored into the generated output directory (src/generated/client/,
src/generated/core/) — there is no separate runtime dependency to install.
If your package.json lists @hey-api/client-fetch under dependencies or
devDependencies, drop it. The deprecation warning during pnpm install
goes away, and the generated client keeps working — its imports are
relative paths into the vendored directory.
Plugin options
| Option | Type | Description |
|---|---|---|
| registry | OpenAPIRegistry | The same registry used to generate the OpenAPI spec. The plugin runs the same generator internally to discover schema names — no hardcoded list. |
| schemasFrom | string | Module specifier the generated client imports your schemas from. Must be unambiguous from any caller (package name, #imports alias, or file:// URL — not a relative path). See The schemasFrom option. |
| generatorClass | OpenApiGeneratorV3 | Pass OpenApiGeneratorV3 from @asteasolutions/zod-to-openapi explicitly. Avoids resolution ambiguity in the codegen environment. |
| $ | typeof $ | Pass $ from @hey-api/openapi-ts explicitly. Same reason. |
SDK wrappers
The plugin emits one canonical SDK function per operation in
registry-validator.gen.ts. With includeInEntry: false on
@hey-api/sdk (required, see Usage), these wrappers are the only public
SDK surface: the auto-generated index.ts re-exports them under the
operation's plain name (getBlockMetadata, createMessage, …). The raw
SDK functions in sdk.gen.ts are an implementation detail — the wrapper
delegates to them for HTTP wiring.
For ops without a registered input schema, the wrapper is a thin re-binding of the upstream SDK function — same call signature, no runtime overhead, just present so every op has a canonical entry in the auto-barrel:
// Generated registry-validator.gen.ts
export const getBlockNumber = getBlockNumber2; // re-bind from sdk.gen.tsFor ops whose request.{params, query, body} schema is exported from
schemasFrom (any named export — no .openapi('Name') chain
required), the wrapper additionally encodes the runtime → wire
direction before delegating:
// Generated registry-validator.gen.ts
export type GetBlockMetadataInput = Omit<GetBlockMetadataData, 'path'> & {
// Runtime shape (`bigint`), not the wire string `BlockMetadataData.path` declares.
path: z.output<typeof BlockNumberPathParams>;
};
export const getBlockMetadataInputTransformer = async (
input: Pick<GetBlockMetadataInput, 'path'>
) => ({ path: await z.encode(BlockNumberPathParams, input.path) });
export const getBlockMetadata = async <ThrowOnError extends boolean = false>(
options: Options<GetBlockMetadataInput, ThrowOnError>
) => {
const transformed = await getBlockMetadataInputTransformer(options);
return await getBlockMetadata2({ ...options, ...transformed });
};z.encode(schema, value) runs the runtime → wire direction of any Zod
schema, including codecs:
Int64Codec.encode = (b) => b.toString(),
IsoDateCodec.encode = (d) => d.toISOString(). The case this matters
for is IsoDateCodec on a path or query parameter: String(date)
emits the locale string and the server's z.iso.datetime() validator
rejects it, but z.encode produces the ISO 8601 string the parser
accepts. Number-flavoured codecs (Int64Codec, BigIntegerCodec,
DecimalStringCodec) get the same ergonomic typing on the request side
as on the response side.
How input schema names are resolved
The plugin dynamic-imports schemasFrom at codegen time (already does
this for the response audit) and builds a Map<ZodType-instance,
exportName> from the named exports. For each route's input slot, it
looks up the slot's ZodType in the map. Found → use the export name as
the import binding. Not found → silently skip input-encoding for that
slot.
The user-side rule is just: export the schema, and use that same exported instance in the route.
// schemas.ts — plain export, no .openapi('Name') chain required
export const BlockNumberPathParams = z.object({ blockNumber: Int64Codec });
// routes/blocks.ts — same instance in request.params
import { BlockNumberPathParams } from '../schemas.ts';
registry.registerPath({
operationId: 'getBlockMetadata',
method: 'get',
path: '/api/blocks/{blockNumber}',
request: { params: BlockNumberPathParams },
// ...
});.openapi('Name') chains are only needed where the OpenAPI generator
needs them (response schemas that should $ref rather than inline,
body schemas you want named in the spec). For path / query slots,
OpenApiGeneratorV3 inlines per-parameter schemas regardless, so a
chain is purely cosmetic.
Identity matters because .openapi('Name') and register('Name',
schema) both clone the schema (asteasolutions creates a new
instance via new this.constructor(this._def) so chained metadata
calls don't accumulate). If you chain .openapi('Foo') on the export
and call register('Foo', x) somewhere else, the post-register
clone is a different instance from your export — identity lookup
won't find it and input encoding silently skips. Pick one source for
the schema instance (the export) and use it everywhere.
Per-slot optionality
The plugin mirrors hey-api's ${Op}Data slot optionality in the
emitted ${Op}Input and the wrapper's options parameter. A route
whose query schema has only optional fields (no required query
params) emits:
export type ListMessagesInput = Omit<ListMessagesData, 'query'> & {
query?: z.output<typeof RecentMessagesQuery>;
};
export const listMessages = async <ThrowOnError extends boolean = false>(
options?: Options<ListMessagesInput, ThrowOnError>
) => { ... };— so callers can write listMessages() with no args. Routes with a
required path slot emit path: ... and (options: ...) (no ?),
demanding the slot at the call site. The detection mirrors hey-api's
own hasParameterGroupObjectRequired for params / query / headers and
reads body.required for the body slot — set
body: { required: true, ... } on the route config if the body should
be required (asteasolutions defaults to optional otherwise).
What's not covered
- Headers: schema-typed header maps are out of scope. Headers are
rarely codec-typed in practice; if they become a need, the plugin
recognises a registered headers ZodObject the same way it recognises
params / query, but the emit needs verification against
@hey-api/client-fetch's header-serialisation surface.
What gets emitted
For every operation whose response is a $ref to a registered schema, the
plugin emits a block like:
import { z } from 'zod';
import { Foo } from '@my-org/my-schemas';
export type GetFooResponses = {
200: z.output<typeof Foo>;
};
export type GetFooResponse = GetFooResponses[keyof GetFooResponses];
export const getFooTransformer = async (data: unknown): Promise<z.output<typeof Foo>> =>
await Foo.parseAsync(data);Responses is keyed by status code so it composes cleanly with operations
that declare more than one response (see
Multi-status responses). Response is
Responses[keyof Responses] — the union of bodies across every declared
status.
The SDK plugin (@hey-api/sdk with transformer: true) wires
responseTransformer: getFooTransformer onto the route's call options, so
the fetch client runs the transformer on the raw JSON response and the
codec decode reaches the caller.
Multi-status responses
The plugin handles operations that declare more than one response status. For each operation it walks the full responses map and splits 2xx (success) from non-2xx (errors), emitting two pairs of types:
// 200 + 201 success, 400 + 404 + 500 errors
export type CreateOrFetchResourceResponses = {
200: z.output<typeof ResourceFetched>;
201: z.output<typeof ResourceCreated>;
};
export type CreateOrFetchResourceResponse =
CreateOrFetchResourceResponses[keyof CreateOrFetchResourceResponses];
export type CreateOrFetchResourceErrors = {
400: z.output<typeof BadRequestError>;
404: z.output<typeof NotFoundError>;
500: z.output<typeof ServerError>;
};
export type CreateOrFetchResourceError =
CreateOrFetchResourceErrors[keyof CreateOrFetchResourceErrors];@hey-api/sdk reads the Errors symbol when typing the second generic of
client.method<Responses, Errors, ThrowOnError>(...), so a caller that passes
throwOnError: false gets a fully-typed error field on the result.
The runtime transformer
The fetch client invokes the response transformer for any 2xx status,
without passing the status code. When an operation has multiple distinct 2xx
schemas the plugin emits a z.union(...) transformer so the parser accepts
any of them:
// One 2xx schema → simple form
export const getFooTransformer = async (data: unknown): Promise<z.output<typeof Foo>> =>
await Foo.parseAsync(data);
// Multiple distinct 2xx schemas → union form
export const createOrFetchResourceTransformer = async (
data: unknown
): Promise<z.output<typeof ResourceFetched> | z.output<typeof ResourceCreated>> =>
await z.union([ResourceFetched, ResourceCreated]).parseAsync(data);Operations whose only responses are errors get the Errors/Error types but
no transformer (there's no success body to decode).
Codegen drift
The recommended pattern is to commit the generated src/generated/
directory and gate it with a codegen-drift-check script in the package's
scripts:
// package.json
{
"scripts": {
"generate": "openapi-ts -f openapi-ts.config.ts",
"codegen-drift-check": "pnpm run generate && git diff --exit-code -- src/generated"
}
}CI runs codegen-drift-check, so any spec change without a regenerated
client surfaces as a PR review item. Adopters get this for free with no
extra workflow plumbing — wire it into the same job that runs lint /
typecheck.
Sonar / static-analysis tools
If your repo runs SonarCloud (or any static-analysis tool with similar
behaviour), exclude src/generated/** from analysis. Without exclusions
the generated code dominates issue counts and blocks the quality gate
without anything actionable to fix:
# sonar-project.properties
sonar.exclusions=**/src/generated/**
sonar.coverage.exclusions=**/src/generated/**(See the sonar-project.properties in the
apps-team-ts-template
for the canonical version.)
Migration
From *Schema-suffixed exports
A common pre-existing pattern in the team's code is
export const FooSchema = z.object(...).openapi('Foo'); export type Foo = z.infer<typeof FooSchema>;.
The plugin requires the schema export's binding name to equal the registry
name, so adopting it means renaming the export to drop the suffix. The type
alias Foo then collides with the renamed schema export.
Recommended migration steps:
For schemas registered as OpenAPI components (those with
.openapi('Name')chained), drop theSchemasuffix on the export and match the registered name verbatim:// before export const FooSchema = z.object({ ... }).openapi('Foo'); export type Foo = z.infer<typeof FooSchema>; // after export const Foo = z.object({ ... }).openapi('Foo'); // Drop the type alias — see step 3.Building blocks and parameter / query schemas can keep the
Schemasuffix. The plugin's audit ignores them, and keeping the suffix distinguishes "this is a Zod schema, not a registered component" at call sites.Replace the type alias with
z.output<typeof Schema>(orz.infer) inline at the call site:// before function handle(body: Foo) { ... } // after function handle(body: z.infer<typeof Foo>) { ... }z.outputis what the plugin's generated transformer uses — it covers codecs (the runtime type, not the wire string).z.inferis fine when no codec is involved.Update callers in the same PR. The audit will fail loudly if any registered name still has a
*Schema-suffixed export, so a half-done migration produces a clear error rather than silent runtime breakage.
From orval, openapi-typescript, or @hey-api/zod
These tools all derive types from the OpenAPI spec rather than importing the source Zod schemas. They lose codecs, refinements, branded types, and custom error messages — adoptions are net upgrades in correctness, not just ergonomics.
Practical steps:
- Replace the orval / openapi-typescript invocation with
@hey-api/openapi-ts+ this plugin. See Usage. - Migrate any custom
customFetch-style fetch adapter to the singletonclient.setConfig({ fetch: ... })pattern, or — for SSR / multi-instance setups — tocreateClient(createConfig({ fetch: ... })). - Wherever a caller formerly read
result.data.somethingfrom the orval shape ({ data, status, headers }), the hey-api shape is{ data, error, ... };result.databecomes the parsed body directly. - Drop any inline Zod re-derivations (typically
z.infer<typeof components['schemas']['Foo']>or similar). Import the actual schema instead.
Testing consumer code
The generated client works with MSW-style HTTP fakes — point the client at a mock baseUrl, register handlers for the routes under test, and call the SDK function. The transformer runs on the response body, so codec decode is exercised end-to-end:
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { client } from './generated/client.gen.ts';
import { getPayment } from './generated/sdk.gen.ts';
const server = setupServer();
beforeAll(() => {
client.setConfig({ baseUrl: 'http://api.test' });
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('decodes int64 wire strings to bigint', async () => {
server.use(
http.get('http://api.test/payments/:id', () =>
HttpResponse.json({ id: 'p_1', amount: '999999999999' })
)
);
const { data } = await getPayment({ path: { id: 'p_1' } });
expect(typeof data!.amount).toBe('bigint');
expect(data!.amount).toBe(999999999999n);
});test/api.test.ts in this package follows the same pattern against the
fixture registry — see it for multi-status, error-path, and parse-rejection
examples.
Constraints — what the plugin doesn't handle
- Inline response schemas (no
$refto a registered schema) are skipped — there's no Zod schema to bind to. To get a transformer, register the schema in your registry and use the value returned fromregister()in the route response, not the original schema. zod-to-openapi treats the original and the named instance as separate schemas; only the named one emits a$ref.
Why z.output<typeof Schema> instead of walking the schema
The plugin defers to TypeScript's own resolution of Zod's type machinery via
z.output<typeof Schema>, rather than walking the schema's _def structure
to build TypeScript types directly.
Walking _def requires a dedicated branch for every Zod construct (tuples,
intersections, lazy, branded types, defaults, ZodPipe variants, ZodReadonly,
…), and any construct without a branch silently falls through to unknown —
the worst kind of bug, where types compile and the SDK looks fine until a
caller trips over it at runtime. z.output<> is automatically correct for
everything z.infer supports today and anything Zod adds tomorrow.
The trade-off is a type-only dependency on the schemas package in the generated
.d.ts. Since the generated runtime already calls Schema.parseAsync(data)
from that same package, this is honest about the actual coupling.
