diff --git a/packages/zod/src/v4/classic/schemas.ts b/packages/zod/src/v4/classic/schemas.ts index 5f41192a9d..5f86ad93cb 100644 --- a/packages/zod/src/v4/classic/schemas.ts +++ b/packages/zod/src/v4/classic/schemas.ts @@ -174,7 +174,10 @@ export const ZodType: core.$constructor = /*@__PURE__*/ core.$construct typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch ), ], - }) + }), + { + parent: true, + } ); }; inst.clone = (def, params) => core.clone(inst, def, params); diff --git a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts index 45bf82375d..e3c1b4689a 100644 --- a/packages/zod/src/v4/classic/tests/to-json-schema.test.ts +++ b/packages/zod/src/v4/classic/tests/to-json-schema.test.ts @@ -1720,7 +1720,7 @@ test("override: do not run on references", () => { }, }); - expect(overrideCount).toBe(6); + expect(overrideCount).toBe(12); }); test("override with refs", () => { @@ -2020,6 +2020,47 @@ test("describe with id", () => { `); }); +test("describe with id on wrapper", () => { + // Test that $ref propagation works when processor sets a different ref (readonly -> innerType) + // but parent was extracted due to having an id + const roJobId = z.string().readonly().meta({ id: "roJobId" }); + + const a = z.toJSONSchema( + z.object({ + current: roJobId.describe("Current readonly job"), + previous: roJobId.describe("Previous readonly job"), + }) + ); + expect(a).toMatchInlineSnapshot(` + { + "$defs": { + "roJobId": { + "id": "roJobId", + "readOnly": true, + "type": "string", + }, + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "current": { + "$ref": "#/$defs/roJobId", + "description": "Current readonly job", + }, + "previous": { + "$ref": "#/$defs/roJobId", + "description": "Previous readonly job", + }, + }, + "required": [ + "current", + "previous", + ], + "type": "object", + } + `); +}); + test("overwrite id", () => { const jobId = z.string().meta({ id: "aaa" }); @@ -2754,22 +2795,17 @@ test("z.file()", () => { "$schema": "https://json-schema.org/draft/2020-12/schema", "anyOf": [ { - "contentEncoding": "binary", "contentMediaType": "image/png", - "format": "binary", - "maxLength": 10000, - "minLength": 1000, - "type": "string", }, { - "contentEncoding": "binary", "contentMediaType": "image/jpg", - "format": "binary", - "maxLength": 10000, - "minLength": 1000, - "type": "string", }, ], + "contentEncoding": "binary", + "format": "binary", + "maxLength": 10000, + "minLength": 1000, + "type": "string", } `); }); diff --git a/packages/zod/src/v4/core/json-schema-processors.ts b/packages/zod/src/v4/core/json-schema-processors.ts index 96187fa630..e570140751 100644 --- a/packages/zod/src/v4/core/json-schema-processors.ts +++ b/packages/zod/src/v4/core/json-schema-processors.ts @@ -234,10 +234,8 @@ export const fileProcessor: Processor = (schema, _ctx, json, _ file.contentMediaType = mime[0]!; Object.assign(_json, file); } else { - _json.anyOf = mime.map((m) => { - const mFile: JSONSchema.StringSchema = { ...file, contentMediaType: m }; - return mFile; - }); + Object.assign(_json, file); // shared props at root + _json.anyOf = mime.map((m) => ({ contentMediaType: m })); // only contentMediaType differs } } else { Object.assign(_json, file); diff --git a/packages/zod/src/v4/core/to-json-schema.ts b/packages/zod/src/v4/core/to-json-schema.ts index fd1333ef27..f76587a89f 100644 --- a/packages/zod/src/v4/core/to-json-schema.ts +++ b/packages/zod/src/v4/core/to-json-schema.ts @@ -75,7 +75,10 @@ export interface Seen { /** Cycle path */ cycle?: (string | number)[] | undefined; isParent?: boolean | undefined; - ref?: schemas.$ZodType | undefined | null; + /** Schema to inherit JSON Schema properties from (set by processor for wrappers) */ + ref?: schemas.$ZodType | null; + /** Parent schema in the clone chain (for $ref propagation when parent is extracted) */ + parent?: schemas.$ZodType | undefined; /** JSON Schema property path for this schema */ path?: (string | number)[] | undefined; } @@ -172,14 +175,7 @@ export function process( path: _params.path, }; - const parent = schema._zod.parent as T; - - if (parent) { - // schema was cloned from another schema - result.ref = parent; - process(parent, ctx, params); - ctx.seen.get(parent)!.isParent = true; - } else if (schema._zod.processJSONSchema) { + if (schema._zod.processJSONSchema) { schema._zod.processJSONSchema(ctx, result.schema, params); } else { const _json = result.schema; @@ -189,6 +185,17 @@ export function process( } processor(schema, ctx, _json, params); } + + const parent = schema._zod.parent as T; + + if (parent) { + // Track parent separately from processor ref + result.parent = parent; + // Also set ref if processor didn't (for inheritance) + if (!result.ref) result.ref = parent; + process(parent, ctx, params); + ctx.seen.get(parent)!.isParent = true; + } } // metadata @@ -357,49 +364,89 @@ export function finalize( ctx: ToJSONSchemaContext, schema: T ): ZodStandardJSONSchemaPayload { - // - - // iterate over seen map; const root = ctx.seen.get(schema); - if (!root) throw new Error("Unprocessed schema. This is a bug in Zod."); - // flatten _refs + // flatten refs - inherit properties from parent schemas const flattenRef = (zodSchema: schemas.$ZodType) => { const seen = ctx.seen.get(zodSchema)!; - const schema = seen.def ?? seen.schema; - const _cached = { ...schema }; + // already processed + if (seen.ref === null) return; - // already seen - if (seen.ref === null) { - return; - } + const schema = seen.def ?? seen.schema; + const _cached = { ...schema }; - // flatten ref if defined const ref = seen.ref; - seen.ref = null; // prevent recursion + seen.ref = null; // prevent infinite recursion + if (ref) { flattenRef(ref); + const refSeen = ctx.seen.get(ref)!; + const refSchema = refSeen.schema; + // merge referenced schema into current - const refSchema = ctx.seen.get(ref)!.schema; if (refSchema.$ref && (ctx.target === "draft-07" || ctx.target === "draft-04" || ctx.target === "openapi-3.0")) { + // older drafts can't combine $ref with other properties schema.allOf = schema.allOf ?? []; schema.allOf.push(refSchema); } else { Object.assign(schema, refSchema); - Object.assign(schema, _cached); // prevent overwriting any fields in the original schema + } + // restore child's own properties (child wins) + Object.assign(schema, _cached); + + const isParentRef = (zodSchema as any)._zod.parent === ref; + + // For parent chain, child is a refinement - remove parent-only properties + if (isParentRef) { + for (const key in schema) { + if (key === "$ref" || key === "allOf") continue; + if (!(key in _cached)) { + delete schema[key]; + } + } + } + + // When ref was extracted to $defs, remove properties that match the definition + if (refSchema.$ref) { + for (const key in schema) { + if (key === "$ref" || key === "allOf") continue; + if (key in refSeen.def! && JSON.stringify(schema[key]) === JSON.stringify(refSeen.def![key])) { + delete schema[key]; + } + } + } + } + + // If parent was extracted (has $ref), propagate $ref to this schema + // This handles cases like: readonly().meta({id}).describe() + // where processor sets ref to innerType but parent should be referenced + if (seen.parent && seen.parent !== ref) { + // Ensure parent is processed first so its def has inherited properties + flattenRef(seen.parent); + const parentSeen = ctx.seen.get(seen.parent); + if (parentSeen?.schema.$ref) { + schema.$ref = parentSeen.schema.$ref; + // De-duplicate with parent's definition + if (parentSeen.def) { + for (const key in schema) { + if (key === "$ref" || key === "allOf") continue; + if (key in parentSeen.def && JSON.stringify(schema[key]) === JSON.stringify(parentSeen.def[key])) { + delete schema[key]; + } + } + } } } // execute overrides - if (!seen.isParent) - ctx.override({ - zodSchema: zodSchema as schemas.$ZodTypes, - jsonSchema: schema, - path: seen.path ?? [], - }); + ctx.override({ + zodSchema: zodSchema as schemas.$ZodTypes, + jsonSchema: schema, + path: seen.path ?? [], + }); }; for (const entry of [...ctx.seen.entries()].reverse()) { diff --git a/packages/zod/src/v4/mini/schemas.ts b/packages/zod/src/v4/mini/schemas.ts index e8002d259c..5e97735c8b 100644 --- a/packages/zod/src/v4/mini/schemas.ts +++ b/packages/zod/src/v4/mini/schemas.ts @@ -65,8 +65,8 @@ export const ZodMiniType: core.$constructor = /*@__PURE__*/ core.$c typeof ch === "function" ? { _zod: { check: ch, def: { check: "custom" }, onattach: [] } } : ch ), ], - } - // { parent: true } + }, + { parent: true } ); }; inst.refine = (check, params) => inst.check(refine(check, params)) as never; diff --git a/play.ts b/play.ts index dd03991d8c..802d300b6b 100644 --- a/play.ts +++ b/play.ts @@ -1,6 +1,19 @@ -import * as z from "zod/v4"; +import * as z from "./packages/zod/src/index.js"; -z; -z.unknown() - .refine((val) => typeof val === "number") - .parse(1); +// Test: metadata order matters? + +// Case 1: .meta() before .min() - reported as losing metadata +const schema1 = z.object({ + name: z.string().meta({ description: "first name" }).min(1), +}); + +// Case 2: .meta() after .min() - reported as working +const schema2 = z.object({ + name: z.string().min(1).meta({ description: "A user name" }), +}); + +console.log("Case 1 - .meta() before .min():"); +console.log(JSON.stringify(z.toJSONSchema(schema1), null, 2)); + +console.log("\nCase 2 - .meta() after .min():"); +console.log(JSON.stringify(z.toJSONSchema(schema2), null, 2));