Skip to content

toJSONSchema throws on a schema with both a refinement and a toJSONSchema override (4.3.0 regression) #6741

Open

Description

@zfralish

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.

I reproduced this with the following:

import * as z from "zod"
const s = z.string().min(1)
s._zod.toJSONSchema = () => ({ type: "string", title: "x" })
z.toJSONSchema(s)
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:

const overrideSchema = schema._zod.toJSONSchema?.();
if (overrideSchema) {
   result.schema = overrideSchema;          // parent never registered
}
else {
   /* ... */
   const parent = 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:

const seen = ctx.seen.get(zodSchema);
// already processed
if (seen.ref === null) return;             // throws when seen is undefined
...
flattenRef(parent);
const parentSeen = 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.

Activity

  1. manav4u commented on Nov 7, 2026

    @manav4u

    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.

    const parent = z.string().meta({ id: "Parent" });
    const child = parent.min(1);
    child._zod.toJSONSchema = () => ({ type: "integer", title: "override" });
    
    console.log(z.toJSONSchema(z.object({ parent, child })));

    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.

  2. HuluWZ commented on Nov 9, 2026

    @HuluWZ

    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.

  3. manav4u commented on Nov 9, 2026

    @manav4u

    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.

    const schema = z.string();
    schema._zod.toJSONSchema = () => ({ type: "integer" });
    let calls = 0;
    const result = z.toJSONSchema(schema, {
      override: ({ jsonSchema }) => {
        calls++;
        jsonSchema.description = "final override";
      },
    });
    console.log(calls, result.description);

    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.

  4. HuluWZ commented on Nov 9, 2026

    @HuluWZ

    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.

    Both commits are on the branch: https://github.com/HuluWZ/zod/tree/fix-6741 (899d78c2 and cdb45448).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions