Improve metadata tracking across child-parent relationships#5578
Improve metadata tracking across child-parent relationships#5578colinhacks merged 1 commit intomainfrom
Conversation
There was a problem hiding this comment.
Low urgency — The approach addresses a real pain point (metadata lost when .meta() precedes refinements like .min()). The implementation is sound, though I have some concerns about edge cases in the deduplication logic and whether the override behavior change is intentional.
The play.ts file changes appear to be debug scaffolding that should not be merged.
| @@ -1,6 +1,19 @@ | |||
| import * as z from "zod/v4"; | |||
| import * as z from "./packages/zod/src/index.js"; | |||
There was a problem hiding this comment.
This looks like debug/exploration code that shouldn't be committed. Consider reverting this file or adding it to .gitignore.
| 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])) { |
There was a problem hiding this comment.
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.
| jsonSchema: schema, | ||
| path: seen.path ?? [], | ||
| }); | ||
| ctx.override({ |
There was a problem hiding this comment.
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.
| // restore child's own properties (child wins) | ||
| Object.assign(schema, _cached); | ||
|
|
||
| const isParentRef = (zodSchema as any)._zod.parent === ref; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Pull request overview
This PR improves metadata tracking across child-parent schema relationships in Zod's JSON Schema generation. The main issue addressed is that metadata (like id and custom properties) was being lost when schemas were chained together, particularly when .meta() was called before constraint methods like .min().
Key Changes:
- Enabled parent tracking in schema cloning by passing
{ parent: true }when.check()is called - Separated
parenttracking fromreftracking in the JSON Schema generation process to handle cases where processors set a different ref than the parent chain - Added logic to propagate
$reffrom extracted parent schemas to their children
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| play.ts | Test file demonstrating the metadata ordering issue being fixed |
| packages/zod/src/v4/mini/schemas.ts | Added { parent: true } parameter to clone calls in .check() method |
| packages/zod/src/v4/classic/schemas.ts | Added { parent: true } parameter to clone calls in .check() method |
| packages/zod/src/v4/core/to-json-schema.ts | Core changes: added parent field to Seen interface, moved parent processing after processor execution, enhanced flattening logic to handle parent-child ref propagation |
| packages/zod/src/v4/core/json-schema-processors.ts | Optimized file processor to avoid duplicating common properties in anyOf branches |
| packages/zod/src/v4/classic/tests/to-json-schema.test.ts | Updated test expectations for override count (now runs on all schemas), added test for wrapper with id, updated file processor test expectations |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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])) { |
There was a problem hiding this comment.
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.
| 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])) { |
There was a problem hiding this comment.
@colinhacks I'm getting this exact error when trying to upgrade from 4.2.1 to 4.3.5:
node_modules/.pnpm/[email protected]/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/[email protected]/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
| }); | ||
|
|
||
| expect(overrideCount).toBe(6); | ||
| expect(overrideCount).toBe(12); |
There was a problem hiding this comment.
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.
Fixes #5578