fix: skip non-enumerable properties in record validation#5719
Conversation
z.record() was iterating over non-enumerable properties via Reflect.ownKeys(), causing z.json() and z.record() to reject objects that have non-enumerable properties like the ~standard property added by z.toJSONSchema() since v4.2.0. This adds a propertyIsEnumerable check to skip non-enumerable properties during record key iteration, aligning record behavior with z.object() which already uses for...in (enumerable only). Fixes colinhacks#5714
There was a problem hiding this comment.
Low urgency. The fix is correct and well-scoped. propertyIsEnumerable is the right check — it handles both string and Symbol keys correctly, and aligns z.record() with z.object() (which uses for...in, inherently enumerable-only). Tests pass. Two minor suggestions below.
| test("z.json() should accept objects with non-enumerable properties", () => { | ||
| const schema = z.json(); | ||
|
|
||
| const input = { name: "test", count: 42 }; | ||
| Object.defineProperty(input, "~standard", { | ||
| value: { | ||
| validate: () => {}, | ||
| vendor: "zod", | ||
| version: 1, | ||
| jsonSchema: { | ||
| input: () => {}, | ||
| output: () => {}, | ||
| }, | ||
| }, | ||
| enumerable: false, | ||
| configurable: false, | ||
| }); | ||
|
|
||
| const result = schema.safeParse(input); | ||
| expect(result.success).toBe(true); | ||
| }); |
There was a problem hiding this comment.
This test manually constructs a ~standard-like property, but the bug report is specifically about z.toJSONSchema() output failing z.json() validation. Consider replacing (or supplementing) this with the actual roundtrip from the issue:
test("z.json() accepts z.toJSONSchema() output", () => {
const schema = z.object({ name: z.string() });
const jsonSchema = z.toJSONSchema(schema);
expect(z.json().safeParse(jsonSchema).success).toBe(true);
});This directly tests the reported scenario and would catch regressions without relying on implementation details of how ~standard is structured.
There was a problem hiding this comment.
Great suggestion, added that roundtrip test. It directly covers the reported scenario which is much better than manually constructing the property.
| payload.value = {}; | ||
| for (const key of Reflect.ownKeys(input)) { | ||
| if (key === "__proto__") continue; | ||
| // skip non-enumerable properties (e.g. ~standard from Standard Schema protocol) |
There was a problem hiding this comment.
Nit: the comment references a specific origin (~standard from Standard Schema protocol) which makes this feel like a targeted workaround rather than a general semantic decision. Something like // skip non-enumerable properties (consistent with for...in used elsewhere) would better convey why this is correct in general, not just for one property.
There was a problem hiding this comment.
Good call -- updated the comment to reference the for...in consistency rationale instead. Makes it clearer this isn't a one-off workaround.
Review submitted for PR #5719Verdict: Approve with minor suggestions The fix is correct — Two minor suggestions:
|
- Update comment to reference for...in consistency rather than ~standard specifically - Add z.toJSONSchema() -> z.json() roundtrip test covering the reported scenario Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Thanks for the review @pullfrog! The suggestions make sense — I'll get those addressed in the next push. |
|
@pullfrog rereview fresh. |
There was a problem hiding this comment.
Reviewed — no issues found.
Task list (4/4 completed)
- Checkout PR and read diff
- Investigate each area of change in depth
- Self-critique and finalize comments
- Submit review
Claude Opus | 𝕏
- Note Symbol-key intent on the Reflect.ownKeys loop in $ZodRecord. - Rename misleading test to describe the actual assertion (enumerable invalid value still fails when a non-enumerable property is present). - Drop redundant manual ~standard test; the z.toJSONSchema() roundtrip is a stronger end-to-end check of the reported scenario. - Add a Symbol-key test asserting enumerable Symbol keys are validated and non-enumerable Symbol keys are skipped, locking in why Reflect.ownKeys is used here.
|
TL;DR — Key changes
Summary | 2 files | 4 commits | base:
The core fix is a single-line addition in the
|
|
Merged. I pushed a follow-up ( Note: this comment was produced by an AI coding assistant. |
|
Landed in Zod 4.4 |
Fixes #5714
Problem
z.record()usesReflect.ownKeys()to iterate over input properties, which includes non-enumerable properties. This causesz.json()andz.record()to reject objects that have non-enumerable properties like the~standardproperty added byz.toJSONSchema()since v4.2.0.For example:
The
~standardproperty is non-enumerable and contains functions, so whenz.record(z.string(), z.json())(used internally byz.json()) encounters it, it tries to validate function values against the json schema and fails.Fix
Added a
propertyIsEnumerablecheck in the record validation loop to skip non-enumerable properties. This alignsz.record()withz.object(), which already usesfor...in(enumerable-only iteration).The fix preserves Symbol key support (via
Reflect.ownKeys) while filtering out non-enumerable properties that shouldn't participate in validation.Tests
Added three tests:
z.json()accepts objects with non-enumerable~standardpropertiesAll existing tests pass (3580 tests).