@aemrezorlu/zod-contract
v0.4.0
Published
Zod schemas → OpenAPI 3.x components (default 3.2) + JSON examples. Zero wrapping, zero config.
Maintainers
Readme
zod-contract
Zod schemas → OpenAPI 3.x components (default 3.2) + JSON examples. Zero wrapping, zero config.
import { z } from 'zod'
export const User = z.object({
id: z.string().uuid().default('9b1deb4d-...'),
email: z.string().email().default('[email protected]')
})$ npx @aemrezorlu/zod-contract build src/api api
✓ 1 schema → 2 files in apiapi/
├── components/schemas/User.yaml # OpenAPI 3.1 schema
├── examples/user.json # JSON example
└── index.yaml # OpenAPI 3.1 rootWhy
Most Zod → OpenAPI tools force you to wrap every schema:
// the old way
const User = z.object({...}).openapi('User')zod-contract skips that step. The schema is the contract.
Cross-schema references
When a sub-schema is the same instance as a top-level export, it's emitted as a $ref:
export const Address = z.object({ city: z.string() })
export const User = z.object({ name: z.string(), address: Address })User:
type: object
properties:
name: { type: string }
address: { $ref: '#/components/schemas/Address' }
required: [name, address]Wrappers carry the marker through:
Schema.optional()→$ref(optional drops the field fromrequired)Schema.nullable()→$refwithnullable: true(stays inrequiredin OpenAPI 3.1)Schema.default(x)→$refwithexample: xz.array(Schema)→type: arraywithitems: { $ref }z.union([A, B])→oneOfwith$refs for registered members
Bidirectional refs (User→Address and Address→User in the same file) work via z.lazy() on at least one side:
export const Address = z.object({ city: z.string(), occupant: z.lazy(() => User) })
export const User = z.object({ name: z.string(), home: Address })Both sides emit $ref; the walking set in convert() handles the cycle.
Example values
In Zod 3, attach example data with .default(value). Zod 4 (when stable) will also
support .example(value) natively; the converter already reads both.
export const User = z.object({
email: z.string().email().default('[email protected]')
})
// → examples/user.json: { "email": "[email protected]", ... }Fields without a default get a heuristic example ("string", 0, false, [], ...).
Install
npm install --save-dev @aemrezorlu/zod-contractPeer dep: zod ^3.23.
CLI
build
zod-contract build <src> <out>
zod-contract build <src> <out> --format json<src>— a.tsfile or a directory (walks recursively, skips*.test.tsand*.d.ts)<out>— output directory (created if missing)--format yaml|json— schema + index file format; examples are always JSON
Output:
components/schemas/<Name>.{yaml,json}— one OpenAPI 3.1 schema per exportexamples/<name>.json— JSON example extracted from.example()/.default()/ heuristicindex.{yaml,json}— OpenAPI 3.1 root that $refs all schemas
watch
zod-contract watch <src> <out>
zod-contract watch <src> <out> --format json --debounce 200Initial build, then incremental rebuilds on any source change. Debounce collapses rapid bursts (e.g. editor save chains). Ctrl-C to stop.
Supported Zod types
ZodString (with uuid, email, url, datetime, regex, min/max length),
ZodNumber (with int, min/max), ZodBoolean, ZodNull, ZodBigInt, ZodDate,
ZodArray, ZodObject (with required tracking and catchall),
ZodEnum / ZodNativeEnum, ZodLiteral, ZodOptional, ZodNullable,
ZodDefault, ZodUnion, ZodDiscriminatedUnion, ZodRecord, ZodAny, ZodUnknown.
Unknown types emit {} rather than failing.
Plugin extension surface
import type { Plugin } from '@aemrezorlu/zod-contract'
const myPlugin: Plugin = {
name: 'example',
transformSchema(schema, info) {
// mutate and return
return schema
},
finalize(ctx) {
// add files to ctx.outputs
ctx.outputs.set('paths/users.yaml', '...')
return ctx
},
}Shipped plugins:
@aemrezorlu/zod-contract-paths— file-based routing → OpenAPIpaths.yaml@aemrezorlu/zod-contract-hono— Honoapp.routes→ OpenAPIpaths.yaml@aemrezorlu/zod-contract-trpc— tRPCappRouter→ OpenAPIpaths.yaml
Programmatic API
import { build } from '@aemrezorlu/zod-contract'
await build({ src: 'src/api', out: 'api' })License
MIT
