diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts index e21879438..9fd1d3ce1 100644 --- a/packages/zod/src/v4/core/schemas.ts +++ b/packages/zod/src/v4/core/schemas.ts @@ -1903,6 +1903,8 @@ export const $ZodObjectJIT: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$ // requires cast because technically $ZodObject doesn't extend $ZodObject.init(inst, def); + const visitedNodes = new Set(); + const superParse = inst._zod.parse; const _normalized = util.cached(() => normalizeDef(def)); @@ -1979,6 +1981,15 @@ export const $ZodObjectJIT: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$ return payload; } + if (visitedNodes.has(input)) { + return { + value: input, + issues: [], + }; + } + + visitedNodes.add(input); + if (jit && fastEnabled && ctx?.async === false && ctx.jitless !== true) { // always synchronous if (!fastpass) fastpass = generateFastpass(def.shape); @@ -1990,6 +2001,8 @@ export const $ZodObjectJIT: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$ return superParse(payload, ctx); }; + + visitedNodes.clear(); } ); diff --git a/packages/zod/src/v4/core/tests/circular-object.test.ts b/packages/zod/src/v4/core/tests/circular-object.test.ts new file mode 100644 index 000000000..8c1be69eb --- /dev/null +++ b/packages/zod/src/v4/core/tests/circular-object.test.ts @@ -0,0 +1,40 @@ +import { test, expect } from "vitest"; +import * as z from "zod/v4"; + +test("validates circular objects", () => { + const userSchema = z.object({ + id: z.number(), + get posts() { + return z.array(postSchema); + }, + }); + + const postSchema = z.object({ + title: z.string(), + get author() { + return userSchema; + }, + }); + + const user = { + id: 1, + posts: [ + { + title: "First", + get author() { + return user; + }, + }, + ], + }; + + expect(userSchema.parse(user)).toEqual({ + id: 1, + posts: [ + { + title: "First", + author: user, + }, + ], + }); +});