Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/zod/src/v4/classic/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,10 @@ export const ZodType: core.$constructor<ZodType> = /*@__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);
Expand Down
58 changes: 47 additions & 11 deletions packages/zod/src/v4/classic/tests/to-json-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1720,7 +1720,7 @@ test("override: do not run on references", () => {
},
});

expect(overrideCount).toBe(6);
expect(overrideCount).toBe(12);
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The override count expectation changed from 6 to 12. This change reflects that the override function now runs on parent schemas as well, which were previously skipped (by checking !seen.isParent). This doubles the count because each schema in the chain is now visited. While this aligns with the code change in the finalize function (removing the isParent check), consider adding a comment explaining why this number doubled to make the test more maintainable and document the expected behavior.

Copilot uses AI. Check for mistakes.
});

test("override with refs", () => {
Expand Down Expand Up @@ -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" });

Expand Down Expand Up @@ -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",
}
`);
});
Expand Down
6 changes: 2 additions & 4 deletions packages/zod/src/v4/core/json-schema-processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,10 +234,8 @@ export const fileProcessor: Processor<schemas.$ZodFile> = (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);
Expand Down
107 changes: 77 additions & 30 deletions packages/zod/src/v4/core/to-json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -172,14 +175,7 @@ export function process<T extends schemas.$ZodType>(
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;
Expand All @@ -189,6 +185,17 @@ export function process<T extends schemas.$ZodType>(
}
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
Expand Down Expand Up @@ -357,49 +364,89 @@ export function finalize<T extends schemas.$ZodType>(
ctx: ToJSONSchemaContext,
schema: T
): ZodStandardJSONSchemaPayload<T> {
//

// 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using (zodSchema as any)._zod.parent bypasses type safety. Since zodSchema is typed as schemas.$ZodType, consider adding parent to the $ZodType interface if it's a supported property, or use a type guard.


// 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])) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.stringify comparison for equality is O(n) and can be slow for deeply nested schemas. It's also order-dependent for object properties. If this path is hot, consider a shallow key comparison or structural equality check. That said, this is finalization code that runs once per schema, so it may be acceptable.

Comment on lines +413 to +416
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential runtime error: The non-null assertion on refSeen.def! is unsafe. This code path is reached when refSchema.$ref exists (line 413). However, $ref can be set on a schema in two ways: (1) during extractToDef which also sets def, or (2) at line 431 which propagates $ref from a parent without setting def.

If schema A's parent B was extracted (has $ref and def), and A itself was not extracted, then line 431 sets A.schema.$ref = B.schema.$ref but A.def remains undefined. If another schema C references A, when processing C's ref (A) at line 413-420, the condition refSchema.$ref will be true, but refSeen.def will be undefined, causing a runtime error.

Consider checking if refSeen.def exists before accessing it, or ensuring that when $ref is propagated at line 431, the def is also set appropriately.

Suggested change
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])) {
if (refSchema.$ref && refSeen.def) {
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])) {

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@brunolemos brunolemos Jan 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@colinhacks I'm getting this exact error when trying to upgrade from 4.2.1 to 4.3.5:

node_modules/.pnpm/zod@4.3.5/node_modules/zod/v4/core/to-json-schema.cjs:267
                    if (key in refSeen.def && JSON.stringify(schema[key]) === JSON.stringify(refSeen.def[key])) {
                            ^
TypeError: Cannot use 'in' operator to search for 'id' in undefined
    at flattenRef (node_modules/.pnpm/zod@4.3.5/node_modules/zod/v4/core/to-json-schema.cjs:267:29)

Maybe it should have the safety checks suggested by copilot?
I can confirm adding refSeen.def && in the beginning of line 416 fixed the issue for me.

PR: #5644

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({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The !seen.isParent guard was removed, doubling the overrideCount in tests. Is this intentional? If users were relying on overrides not being called for parent schemas (e.g., to avoid double-processing), this is a breaking change.

zodSchema: zodSchema as schemas.$ZodTypes,
jsonSchema: schema,
path: seen.path ?? [],
});
};

for (const entry of [...ctx.seen.entries()].reverse()) {
Expand Down
4 changes: 2 additions & 2 deletions packages/zod/src/v4/mini/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ export const ZodMiniType: core.$constructor<ZodMiniType> = /*@__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;
Expand Down
23 changes: 18 additions & 5 deletions play.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import * as z from "zod/v4";
import * as z from "./packages/zod/src/index.js";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like debug/exploration code that shouldn't be committed. Consider reverting this file or adding it to .gitignore.


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));