You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
toJSONSchema throws on a schema with both a refinement and a toJSONSchema override (4.3.0 regression) #6741
z.toJSONSchema throws a TypeError instead of honoring a schema's toJSONSchema override, when that schema also carries a refinement. Present in latest (4.6.7), regressed in 4.3.0.
TypeError: Cannot read properties of undefined (reading 'ref')
at flattenRef (zod/v4/core/to-json-schema.js:397:18)
at flattenRef (zod/v4/core/to-json-schema.js:444:13)
at finalize (zod/v4/core/to-json-schema.js:470:13)
at toJSONSchema (zod/v4/core/json-schema-processors.js:842:12)
My speculation on the cause/ patch that fixed it for us:
processSchema registers a schema's parent in ctx.seen only inside the else branch skipped whenever the override returns a value:
constoverrideSchema=schema._zod.toJSONSchema?.();if(overrideSchema){result.schema=overrideSchema;// parent never registered}else{/* ... */constparent=schema._zod.parent;if(parent){if(!result.ref)result.ref=parent;processSchema(parent,ctx,params);ctx.seen.get(parent).isParent=true;}}
finalize's flattenRef then recurses into that unregistered parent and dereferences without a guard:
constseen=ctx.seen.get(zodSchema);// already processedif(seen.ref===null)return;// throws when seen is undefined
...
flattenRef(parent);constparentSeen=ctx.seen.get(parent);if(parentSeen?.schema.$ref)// same map, already optional-chained here
Patched locally by adding conditional to walker code:
const flattenRef = (zodSchema) => {
const seen = ctx.seen.get(zodSchema);
- // already processed- if (seen.ref === null)+ // A schema with both a custom `toJSONSchema` override and a `parent` is never+ // registered in `seen` by `processSchema`, yet `flattenRef(parent)` below still+ // recurses into it. Treat an unregistered schema as nothing to flatten.+ if (seen === undefined || seen.ref === null)
return;
Happy to put up a PR if this actually turns out to be a regression, just give me a 👍🏻 and I'll get started.
I reproduced this on current main (19c40d2). There is one related case worth covering when fixing it: if the parent is also present elsewhere in the schema and has an id, it is already in ctx.seen, so an undefined guard alone still lets the parent's $ref leak into the custom override.
The child currently becomes { type: "integer", title: "override", $ref: "#/$defs/Parent" }, while Parent requires a string. It happens in both property orders. The custom schema should not inherit that reference.
A private candidate that records the custom override and skips only its parent-inheritance step covers both cases while still running the final override callback. The three new regression tests fail on the baseline and pass with that change. Local validation: 2,308 V4 runtime tests across 137 files, plus changed-file Biome and a standalone source TypeScript check. This used Node 22 and Vitest 4.0.18 with a source-alias test harness, not the repository's Node 24/Nub setup, so it is not a full upstream CI result.
Sharing this as a supplementary finding for your fix, rather than opening a competing PR. Investigation and tests were AI-assisted.
Reproduced on main. The cause is that a schema with an override and a parent never registers the parent in seen, so flattenRef throws
on the missing entry. A guard in flattenRef fixes the throw, but a parent with an id still leaks its $ref into the override (the case manav4u noted above), so I marked the override as already flattened instead (result.ref = null in processSchema).
Fix and regression test (both property orders, parent with an id): https://github.com/HuluWZ/zod/tree/fix-6741. I can't open a PR
because pull requests are limited to collaborators, so feel free to cherry-pick 899d78c.
Thanks for covering the parent-id case. One compatibility case is still worth checking in 899d78c2: setting result.ref = null also makes flattenRef return before the final options.override callback.
On your commit this prints 0, undefined; on its parent it prints 1, "final override". The same callback is skipped for a refined custom schema after the fix. A regression should keep that callback running while preventing parent-reference inheritance, rather than marking the whole schema already finalized.
Checked locally with Node 22 and a source-import harness, not upstream CI. Investigation was AI-assisted. Sharing this for your fix, not opening a competing PR.
Thanks, that was a real bug: result.ref = null made flattenRef return before the final options.override callback ran. I reproduced it (0 calls, no description). The custom schema is now flagged instead, and flattenRef skips only the parent-inheritance step. The callback runs once again, and the parent-with-id case still works. Your snippet is added to the regression test.
Activity
manav4u commentedon Nov 7, 2026
I reproduced this on current main (
19c40d2). There is one related case worth covering when fixing it: if the parent is also present elsewhere in the schema and has an id, it is already inctx.seen, so an undefined guard alone still lets the parent's$refleak into the custom override.The child currently becomes
{ type: "integer", title: "override", $ref: "#/$defs/Parent" }, whileParentrequires a string. It happens in both property orders. The custom schema should not inherit that reference.A private candidate that records the custom override and skips only its parent-inheritance step covers both cases while still running the final
overridecallback. The three new regression tests fail on the baseline and pass with that change. Local validation: 2,308 V4 runtime tests across 137 files, plus changed-file Biome and a standalone source TypeScript check. This used Node 22 and Vitest 4.0.18 with a source-alias test harness, not the repository's Node 24/Nub setup, so it is not a full upstream CI result.Sharing this as a supplementary finding for your fix, rather than opening a competing PR. Investigation and tests were AI-assisted.
HuluWZ commentedon Nov 9, 2026
Reproduced on main. The cause is that a schema with an override and a parent never registers the parent in seen, so flattenRef throws
on the missing entry. A guard in flattenRef fixes the throw, but a parent with an id still leaks its $ref into the override (the case manav4u noted above), so I marked the override as already flattened instead (result.ref = null in processSchema).
Fix and regression test (both property orders, parent with an id): https://github.com/HuluWZ/zod/tree/fix-6741. I can't open a PR
because pull requests are limited to collaborators, so feel free to cherry-pick 899d78c.
manav4u commentedon Nov 9, 2026
Thanks for covering the parent-id case. One compatibility case is still worth checking in
899d78c2: settingresult.ref = nullalso makesflattenRefreturn before the finaloptions.overridecallback.On your commit this prints
0, undefined; on its parent it prints1, "final override". The same callback is skipped for a refined custom schema after the fix. A regression should keep that callback running while preventing parent-reference inheritance, rather than marking the whole schema already finalized.Checked locally with Node 22 and a source-import harness, not upstream CI. Investigation was AI-assisted. Sharing this for your fix, not opening a competing PR.
HuluWZ commentedon Nov 9, 2026
Thanks, that was a real bug:
result.ref = nullmadeflattenRefreturn before the finaloptions.overridecallback ran. I reproduced it (0 calls, no description). The custom schema is now flagged instead, andflattenRefskips only the parent-inheritance step. The callback runs once again, and the parent-with-id case still works. Your snippet is added to the regression test.Both commits are on the branch: https://github.com/HuluWZ/zod/tree/fix-6741 (
899d78c2andcdb45448).