TL;DR
- v0.4 ships as five focused npm packages under the
@aemrezorlu/zod-contractumbrella. Not one, because the problem doesn’t fit in one. - Core CLI gains
initand--exclude. Three collision modes land too:error,merge,first-wins. - Bidirectional
$refworks viaz.lazy(). Every other library breaks this. - tRPC subscriptions land in OpenAPI 3.2
webhooks:, the only native place for them. @aemrezorlu/zod-contract-authv0.1.0 debuts. Security schemes, per-operationsecurity:, public-route allowlists.- Roadmap through v0.5: watch deletion,
zod-contract validate, structured errors. Plus OAuth2/OIDC, Fastify/Express adapters, a Drizzle → Zod bridge.
The problem: every OpenAPI generator wants you to wrap
Most Zod → OpenAPI tools force the same wrapping ritual:
const User = z.object({...}).openapi('User') // the contract gets a name
const Foo = z.object({...}).openapi('Foo') // by hand, twice, per file
You do it once, fine. You do it on a 200-schema codebase and you start hating it. Worse: every time you forget, the schema silently appears in the output as anonymous. $ref breaks. Debugging gets harder. Your contract drifts away from what the code does.
zod-contract’s position: the export name IS the contract. export const User = z.object({...}) is enough. No .openapi() ceremony.
The first version of this project shipped in a weekend and lived a year. The things people still ask for are the ones that only matter once you cross 50 schemas. Bidirectional refs. Validation against the generated output. An init that doesn’t make you copy-paste from a README. v0.4 is where those land.
Why five packages (and not one meg-plugin)
A Hono plugin is not a tRPC plugin. An auth plugin is not a routing plugin. Trying to merge them is how you ship a meg-plugin nobody trusts.
The v0.1 era of this project (yes, there were 0.x experiments) was one big repo. Every new routing framework meant a feature branch that broke the existing tests. Every new auth flavor meant a coupling that didn’t belong there.
v0.3+ split the boundary:
| Package | Role |
|---|---|
@aemrezorlu/zod-contract |
Zod introspection → OpenAPI components + JSON examples |
@aemrezorlu/zod-contract-paths |
File-based routing (e.g. users.post.ts → POST /users) |
@aemrezorlu/zod-contract-hono |
Walks a Hono app.routes table, supports inline zValidator() via AST |
@aemrezorlu/zod-contract-trpc |
Walks a tRPC appRouter._def.procedures, splits subscriptions into webhooks: on 3.2 |
@aemrezorlu/zod-contract-auth |
Emits components/securitySchemes + per-operation security: |
Each package gets its own peer-deps range. Its own semver. Its own changelog. When you don’t need auth, you don’t import it. When your router changes, you only bump the routing plugin.
The peer-dep discipline this enforces is not free. It’s the cycle’s most underrated chore (more on that below). It pays back when a Hono user upgrades without dragging tRPC changes along, and vice versa.
What’s in v0.4
Core, zod-contract 0.4.0
zod-contract init [dir]scaffoldssrc/schemas/User.tswith a working starter, idempotent by default. Add--with-package-scriptsand it injectszod-contract:buildpluszod-contract:watchintopackage.json. Only if absent. It never overwrites anything you’ve already set.--exclude <glob...>filters source files during scan. Supports**,*,?. Real-world pattern:zod-contract build src/api api \ --exclude '**/internal/**' '**/*.draft.ts'--on-collision <mode>controls behavior when two exports share a name across files:
Mode Behavior first-wins(default)Keep the first occurrence, stderr note errorThrow, refuse to build mergeCombine ZodObjectshapes viaZod.merge()(last-wins per key); non-ZodObject collisions throw with a clear “rename one” pointer- Bidirectional
$refviaz.lazy()is the cycle that every other library silently breaks.User → Address → Usernow emits$refon both sides. The recursion goes through thewalkingset inconvert(). (This was actually v0.3.0 but worth surfacing. Most users hit it on day one.)
Routing plugins, paths 0.4.1, hono 0.3.1, trpc 0.3.1
All three shipped peer-dep bumps to ^0.4.0. Beyond that:
paths0.4.x:export const path = '/v2/users'in a route file overrides the convention-derived path.- Tag inference from folder:
admin/users.get.ts→tags: [admin]. Schema.describe('...')copied tooperation.description.
hono0.3.x:- Description flow as above.
- Tag inference from the route path: two-or-more-stem paths get the second-to-last non-param segment.
trpc0.3.x:- Description flow on output / input schemas.
- Subscriptions still emit
webhooks:on 3.2 (from v0.2; kept).
New package, zod-contract-auth 0.1.0
This was the v0.4 feature I was most reluctant to ship. Auth is opinionated, and the moment you ship a JWT helper you own the JWT lifecycle: key rotation, issuer validation, clock skew. All on you.
So v0.1.0 deliberately stays at the OpenAPI surface.
import { authPlugin } from '@aemrezorlu/zod-contract-auth'
authPlugin({
schemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
apiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
},
require: {
default: ['bearerAuth'],
public: ['/health', '/internal/*'], // wildcard suffix supported
},
})
Output:
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
apiKeyAuth: { type: apiKey, in: header, name: X-API-Key }
paths:
/health:
get: {} # public, no security
/users:
get:
security: [{ bearerAuth: [] }]
OAuth2, OIDC, mutual TLS are all out of v0.1. Each needs real work on tokenUrl, authorizationUrl, scopes. v0.2.
Lessons from shipping this cycle
Two things I relearned this release:
1. Peer-deps ARE the release contract. Bumping @aemrezorlu/zod-contract ^0.3.0 → ^0.4.0 across three plugins was the most underrated chore of the cycle. Skip it and you leave users on a beta plugin talking to a stable core. They’d run together, sure, but the contract wouldn’t fit. The cost stays invisible until someone hits it on npm install. Then debugging gets ugly.
2. Publish-last beats ship-fast. The order local → commit → publish → report turned chaos into a 50-green-test cycle. I had one premature-release moment this cycle, and the discipline caught it before npm saw it.
If you maintain a plugin family, I’d love to hear how you handle this.
The roadmap through v0.5
After v0.4 ships, three Tier 2 CLI items get attention:
| Target | Items |
|---|---|
| v0.4.1 (already shipped, 3 plugins) | Peer-dep bumps to ^0.4.0 |
| v0.4.2 (planned) | zod-contract watch handles .unlink (removed files don’t leave orphan outputs); ZodContractError class with cause chains + --verbose flag for stack traces |
| v0.5.0 (planned) | zod-contract validate <dir> runs AJV against the generated OpenAPI + JSON examples (CI-friendly). Plus zod-contract-auth v0.2: OAuth2, OpenID Connect, mutual TLS |
| Parked for v0.6+ | Fastify / Express adapters, Drizzle / Prisma → Zod bridge, $ref cycle report, Vitest matchers (toMatchOpenAPISchema(User)), VS Code extension for hover-on-schema |
Every entry on this list is something a user has asked for. Prioritization will track what gets adoption traction, not what I find interesting.
If something on the parked list would unblock you, the most useful thing you can do is open an issue with a one-paragraph description of what you’d build on top of it. Adoption signal drives ordering more than “wouldn’t it be cool.”
Try it
npm install --save-dev @aemrezorlu/zod-contract
npx zod-contract init
npx zod-contract build src/schemas api
Add plugins as needed:
npm install @aemrezorlu/zod-contract-paths
npm install @aemrezorlu/zod-contract-hono
npm install @aemrezorlu/zod-contract-auth
50 tests across the five packages, MIT, on npm. Issues and PRs on each repo:
If you ship an API on this, I’d love to hear about it.