Skip to content

finding(types): ten z.lazy exports in the zod node face rebuild their schema on every getter call, so the recursion point cannot be compared by identity #7918

Description

@claude

Filed unassigned by the os-dev seat while running the objectui#7581 measurement (session session_013uAaxiwgYDybsTNV9xwa1M, PR #7916). Grading and domain:* are the triage seat's. ⛔ Not repaired in #7581's PR — that card is a measurement.

Measured on origin/main fa7d66c4505d0fcc866fbecec1901c4990a80c3a

Ten exports of @object-ui/types/zod are z.lazy(() => …) whose getter constructs a new schema on every call:

ActionSchema · AppMenuItemSchema · FilterBuilderConditionSchema · FilterGroupSchema · MenuItemSchema · NavLinkSchema · NavigationItemSchema · NavigationMenuItemSchema · SchemaNodeSchema · TreeNodeSchema

The probe is one line and its result is unambiguous:

S._zod.def.getter() === S._zod.def.getter()   // false, for each of the ten

Control: the same comparison on a z.lazy whose getter returns a module-level constant answers true, so the reading is about these getters and not about z.lazy.

The two consequences

  1. Identity comparison is impossible. Anything that wants to ask "is this slot the node-recursion point?" — a schema walker, a designer's slot detector, a derivation that must treat the recursion point specially — cannot compare the resolved inner schema by reference. It has to compare the LAZY WRAPPER instead, which works only if the wrapper itself is the thing it holds. Measure the strict authoring face before building it: run catalog fixtures, docs json fences and apps through strict node schemas and count undeclared keys per component (first deliverable of the #5250 ruling) #7581's measurement script hit exactly this: naming a union arm by identity reported ActionSchema as "not exported by name" when it plainly is, and the script had to fall back to matching arms by their declared type literals.

  2. The schema is rebuilt on every parse. z.lazy in zod 4 calls the getter each time the schema is entered, so a document with N nodes reconstructs the whole recursive sub-schema N times. Not measured as a wall-clock cost here — the corpora are small — and it may well be irrelevant at authoring scale. Recording it so whoever prices the strict-face programme (finding: JSON doc snippets are checked by nothing — the ts/tsx gate cannot see them, and BaseSchema.passthrough() makes objectui validate accept arbitrary undeclared keys #5250) knows it is there, since a strict face parses more, not less.

Not claimed

That the ten are wrong. Memoising the getter (const inner = …; z.lazy(() => inner)) is the obvious shape and is what the other z.lazy sites already do, but whether the difference is deliberate — some of these are mutually recursive and the current spelling may be dodging a TDZ — has not been checked. That check is the card.


Generated by Claude Code

Activity

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

    domain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lanefindingpm:queuepriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions