Skip to content

fix: attach .openapi to schemas constructed before extendZodWithOpenApi runs#382

Open
lobotomoe wants to merge 1 commit into
asteasolutions:masterfrom
lobotomoe:fix/extend-already-constructed-schemas
Open

fix: attach .openapi to schemas constructed before extendZodWithOpenApi runs#382
lobotomoe wants to merge 1 commit into
asteasolutions:masterfrom
lobotomoe:fix/extend-already-constructed-schemas

Conversation

@lobotomoe
Copy link
Copy Markdown

@lobotomoe lobotomoe commented Apr 27, 2026

Problem

extendZodWithOpenApi(z) only attaches .openapi to schemas constructed after it runs. Anything constructed earlier — for any reason — crashes at first use with TypeError: <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 extendZodWithOpenApi once 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 calls extendZodWithOpenApi. 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)

// zod/v4/core/core.js
export function $constructor(name, initializer, params) {
  function init(inst, def) {
    // ...
    initializer(inst, def);
    // bind own enumerable prototype keys onto the instance
    const proto = _.prototype;
    for (const k of Object.keys(proto)) {
      if (!(k in inst)) inst[k] = proto[k].bind(inst);
    }
  }
  // ...
}

Two facts collide:

  1. ZodObject.prototype.__proto__ !== ZodType.prototype. Concrete schema classes are independent factories, and their prototypes do not chain to ZodType.prototype. A property added to ZodType.prototype is therefore unreachable via prototype-chain lookup from a ZodObject / ZodString / etc. instance.
  2. init iterates Object.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.openapi is invisible to it — both on the instance and via the prototype chain. Verified directly:

import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';

console.log(z.ZodObject.prototype.__proto__ === z.ZodType.prototype); // false
console.log(z.ZodObject.prototype.__proto__ === Object.prototype);    // true

const before = z.object({ a: z.string() });
extendZodWithOpenApi(z);
console.log('openapi' in before); // false (not on instance, not in proto chain)

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:

  • Bug fires on stock 8.5.0 — runs node test.mjs against unpatched 8.5.0, exits 1 with the TypeError: <schema>.openapi is not a function stack trace.
  • Fix from PR #382 resolves the bug — same node test.mjs, but with this PR's branch installed via npm 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

// schemaModule.mjs
import { z } from 'zod';
export const before = z.object({ a: z.string() });

// openapiPatch.mjs
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
extendZodWithOpenApi(z);

// test.mjs
import { before } from './schemaModule.mjs';
import './openapiPatch.mjs';

console.log(typeof before.openapi); // 'undefined'
console.log(z.object({ b: z.string() }).openapi); // 'function'

Real-world reproduction (Next.js 16 + Turbopack)

We hit this in CI with the following stack frame:

TypeError: d.offerSchema.openapi is not a function
    at <unknown> (.next/server/chunks/_0.oqlt-._.js:2:5978)
    at module evaluation (.next/server/chunks/_0.oqlt-._.js:2:5776)
    at instantiateModule (.next/server/chunks/[turbopack]_runtime.js:853:9)
    at Context.esmImport [as i] (.next/server/chunks/[turbopack]_runtime.js:281:20)
> Build error occurred
Error: Failed to collect page data for /api/v1/[[...route]]

Inspecting the bundle showed exactly the mechanism above. @hono/zod-openapi (which calls extendZodWithOpenApi on import) was assigned module id 773998. The schema-defining module was 714981. The route's chunk imports both in the right order:

// _0.oqlt-._.js (route handler)
var a = e.i(773998),  // @hono/zod-openapi — applies the patch
    /* ... */
    d = e.i(714981);  // schemas — constructed AFTER patch
let m = d.offerSchema.openapi("OfferRequest");  // works in isolation

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 imports e.i(714981) and does not import e.i(773998)@hono/zod-openapi is 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 .openapi on the prototype. The ESM module cache pinned that broken state. When the API route chunk later evaluated, e.i(773998) ran the patch, but e.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-openapi is 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 concrete Zod*.prototype. Then:

  • Pre-existing instances resolve .openapi via prototype-chain lookup against their own constructor's prototype (e.g. ZodObject.prototype), since the chain is inst -> ZodObject.prototype -> Object.prototype and we just put .openapi on ZodObject.prototype.
  • Newly constructed instances resolve it via the same bind-on-init loop that $constructor runs 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.openapi is preserved, so this stays idempotent.

const openapiMethod = zod.ZodType.prototype.openapi;
for (const [key, ctor] of Object.entries(zod) as [string, unknown][]) {
  // ZodError is the only Zod*-prefixed export that isn't a schema constructor.
  if (key === 'ZodError') continue;
  if (!key.startsWith('Zod') || typeof ctor !== 'function') continue;
  const proto = (ctor as { prototype?: { openapi?: unknown } }).prototype;
  if (!proto || typeof proto.openapi !== 'undefined') continue;
  proto.openapi = openapiMethod;
}

Tests

spec/late-extend.spec.ts — seven regression tests across primitives, composites, and wrappers:

  • .openapi on primitives constructed before the call (string, number)
  • .openapi on composites constructed before the call (object, array, tuple, union)
  • .openapi on wrappers constructed before the call (optional, nullable)
  • .openapi still works on schemas constructed after the call (no regression)
  • end-to-end via expectSchema() for a pre-existing primitive
  • end-to-end via expectSchema() for a pre-existing composite
  • guard against attaching .openapi to ZodError (which extends Error, not a schema)

Without the fix, 5 of the 7 tests fail with Expected: "function", Received: "undefined" or TypeError: <schema>.openapi is not a function. The two that pass either way are the "constructed after the call" case (no-regression check) and the ZodError guard (it stays undefined either way without the fix, because nothing patches it).

Full suite: 49 suites / 302 tests passing (was 48 / 295).

Validation

Patched via pnpm patch in a Next.js 16 + Turbopack production build that was reliably failing CI on Linux. With the patch, next build page-data collection succeeds and chained .openapi("Name") on schemas imported across package boundaries works regardless of which chunk the collector instantiates first.

Notes

@lobotomoe lobotomoe marked this pull request as draft April 27, 2026 23:22
@lobotomoe lobotomoe force-pushed the fix/extend-already-constructed-schemas branch 2 times, most recently from fa62ffc to ea584bc Compare April 28, 2026 00:20
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).
@lobotomoe lobotomoe force-pushed the fix/extend-already-constructed-schemas branch from ea584bc to 3850180 Compare April 28, 2026 00:23
@lobotomoe lobotomoe marked this pull request as ready for review April 28, 2026 01:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant