Skip to content

fix: skip non-enumerable properties in record validation#5719

Merged
colinhacks merged 4 commits into
colinhacks:mainfrom
veeceey:fix/issue-5714-record-non-enumerable
Apr 28, 2026
Merged

fix: skip non-enumerable properties in record validation#5719
colinhacks merged 4 commits into
colinhacks:mainfrom
veeceey:fix/issue-5714-record-non-enumerable

Conversation

@veeceey

@veeceey veeceey commented Feb 15, 2026

Copy link
Copy Markdown
Contributor

Fixes #5714

Problem

z.record() uses Reflect.ownKeys() to iterate over input properties, which includes non-enumerable properties. This causes 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.

For example:

const schema = z.object({ name: z.string() });
const jsonSchema = z.toJSONSchema(schema);

z.json().safeParse(jsonSchema).success; // false -- should be true

The ~standard property is non-enumerable and contains functions, so when z.record(z.string(), z.json()) (used internally by z.json()) encounters it, it tries to validate function values against the json schema and fails.

Fix

Added a propertyIsEnumerable check in the record validation loop to skip non-enumerable properties. This aligns z.record() with z.object(), which already uses for...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:

  • Record skips non-enumerable properties during validation
  • Record still validates enumerable properties correctly
  • z.json() accepts objects with non-enumerable ~standard properties

All existing tests pass (3580 tests).

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment on lines +101 to +121
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);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion, added that roundtrip test. It directly covers the reported scenario which is much better than manually constructing the property.

Comment thread packages/zod/src/v4/core/schemas.ts Outdated
payload.value = {};
for (const key of Reflect.ownKeys(input)) {
if (key === "__proto__") continue;
// skip non-enumerable properties (e.g. ~standard from Standard Schema protocol)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call -- updated the comment to reference the for...in consistency rationale instead. Makes it clearer this isn't a one-off workaround.

@pullfrog

pullfrog Bot commented Feb 15, 2026

Copy link
Copy Markdown
Contributor

Review submitted for PR #5719

Verdict: Approve with minor suggestions

The fix is correct — propertyIsEnumerable is the right check to skip non-enumerable properties in z.record() validation, aligning it with z.object() (which uses for...in, inherently enumerable-only). Tests pass cleanly.

Two minor suggestions:

  1. Test the actual reported scenario — the third test manually constructs a ~standard-like property, but a z.toJSONSchema()z.json() roundtrip test would directly cover the bug report
  2. Generalize the comment — reference the semantic rationale (consistency with for...in) rather than tying it to ~standard specifically

Review link

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

- 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>
@veeceey

veeceey commented Feb 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review @pullfrog! The suggestions make sense — I'll get those addressed in the next push.

@colinhacks

Copy link
Copy Markdown
Owner

@pullfrog rereview fresh.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Pullfrog  | View workflow run | Using 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.
@pullfrog

pullfrog Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

TL;DRz.record() now skips non-enumerable properties during validation, fixing a regression where z.json().safeParse() rejected objects carrying the non-enumerable ~standard property added by z.toJSONSchema() since v4.2.0 (closes #5714).

Key changes

  • Skip non-enumerable properties in $ZodRecord validation — adds a propertyIsEnumerable guard inside the Reflect.ownKeys loop so record schemas only validate enumerable entries, consistent with z.object()'s for...in iteration.
  • Test coverage for non-enumerable and Symbol-key edge cases — five new tests covering skipped non-enumerable string keys, non-enumerable Symbol keys, enumerable validation still failing as expected, and the z.json()z.toJSONSchema() roundtrip from the original issue.

Summary | 2 files | 4 commits | base: mainfix/issue-5714-record-non-enumerable


Before: Reflect.ownKeys iterated all own properties — including non-enumerable ones like ~standard — causing record validation to attempt validating function values and fail.
After: A propertyIsEnumerable check skips non-enumerable properties, aligning z.record() with z.object()'s enumerable-only semantics.

The core fix is a single-line addition in the $ZodRecord constructor's validation loop. Reflect.ownKeys is retained (rather than switching to for...in) so that enumerable Symbol keys continue to be validated. The propertyIsEnumerable call filters out any property whose descriptor has enumerable: false, which includes the ~standard metadata property injected by the Standard Schema interface.

Why not switch to for...in instead? for...in only visits string-keyed enumerable properties. z.record() supports Symbol keys (e.g. z.record(z.symbol(), z.string())), so the implementation must use Reflect.ownKeys — which returns both string and Symbol own keys — and manually filter non-enumerable entries.

schemas.ts · record-constructor.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

@colinhacks colinhacks merged commit 7f789de into colinhacks:main Apr 28, 2026
4 of 6 checks passed
@colinhacks

Copy link
Copy Markdown
Owner

Merged. I pushed a follow-up (09e6304a) on top: extended coverage to Symbol keys (enumerable Symbol keys still validate, non-enumerable ones are skipped) so the reason z.record() uses Reflect.ownKeys + propertyIsEnumerable rather than for...in is locked into the suite, and dropped the standalone manual ~standard test since the z.toJSONSchema()z.json() roundtrip already covers the original scenario. Closes #5714, thanks for tracking this down 👍

Note: this comment was produced by an AI coding assistant.

@colinhacks

Copy link
Copy Markdown
Owner

Landed in Zod 4.4

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.

z.toJSONSchema() output has non-enumerable ~standard property that breaks z.json() validation

2 participants