Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions spec/late-extend.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { extendZodWithOpenApi } from '../src/zod-extensions';
import { expectSchema } from './lib/helpers';

// Schemas constructed before `extendZodWithOpenApi` runs must still expose a
// working `.openapi`. Zod v4 binds prototype keys to instances at construction
// time and does not link `ZodObject.prototype` (etc.) to `ZodType.prototype`,
// so a late patch on `ZodType.prototype` alone is unreachable from
// pre-existing instances.
describe('Late extendZodWithOpenApi', () => {
function requireSeparateZodInstance() {
jest.resetModules();
delete require.cache[require.resolve('zod')];

// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('zod');
}

const zod = requireSeparateZodInstance();

const stringBefore = zod.z.string();
const numberBefore = zod.z.number();
const objectBefore = zod.z.object({ a: zod.z.string() });
const arrayBefore = zod.z.array(zod.z.string());
const tupleBefore = zod.z.tuple([zod.z.string(), zod.z.number()]);
const unionBefore = zod.z.union([zod.z.string(), zod.z.number()]);
const optionalBefore = zod.z.string().optional();
const nullableBefore = zod.z.number().nullable();

extendZodWithOpenApi(zod);

it('attaches .openapi to primitives constructed before the call', () => {
expect(typeof stringBefore.openapi).toBe('function');
expect(typeof numberBefore.openapi).toBe('function');
});

it('attaches .openapi to composites constructed before the call', () => {
expect(typeof objectBefore.openapi).toBe('function');
expect(typeof arrayBefore.openapi).toBe('function');
expect(typeof tupleBefore.openapi).toBe('function');
expect(typeof unionBefore.openapi).toBe('function');
});

it('attaches .openapi to wrappers constructed before the call', () => {
expect(typeof optionalBefore.openapi).toBe('function');
expect(typeof nullableBefore.openapi).toBe('function');
});

it('keeps .openapi working for schemas constructed after the call', () => {
const after = zod.z.string();
const afterOptional = zod.z.string().optional();
const afterArray = zod.z.array(zod.z.number());
expect(typeof after.openapi).toBe('function');
expect(typeof afterOptional.openapi).toBe('function');
expect(typeof afterArray.openapi).toBe('function');
});

it('produces a usable spec from a pre-existing primitive', () => {
expectSchema([stringBefore.openapi('SimpleString')], {
SimpleString: { type: 'string' },
});
});

it('produces a usable spec from a pre-existing composite', () => {
expectSchema([objectBefore.openapi('SimpleObject')], {
SimpleObject: {
type: 'object',
properties: { a: { type: 'string' } },
required: ['a'],
},
});
});

it('does not attach .openapi to ZodError', () => {
// ZodError is exported by zod but is not a schema constructor — it
// extends Error and has no `_def`. Polluting its prototype with
// `.openapi` would change observable behavior in `try { ... } catch (e)`
// blocks and would crash with an inscrutable error if anyone called it.
expect(typeof zod.ZodError.prototype.openapi).toBe('undefined');
});
});
18 changes: 18 additions & 0 deletions src/zod-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,24 @@ export function extendZodWithOpenApi(zod: typeof z) {

return result;
};

// Mirror `.openapi` onto every concrete schema prototype. Zod v4's
// `$constructor` binds prototype keys onto each instance at construction
// time, and `ZodObject.prototype` (etc.) does NOT chain to
// `ZodType.prototype`, so the assignment above is unreachable from any
// schema constructed before this function runs. Patching each
// `Zod*.prototype` directly makes the extension order-independent.
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 is not a schema
// constructor — it extends Error, not $ZodType, and has no `_def` for
// `.openapi`'s `new this.constructor(this._def)` clone to consume.
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;
}
}

function getOpenApiConfiguration(
Expand Down