perf(v4): eliminate dictionary-mode overhead via shadow-proto (no breaking changes)#2
Closed
gsoldevila wants to merge 1 commit intomainfrom
Closed
perf(v4): eliminate dictionary-mode overhead via shadow-proto (no breaking changes)#2gsoldevila wants to merge 1 commit intomainfrom
gsoldevila wants to merge 1 commit intomainfrom
Conversation
…aking changes) ## Problem Zod v4 schema instances accumulate too many own properties, pushing them past V8's fast-property threshold (~20–30) and into dictionary mode. Dictionary mode degrades property access by up to 24× (see issue colinhacks#5760). Two sources contribute: 1. The `// support prototype modifications` copy-loop in `$constructor` copies every method from `_.prototype` to each new instance — adding ~60 own props. 2. Classic `ZodType` init assigns ~30 builder methods as per-instance own props. ## Solution: shadow-proto design Introduce a hidden *internal* prototype layer between the user-visible `_.prototype` and the parent prototype chain. Library methods are placed there; the copy-loop continues to target only the user-visible `_.prototype`. ``` inst └── _.prototype ← user space (empty by default; copy-loop targets this) └── internalProto ← library space (_initProto writes here) └── Parent / Object.prototype ``` ### core.ts - Export `$internalProto` (a unique symbol) to identify each constructor's internal prototype from `schemas.ts`. - In `$constructor`, create `internalProto` for each constructor, wire `_.prototype → internalProto`, and expose it as `_[$internalProto]`. - The copy-loop is **unchanged** — but since `_.prototype` is now empty by default, it is a no-op unless a user explicitly adds methods there. ### classic/schemas.ts - Add `_protoInitMap` (WeakMap) and `_initProto` helper. - `_initProto` targets `inst._zod.constr[$internalProto]` (the internal proto), not `Object.getPrototypeOf(inst)` (the user-visible proto). - All 133 per-instance builder method assignments replaced with `_initProto` calls; parse-family closures stay per-instance for detached-usage safety. ### mini/schemas.ts - Same `_initProto` infrastructure added. - The 6 builder methods in `ZodMiniType` (`check`, `with`, `clone`, `brand`, `register`, `apply`) moved to the internal prototype. ## Key advantage over a simpler approach Unlike removing the copy-loop entirely (which breaks prototype augmentation), this design preserves the original contract: ```js // Still works — copy-loop propagates user extensions from _.prototype to instances z.ZodType.prototype.myHelper = function() { ... }; z.string().myHelper(); // ✓ ``` The copy-loop is only a no-op for *library* methods (they live on internalProto, which `Object.keys(_.prototype)` never enumerates). ## Results - `z.string()` own-property count: ~91 → ~18 (well below V8's threshold) - No breaking changes; all 3,589 tests pass including both `prototypes.test.ts` suites without modification - `z.ZodType.prototype` augmentation works exactly as before ## New test `fast-properties.test.ts` asserts: - Own-property count < 25 for common schemas - Builder methods are NOT own properties - Parse methods ARE own properties (detached usage must work) Closes: colinhacks#5760 Made-with: Cursor
Owner
Author
|
This PR was a showcase of the changes. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes schema instances being pushed past V8's fast-property threshold, causing ~24x slower property access (see colinhacks#5760).
Own-property count before/after this PR:
z.string()z.string().min(1).max(10).email()z.number()z.object({})Problem
Two sources contributed to the ~91 own properties per schema instance:
$constructorcopies every method from_.prototypeto each new instance, adding ~60 own properties.ZodType.initassigns ~30 builder methods as per-instance own properties.V8 switches from fast-property (inline-cache) to dictionary mode at ~20–30 own properties, degrading all property access by up to 24×.
Solution: shadow-proto
Introduce a hidden internal prototype layer between the user-visible
_.prototypeand the parent prototype. Library methods are placed oninternalProto; the copy-loop continues to target the empty_.prototype.core.ts
$internalProto(a unique Symbol) soschemas.tscan locate each constructor's internal prototype.$constructor, createinternalProto = Object.create(Parent.prototype), wire_.prototype → internalProtoviaObject.setPrototypeOf, and expose it as_[$internalProto].Object.keys(_.prototype)now returns[]by default, making it a no-op unless a user explicitly adds methods to_.prototype.classic/schemas.ts
_protoInitMap(WeakMap) and_initProtohelper that targetsinst._zod.constr[$internalProto]._initProtocalls.parse,safeParse,parseAsync,safeParseAsync, encode/decode variants) remain per-instance — they captureinstand must work when detached.mini/schemas.ts
_initProtoinfrastructure added.ZodMiniType(check,with,clone,brand,register,apply) moved to the internal prototype.Key advantage: no breaking changes
The original prototype-extension contract is fully preserved:
Because
_.prototypeis now empty by default, the copy-loop activates only when users add methods there — propagating them to new instances exactly as the original code did.Both
prototypes.test.tssuites (classic and mini) pass without modification.New test:
fast-properties.test.tsAsserts for common schemas that:
optional,nullable, etc.) are NOT own propertiesconst { parse } = schema; parse("x"))Comparison with alternative approach
This PR sits alongside
perf/shared-method-references(PR #1 in this fork), which takes a simpler approach: it removes the copy-loop and places methods directly on_.prototype. That version is slightly simpler but is a breaking change for code that augmentsz.ZodType.prototype. This PR achieves the same performance gain with zero breaking changes.perf/shared-method-references)zod-memory-optimization-v2)ZodType.prototypeaugmentation must target concrete type)z.ZodType.prototype.x = fn; z.string().x()fast-properties.test.ts)Test results
Related: colinhacks#5760
Made with Cursor