Skip to content

Commit 3850180

Browse files
committed
fix: extend already-constructed Zod v4 schemas with .openapi()
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).
1 parent 2fb24b0 commit 3850180

2 files changed

Lines changed: 98 additions & 0 deletions

File tree

spec/late-extend.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { extendZodWithOpenApi } from '../src/zod-extensions';
2+
import { expectSchema } from './lib/helpers';
3+
4+
// Schemas constructed before `extendZodWithOpenApi` runs must still expose a
5+
// working `.openapi`. Zod v4 binds prototype keys to instances at construction
6+
// time and does not link `ZodObject.prototype` (etc.) to `ZodType.prototype`,
7+
// so a late patch on `ZodType.prototype` alone is unreachable from
8+
// pre-existing instances.
9+
describe('Late extendZodWithOpenApi', () => {
10+
function requireSeparateZodInstance() {
11+
jest.resetModules();
12+
delete require.cache[require.resolve('zod')];
13+
14+
// eslint-disable-next-line @typescript-eslint/no-require-imports
15+
return require('zod');
16+
}
17+
18+
const zod = requireSeparateZodInstance();
19+
20+
const stringBefore = zod.z.string();
21+
const numberBefore = zod.z.number();
22+
const objectBefore = zod.z.object({ a: zod.z.string() });
23+
const arrayBefore = zod.z.array(zod.z.string());
24+
const tupleBefore = zod.z.tuple([zod.z.string(), zod.z.number()]);
25+
const unionBefore = zod.z.union([zod.z.string(), zod.z.number()]);
26+
const optionalBefore = zod.z.string().optional();
27+
const nullableBefore = zod.z.number().nullable();
28+
29+
extendZodWithOpenApi(zod);
30+
31+
it('attaches .openapi to primitives constructed before the call', () => {
32+
expect(typeof stringBefore.openapi).toBe('function');
33+
expect(typeof numberBefore.openapi).toBe('function');
34+
});
35+
36+
it('attaches .openapi to composites constructed before the call', () => {
37+
expect(typeof objectBefore.openapi).toBe('function');
38+
expect(typeof arrayBefore.openapi).toBe('function');
39+
expect(typeof tupleBefore.openapi).toBe('function');
40+
expect(typeof unionBefore.openapi).toBe('function');
41+
});
42+
43+
it('attaches .openapi to wrappers constructed before the call', () => {
44+
expect(typeof optionalBefore.openapi).toBe('function');
45+
expect(typeof nullableBefore.openapi).toBe('function');
46+
});
47+
48+
it('keeps .openapi working for schemas constructed after the call', () => {
49+
const after = zod.z.string();
50+
const afterOptional = zod.z.string().optional();
51+
const afterArray = zod.z.array(zod.z.number());
52+
expect(typeof after.openapi).toBe('function');
53+
expect(typeof afterOptional.openapi).toBe('function');
54+
expect(typeof afterArray.openapi).toBe('function');
55+
});
56+
57+
it('produces a usable spec from a pre-existing primitive', () => {
58+
expectSchema([stringBefore.openapi('SimpleString')], {
59+
SimpleString: { type: 'string' },
60+
});
61+
});
62+
63+
it('produces a usable spec from a pre-existing composite', () => {
64+
expectSchema([objectBefore.openapi('SimpleObject')], {
65+
SimpleObject: {
66+
type: 'object',
67+
properties: { a: { type: 'string' } },
68+
required: ['a'],
69+
},
70+
});
71+
});
72+
73+
it('does not attach .openapi to ZodError', () => {
74+
// ZodError is exported by zod but is not a schema constructor — it
75+
// extends Error and has no `_def`. Polluting its prototype with
76+
// `.openapi` would change observable behavior in `try { ... } catch (e)`
77+
// blocks and would crash with an inscrutable error if anyone called it.
78+
expect(typeof zod.ZodError.prototype.openapi).toBe('undefined');
79+
});
80+
});

src/zod-extensions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,24 @@ export function extendZodWithOpenApi(zod: typeof z) {
237237

238238
return result;
239239
};
240+
241+
// Mirror `.openapi` onto every concrete schema prototype. Zod v4's
242+
// `$constructor` binds prototype keys onto each instance at construction
243+
// time, and `ZodObject.prototype` (etc.) does NOT chain to
244+
// `ZodType.prototype`, so the assignment above is unreachable from any
245+
// schema constructed before this function runs. Patching each
246+
// `Zod*.prototype` directly makes the extension order-independent.
247+
const openapiMethod = zod.ZodType.prototype.openapi;
248+
for (const [key, ctor] of Object.entries(zod) as [string, unknown][]) {
249+
// `ZodError` is the only `Zod*`-prefixed export that is not a schema
250+
// constructor — it extends Error, not $ZodType, and has no `_def` for
251+
// `.openapi`'s `new this.constructor(this._def)` clone to consume.
252+
if (key === 'ZodError') continue;
253+
if (!key.startsWith('Zod') || typeof ctor !== 'function') continue;
254+
const proto = (ctor as { prototype?: { openapi?: unknown } }).prototype;
255+
if (!proto || typeof proto.openapi !== 'undefined') continue;
256+
proto.openapi = openapiMethod;
257+
}
240258
}
241259

242260
function getOpenApiConfiguration(

0 commit comments

Comments
 (0)