Skip to content

Commit 411f6c6

Browse files
fix(v4): resolve stack overflow in toJSONSchema for recursive lazy with describe (#5797)
* fix(v4): resolve stack overflow in toJSONSchema for recursive lazy with describe When .describe() or .meta() is called on a recursive z.lazy() schema, the clone re-evaluates the getter on each recursion level, producing unbounded new schema objects. Walk up to the root lazy to reuse its cached innerType instead. Fixes #5777. * fix(v4): cache lazy innerType on def so clones share identity Move the recursion fix from the JSON-schema processor into the $ZodLazy constructor. Caching the resolved inner type on the shared `def` means all clones of a lazy (via .describe(), .meta(), etc.) resolve to the same inner instance, so cycle detection works for any consumer — not just toJSONSchema — and parsing no longer rebuilds a fresh subtree on every access through a clone. --------- Co-authored-by: Colin McDonnell <[email protected]>
1 parent f457edf commit 411f6c6

2 files changed

Lines changed: 23 additions & 8 deletions

File tree

packages/zod/src/v4/classic/tests/to-json-schema.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3029,3 +3029,18 @@ test("cycle detection - mutual recursion", () => {
30293029
Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.]
30303030
`);
30313031
});
3032+
3033+
test("recursive lazy with describe does not stack overflow", () => {
3034+
const NodeSchema: z.ZodType = z.lazy(() =>
3035+
z
3036+
.object({
3037+
value: z.string().describe("node value"),
3038+
children: z.array(NodeSchema.describe("child node")).optional().describe("child list"),
3039+
})
3040+
.describe("tree node")
3041+
);
3042+
3043+
const result = z.toJSONSchema(NodeSchema, { cycles: "ref", reused: "ref" });
3044+
expect(result).toBeDefined();
3045+
expect(result.$defs).toBeDefined();
3046+
});

packages/zod/src/v4/core/schemas.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4404,14 +4404,14 @@ export interface $ZodLazy<T extends SomeType = $ZodType> extends $ZodType {
44044404
export const $ZodLazy: core.$constructor<$ZodLazy> = /*@__PURE__*/ core.$constructor("$ZodLazy", (inst, def) => {
44054405
$ZodType.init(inst, def);
44064406

4407-
// let _innerType!: any;
4408-
// util.defineLazy(def, "getter", () => {
4409-
// if (!_innerType) {
4410-
// _innerType = def.getter();
4411-
// }
4412-
// return () => _innerType;
4413-
// });
4414-
util.defineLazy(inst._zod, "innerType", () => def.getter() as $ZodType);
4407+
// Cache the resolved inner type on the shared `def` so all clones of this
4408+
// lazy (e.g. via `.describe()`/`.meta()`) share the same inner instance,
4409+
// preserving identity for cycle detection on recursive schemas.
4410+
util.defineLazy(inst._zod, "innerType", () => {
4411+
const d = def as $ZodLazyDef & { _cachedInner?: $ZodType };
4412+
if (!d._cachedInner) d._cachedInner = def.getter() as $ZodType;
4413+
return d._cachedInner;
4414+
});
44154415
util.defineLazy(inst._zod, "pattern", () => inst._zod.innerType?._zod?.pattern);
44164416
util.defineLazy(inst._zod, "propValues", () => inst._zod.innerType?._zod?.propValues);
44174417
util.defineLazy(inst._zod, "optin", () => inst._zod.innerType?._zod?.optin ?? undefined);

0 commit comments

Comments
 (0)