… other eight cannot (objectui#7918)
Ten `z.lazy` exports of the zod node face rebuild their schema on every getter
call, so `S._zod.def.getter() !== S._zod.def.getter()`. The card did not claim
that was wrong — it asked whether the spelling was dodging a temporal dead zone,
and that check is what this commit carries.
Each of the ten was rewritten in place to `const inner = <body>; z.lazy(() =>
inner)`, the package rebuilt, and the built barrel imported in a fresh process.
Eight refuse to load: seven name the very const being declared, and
`SchemaNodeSchema` names `BaseSchemaCore`, which `base.zod.ts` declares below it.
Their `z.lazy` is load-bearing and they keep the spelling they have. The two that
loaded clean are memoised here, using the shape the face's other `z.lazy` sites
already use — a getter returning a module-level constant:
FilterBuilderConditionSchema not recursive at all
NavigationItemSchema self-reference already deferred by the inner
`z.lazy(() => NavigationItemSchema)` on `children`
Two corrections to the finding came out of the check, both measured:
- The recursion point was already identity-comparable through the right handle.
zod 4.4.3 caches a lazy's inner type on `def._cachedInner` to preserve
"identity for cycle detection on recursive schemas", and `_zod.innerType`
reads that cache — stable for all ten, including the eight. What is unstable
is the public `.unwrap()`, which `ZodLazy` defines as `() =>
_zod.def.getter()`, going around the cache. Memoising is still worth doing
where it is free, because it makes `.unwrap()` honest.
- The "rebuilt on every parse" cost does not exist. The getter runs once per
lazy for the life of the process: one call during the first parse of a
13-node document, zero during the second. Wall clock agrees — 0.94x with
overlapping ranges over nine trials of 200 parses.
No accept/reject behaviour moves; a memoised getter changes schema identity, not
what is declared or admitted. The measurement, the eight ReferenceError
messages, the identity matrix and executable reproductions of both the TDZ
mechanism and the once-per-process getter are pinned in
`zod-lazy-getter-identity-7918.test.ts`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114Ytxr5sM1vdW19Y9WAx6E
Fixes #7918
The card asked for a check, not a fix: ten
z.lazyexports of the zod node face rebuild their schema on every getter call, and the open question was whether that spelling is buying a temporal-dead-zone dodge. It is, for eight of the ten. Two are memoised here; the other eight are deliberately untouched, and the check turned up two corrections to the finding that are worth more than the edit.The probe, reproduced, with its control
Run against the built barrel. The card's own positive control (a
z.lazywhose getter returns a module-level constant) had to hit before anyfalsecounted as a reading, and a negative control proves the probe discriminates.The card's claim holds exactly as written.
AppMenuItemSchema— the name triage could not locateIt exists, and the list really is ten. It has no declaration of its own:
index.zod.ts:46re-exportsapp.zod.ts'sMenuItemSchemaunder that name, while the barrel's ownMenuItemSchemais a different schema fromoverlay.zod.ts:186. Triage's sweep mappedMenuItemSchematoapp.zod.ts:184— that line isAppMenuItemSchema's source — andoverlay.zod.ts:186never surfaced, which is why one name looked missing. Verified by reference:AppMenuItemSchema === app.zod.MenuItemSchema,MenuItemSchema === overlay.zod.MenuItemSchema, and the two are not each other.The TDZ check — measured one schema at a time
Each of the ten was rewritten in place to the card's own naive memoisation (
const inner = BODY; z.lazy(() => inner)),@object-ui/typesrebuilt, and the built barrel imported in a fresh process. Each leg proved the mutation reached the source anddistbefore its result was read; the restore leg isgit checkout HEAD -- ABSOLUTE_PATHwithgit diff HEADempty and the marker gone fromdist.ActionSchemaactions: z.array(ActionSchema))ReferenceError: Cannot access 'ActionSchema' before initializationAppMenuItemSchemaReferenceError: Cannot access 'MenuItemSchema' before initializationFilterGroupSchemaconditionsunion)ReferenceError: Cannot access 'FilterGroupSchema' before initializationMenuItemSchemaReferenceError: Cannot access 'MenuItemSchema' before initializationNavLinkSchemaReferenceError: Cannot access 'NavLinkSchema' before initializationNavigationMenuItemSchemaReferenceError: Cannot access 'NavigationMenuItemSchema' before initializationSchemaNodeSchemaBaseSchemaCoreReferenceError: Cannot access 'BaseSchemaCore' before initializationTreeNodeSchemaReferenceError: Cannot access 'TreeNodeSchema' before initializationFilterBuilderConditionSchemaNavigationItemSchemaz.lazy(() => NavigationItemSchema)onchildrenNone of the ten is mutually recursive.
FilterGroupSchemanamesFilterBuilderConditionSchema, but not the reverse; the import graph across the six files is a clean DAG ontobase.zod.ts.⇒ For eight, the
z.lazyis load-bearing — it buys a TDZ dodge, exactly as the card suspected. They keep the spelling they have. The two changed use the shape the face's otherz.lazysites already use, a getter returning a module-level constant; no third spelling is introduced.Correction 1 — the recursion point was already comparable, through the right handle
zod@4.4.3caches a lazy's resolved inner type ondef._cachedInner, and its own source comment says why: to preserve "identity for cycle detection on recursive schemas"._zod.innerTypereads that cache and is stable for all ten, including the eight, and survives.describe()clones.What is unstable is the public accessor, because
ZodLazydefines it asinst.unwrap = () => inst._zod.def.getter()— straight around the cache. (ZodPromisespells its own as a stored field, which is why this is specific to lazy.)S._zod.def.getter()S.unwrap()S._zod.innerType⇒ A walker can recognise the recursion point today, for all ten, via
_zod.innerType. The #7581 false negative —ActionSchemareported "not exported by name" when it plainly is — was the wrong handle, not an unrecognisable schema. Memoising is still worth doing where it is free, because it makes the public.unwrap()honest.Correction 2 — consequence ② does not reproduce
The card recorded, explicitly unmeasured, that a document with N nodes reconstructs the recursive sub-schema N times. Measured, it does not: the getter runs once per lazy for the life of the process, via the same
_cachedInner— one call during the first parse of a 13-node document, zero during the second.Wall clock agrees.
NavigationItemSchemaover a 73-node document, memoised vs. not, medians of nine trials of 200 parses each:Verification
Union re-run at
e496bdcda, the final commit:pnpm --filter @object-ui/types build—✓ dist completeness: 1 package(s) complete (124 emitted files verified)pnpm exec vitest run --project unit packages/types/— 137 files, 2588 tests passed (2576 before; the 12 new are this card's). All 137 test files in the package ran,zod-mirror-parity.test.tsamong them.pnpm --filter @object-ui/types type-check— clean (tsc --noEmit,tsconfig.examples.json,tsconfig.test.json). The new test is confirmed in thetsconfig.test.jsonprogram via--listFiles, so its@ts-expect-errorwas really checked.pnpm --filter @object-ui/types lint— 0 errors, 272 warnings, all pre-existingno-explicit-anyonz.ZodTypeany-parameter annotations this PR does not add to (count unchanged vs. base:app.zod.ts3,complex.zod.ts2).check:control-bytes,check:spec-symbols,check:side-effects-array,check:unreferenced-sources,check:esm-specifiers— all exit 0.Repo-scale scans (
pnpm lintover the whole tree,check:eager-closure, which needs a console bundle) are left to CI, which runs the farm exactly once regardless.Scope
zod-mirror-parity.test.ts; this one toucheszod/*.zod.tsand adds a new test file. The full mirror-parity suite passes here, so no mirror-pair count moved.NavigationItemSchema'ssuperRefinestill refusing a label-less nested child.Left open deliberately
The eight could be memoised by hoisting each body to a module const and pushing the self-reference behind an inner
z.lazy(() => X)— the shapeNavigationItemSchema.childrenalready uses. Not taken here: it trades one identity for another, sincechildrenis currentlyz.array(TreeNodeSchema), whose element is the exported schema, and the rewrite replaces that element with a fresh anonymous wrapper. With consequence ② disproved and_zod.innerTypealready stable, the only remaining prize is public.unwrap()identity. That is a maintainer call, not a cleanup, and it is recorded in the test file's header rather than acted on.Generated by Claude Code