fix: attach .openapi to schemas constructed before extendZodWithOpenApi runs#382
Open
lobotomoe wants to merge 1 commit into
Open
Conversation
fa62ffc to
ea584bc
Compare
Currently `extendZodWithOpenApi` only attaches `.openapi` to schemas constructed AFTER it runs. Schemas constructed earlier in the import graph crash at first use with `TypeError: <schema>.openapi is not a function`. Why this happens (Zod v4): - `core.$constructor` does not link `ZodObject.prototype` (or any other concrete schema class's prototype) to `ZodType.prototype`. They are separate `_.prototype` objects — `ZodObject.prototype.__proto__ === Object.prototype`, not `ZodType.prototype`. - During schema construction, `init` iterates `Object.keys(_.prototype)` and BINDS each method onto the instance. So a method added to `ZodType.prototype` after the schema is constructed is unreachable: it's not on the instance, and it's not in the instance's prototype chain (because the chain skips `ZodType.prototype`). Repro: any ESM dependency graph where a schema module evaluates before the consumer that calls `extendZodWithOpenApi`. We hit it consistently in Next.js Turbopack production builds, where chunk layout caused `@vcr/core/schemas` to evaluate before `@hono/zod-openapi` (which calls `extendZodWithOpenApi(z)` on import). On macOS the page-data collector happened to instantiate the chunks in an order that masked the issue; on the GitHub Linux runner it didn't. Fix: after assigning `.openapi` to `ZodType.prototype`, mirror the same function onto every concrete `Zod*.prototype`. Pre-existing instances then resolve `.openapi` via prototype-chain lookup; newly constructed instances resolve it via the same bind-on-init loop the `$constructor` runs against the schema class's own prototype. Adds `spec/late-extend.spec.ts` with three regression tests: - `.openapi` exists on schemas constructed before the call - `.openapi` still works on schemas constructed after the call - end-to-end: a pre-existing schema produces a valid spec All existing tests still pass (49 suites / 298 tests).
ea584bc to
3850180
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
extendZodWithOpenApi(z)only attaches.openapito schemas constructed after it runs. Anything constructed earlier — for any reason — crashes at first use withTypeError: <schema>.openapi is not a function. Closed-with-workaround issues that hit this same root cause: #330, #338. (PR #378 addresses a separate, type-level subpath conflict.)The standard advice — "call
extendZodWithOpenApionce at the entry point" — is not enough in any bundler that splits modules into chunks. The dependency graph can have a path to a schema-defining module that doesn't go through the module that callsextendZodWithOpenApi. When that path's chunk is the first to instantiate the schema module, the schemas are constructed and cached without.openapi, and every later consumer reads them back broken.Root cause (Zod v4)
Two facts collide:
ZodObject.prototype.__proto__ !== ZodType.prototype. Concrete schema classes are independent factories, and their prototypes do not chain toZodType.prototype. A property added toZodType.prototypeis therefore unreachable via prototype-chain lookup from aZodObject/ZodString/ etc. instance.inititeratesObject.keys(_.prototype)and binds each method onto the instance at construction time. Adding a method to a prototype after a schema is constructed misses this loop, so the instance never gets it directly either.Once a schema is constructed, late-patching
ZodType.prototype.openapiis invisible to it — both on the instance and via the prototype chain. Verified directly:Minimal reproduction
Hosted: https://github.com/lobotomoe/zod-openapi-late-extend-repro
Two GitHub Actions workflows in that repo run on every push and demonstrate both the bug and the fix without anyone having to clone or run anything:
node test.mjsagainst unpatched 8.5.0, exits 1 with theTypeError: <schema>.openapi is not a functionstack trace.node test.mjs, but with this PR's branch installed vianpm install github:lobotomoe/zod-to-openapi#fix/extend-already-constructed-schemas. Exits 0.Or run interactively in browser: https://stackblitz.com/github/lobotomoe/zod-openapi-late-extend-repro
Real-world reproduction (Next.js 16 + Turbopack)
We hit this in CI with the following stack frame:
Inspecting the bundle showed exactly the mechanism above.
@hono/zod-openapi(which callsextendZodWithOpenApion import) was assigned module id773998. The schema-defining module was714981. The route's chunk imports both in the right order:But there was a second chunk in the same build, where a few internal helper modules each did
import { someSchema } from "./schemas"for runtime validation. That chunk importse.i(714981)and does not importe.i(773998)—@hono/zod-openapiis unreachable from it.When the page-data collector evaluated that chunk first, it instantiated the schema module. Schemas were constructed by bare zod with no
.openapion the prototype. The ESM module cache pinned that broken state. When the API route chunk later evaluated,e.i(773998)ran the patch, bute.i(714981)returned the cached schemas, which had been frozen at construction time without the method.This is a structural property of the dependency graph, not a freak ordering. The chunk that imports the schema module without also importing
@hono/zod-openapiis fixed by the source code; whether the bug fires on a given build is a function of which chunk the page-data collector touches first, which is environment-dependent. The same workspace passed locally on macOS and failed on the GitHub Actions Linux runner against the same commit.The fundamental invariant assumed by today's
extendZodWithOpenApi— "the call happens before any consumer instantiates a schema-defining module" — is unenforceable in any bundler with code-splitting and lazy chunk evaluation. Next.js Turbopack is one example; the same shape can surface in Webpack, Rollup, Vite SSR, esbuild splitting.Fix
After the existing
zod.ZodType.prototype.openapi = …assignment, mirror the same function onto every concreteZod*.prototype. Then:.openapivia prototype-chain lookup against their own constructor's prototype (e.g.ZodObject.prototype), since the chain isinst -> ZodObject.prototype -> Object.prototypeand we just put.openapionZodObject.prototype.$constructorruns against their schema class's own prototype.The runtime function body is unchanged. Only the reach of the assignment expands. The early-return guard on
ZodType.prototype.openapiis preserved, so this stays idempotent.Tests
spec/late-extend.spec.ts— seven regression tests across primitives, composites, and wrappers:.openapion primitives constructed before the call (string,number).openapion composites constructed before the call (object,array,tuple,union).openapion wrappers constructed before the call (optional,nullable).openapistill works on schemas constructed after the call (no regression)expectSchema()for a pre-existing primitiveexpectSchema()for a pre-existing composite.openapitoZodError(which extends Error, not a schema)Without the fix, 5 of the 7 tests fail with
Expected: "function", Received: "undefined"orTypeError: <schema>.openapi is not a function. The two that pass either way are the "constructed after the call" case (no-regression check) and theZodErrorguard (it staysundefinedeither way without the fix, because nothing patches it).Full suite: 49 suites / 302 tests passing (was 48 / 295).
Validation
Patched via
pnpm patchin a Next.js 16 + Turbopack production build that was reliably failing CI on Linux. With the patch,next buildpage-data collection succeeds and chained.openapi("Name")on schemas imported across package boundaries works regardless of which chunk the collector instantiates first.Notes
Object.entries(zod)as a record of constructors with prototypes. Both are scoped to the loop and don't widen the public API.zodvszod/v4). They could be merged independently.