Skip to content

fix(zod-v3): rebuild survives a getter-only _def - #637

Merged
ozzyfromspace merged 2 commits into
mainfrom
fix/zod45-getter-only-def
Sep 15, 2026
Merged

ozzyfromspace merged 2 commits into
mainfrom
fix/zod45-getter-only-def

Conversation

@ozzyfromspace

Copy link
Copy Markdown
Contributor

Found while triaging Dependabot #630. That PR bumps apps/bench-arena's zod, which has nothing to do with the v3 adapter, yet it turned test/adapters/zod-v3/rebuild-schema.test.ts red:

TypeError: Cannot set property _def of #<_> which has only a getter
  ❯ rebuildWithDef src/runtime/adapters/zod-v3/rebuild-schema.ts:54:8

The bump is being closed separately. The TypeError is real and lands here.

What broke

rebuild-schema.ts exists so the adapter never rebuilds a node through an ambient constructor. Its own docblock names the hazard it defends against: "a hoisted Zod v4 beside the Zod v3 a schema was authored with". rebuildWithDef puts the replacement on the original's prototype and patches _def:

const node = Object.create(Object.getPrototypeOf(original))
node._def = { ...original._def, ...defPatch }

Zod 4.5 moved _def from a writable own property to a getter-only accessor on the prototype. Measured across the three versions in play:

version _def depth plain assign on the clone
3.25.76 data, writable own ok
4.4.3 data, non-writable own ok (clone has no own _def)
4.5.4 getter, no setter prototype TypeError

Assigning through an inherited setter-less accessor throws under the strict mode every module runs in. So the helper written to survive a foreign-realm node threw a raw TypeError on exactly that node, which is a library throw reaching consumer code.

The fix

Define the own data property instead of assigning it. That shadows the accessor and lands on both realms. The flags reproduce what the assignment produced on v3 (writable, enumerable, configurable all true, verified against a real v3 node), so the intended v3 path is byte-for-byte unchanged.

Why it got in

The existing realm-faithfulness test does exercise this: it hands rebuildObject a zod/v4 node on purpose. But it only bites when the hoisted Zod is 4.5+, and the lockfile pins root zod at 4.4.3, where it passes vacuously. ^4.3.6 floating to 4.5.x is one pnpm update away, so the trap is armed.

The new pin builds the getter-only shape by hand rather than importing a Zod, so it bites whatever the tree resolves. Verified both ways: with the fix reverted it fails with the same TypeError CI produced; with the fix it passes on the current 4.4.3 lockfile, where the old test cannot.

Gates

pnpm typecheck and pnpm lint clean; test/adapters/zod-v3/ 23 files / 337 tests green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PnAVwFjpvKQNppAMkSvoiH

@vercel

vercel Bot commented Sep 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
attaform Ready Ready Preview Sep 15, 2026 4:02am UTC

`rebuildWithDef` created the replacement node with
`Object.create(getPrototypeOf(original))` and then assigned
`node._def = {...}`. That assignment is the whole point of the
module: rebuild a node on its OWN prototype so a mismatched
second Zod hoisted into the tree cannot poison the result.

Zod 4.5 moved `_def` from a writable own property to a
getter-only accessor on the prototype. Assigning through an
inherited accessor with no setter throws under the strict mode
every module runs in, so the helper written to survive a
foreign-realm node threw a raw TypeError on exactly that node:

  TypeError: Cannot set property _def of #<_>
  which has only a getter

Defining an own data property shadows the accessor instead, and
lands on both realms. The flags reproduce what the assignment
produced on Zod v3 (writable, enumerable, configurable), so the
intended v3 path is unchanged.

The existing realm-faithfulness test does cover this, but only
when the hoisted Zod happens to be 4.5 or newer; the lockfile
pins 4.4.3, where it passes vacuously. That is how the
regression got in. The new pin builds the getter-only shape by
hand, so it bites whatever version the tree resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnAVwFjpvKQNppAMkSvoiH
Folds the `_def` definition into the `Object.create` that
already builds the node, instead of creating the node and then
calling `Object.defineProperty` on it.

Same semantics, one expression instead of two, and 14 bytes
cheaper gzipped, which matters here: dist/index.mjs and
dist/zod.mjs sit 40 B under a 53.5 KB cap on main, and the
two-statement form landed 3 B over it.

  main            53460 B   40 B headroom
  defineProperty  53503 B   OVER by 3
  this            53489 B   11 B headroom

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnAVwFjpvKQNppAMkSvoiH
@ozzyfromspace
ozzyfromspace force-pushed the fix/zod45-getter-only-def branch from 6e9521b to 8b6ec0d Compare September 15, 2026 03:57
@ozzyfromspace
ozzyfromspace merged commit cb369d5 into main Sep 15, 2026
15 checks passed
@ozzyfromspace
ozzyfromspace deleted the fix/zod45-getter-only-def branch September 15, 2026 04:03

This branch was successfully deployed

1 active deployment
Preview 8b6ec0d2 Deployed Sep 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant