feat(v4): make z.properties() a schema, and give z.instanceof() a .properties() method (#6536)
* feat(v4): make z.properties() a schema, and give z.instanceof() a .properties() method
z.properties(shape) now constructs a $ZodProperties schema instead of an array
of z.property() checks. It asserts the named properties in place and returns
its input untouched — no new object, so prototypes, unlisted keys and object
identity survive, which z.object() cannot offer. Child results are discarded
rather than written back, exactly as z.property() behaves: identity cannot
distinguish a transform from a nested object schema rebuilding its output.
The schema is also a check ($ZodCustom-style dual trait), and it yields itself
from Symbol.iterator, so the 4.5 call sites keep working:
z.instanceof(URL).check(...z.properties({ ... }))
The spread now aggregates issue paths across keys — one check iterates the
whole shape — where the old array stopped at the first failing property.
z.instanceof() returns a dedicated ZodInstanceOf class whose .properties()
narrows the inferred type: z.instanceof(URL).properties({ protocol:
z.literal("https:") }) infers URL & { protocol: "https:" }. Intersection works
too: z.instanceof(URL).and(z.properties({ ... })).
Wiring: compile.ts fast-path cases for both roles, a JSON Schema processor
(object + properties + required, no additionalProperties), memoizer and visit
cases, and symbol-key support in util.members so Symbol.iterator installs on
the per-constructor prototype. z.core._properties changes signature to
(Class, shape, params).
Mini bundle ceilings rise by the measured cost (+15/+17/+17 gz), from the
symbol loop in util.members that every bundle carries.
* fix(v4): close the review findings on z.properties
- Infer the INPUT side on both sides of $ZodProperties, and in the type
z.instanceof().properties() intersects. The shape is asserted and its results
discarded, so an output-typed inference lied for every entry whose output
differs from its input: z.properties({ a: z.string().default("x") }) claimed
{ a: string } and returned {}. A plain schema has identical input and output,
so the documented URL & { protocol: "https:" } narrowing is unchanged.
- Guard the compiled property read against a nullish value. z.any() and friends
reach the check role with one, where the runtime reports invalid_type and the
generated code threw a TypeError. The guard now lives in the shared helper, so
both roles carry it.
- Refuse to compile a custom `when`, matching the check role. The schema role
ran the shape unconditionally, and a union branch compiles to its own IIFE, so
a wrongly-rejected branch is absorbed as a branch miss and a later one wins:
z.compile(z.union([properties(..., { when: () => false }), obj])) returned
different data from the interpreter, both reporting success.
- Walk the shape with Reflect.ownKeys in the runtime, the compiler and visit, so
a symbol-keyed entry is validated. z.object() has done this since #6448, and
this PR's own memoizer case already did.
- Move the visit case below template_literal, where the leaf comment above it
belongs.
* fix(v4): give z.properties cycle support, and stop two silent divergences
Cyclic input stack-overflowed where z.object() parses it. Two causes:
The shape loop ran from _zod.check, which gets no parse context, and the
memoizer keys its cycle state on that context object — so every child run
started from empty state and the cycle was never seen. $ZodType.init prepends
an instance that already carries the $ZodCheck trait to its own checks, which
is what forced the loop there; initializing the check trait after it keeps the
schema role in parse, where the context arrives. The input is its own output
here, so it registers as its own memo entry and a re-entry hits the bucket.
Separately, every classic container calls _ensureDefaultMemoizer() before the
core init, so the core reads a populated globalConfig.memoizer. ZodProperties
did not, so the cycle wrapper was never installed at all.
Symbol keys were dropped from the JSON Schema output without a word, emitting
a schema that asserts less than this one does. z.object() reports them as
unrepresentable; so does this now, which matters more since these keys began
validating in the previous commit.
`when` gates a check inside the run loop, so it means nothing to a parsed
schema — it was honored in the check role and silently inert in the schema
role. It is no longer publicly settable. Compiling still refuses a def that
carries one, since a hand-built def can, and a union branch compiles to its
own IIFE where a wrong rejection is absorbed with no fallback.
* fix(v4): keep z.properties forward while encoding, and track callable cycles
Both sides of $ZodProperties declare the shape's input type, so the assertion
is forward in either direction. Threading the parse context through for cycle
support also carried the backward direction into every child, which encoded
them and rejected the type this schema claims to take:
const codec = z.codec(z.string(), z.number(), { ... });
z.encode(z.properties({ a: codec }), { a: "1" }); // invalid_type at ["a"]
Property reads work on a callable as well, so a function is a value a cycle
can close through. The memoizer recognized only objects, so `fn.self = fn`
overflowed the stack under a recursive properties schema even though an object
in the same position no longer does. Allocation, lookup and back-edge
detection now share one reference predicate; no other container accepts a
function, so nothing else changes.
* fix(v4): make the z.properties schema role a real type gate
As a check, z.properties() asserts on whatever the base schema produced — a
string's length reads fine, and that is z.property()'s contract. As a schema it
IS the type gate: it infers an object shape and projects to JSON Schema, and
neither can describe "also accepts strings". Ajv rejected `"abc"` against the
emitted schema while safeParse accepted it. The schema role now rejects a
primitive with invalid_type; a function still passes, since its properties read
like any other object's and z.instanceof allows one. The compiler emits the
gate matching each role.
The output JSON Schema described values the schema never returns. Parsed child
results are discarded, so `z.properties({ a: z.string().default("x") })` returns
`{}` while the output schema marked `a` required. Requiredness now reads the
input side in both modes, and a transforming child is reported unrepresentable
in output mode rather than emitting a type that is never produced.
Key names were cached on first parse while the schemas stayed live on the
caller's shape object, so deleting a key afterwards threw a raw TypeError where
z.object() keeps working. Both are snapshotted together now.
* style(core): trim the memoizer reference-predicate comment to one clause C
Colin McDonnell committed
abfb38977dc28dc9993e3fa6a2ecc2f52a8d0c4c
Parent: 69f2a7f
Committed by GitHub <noreply@github.com>
on 8/31/2026, 9:06:27 PM