@@ -233,8 +233,13 @@ export const JSON_SCHEMA_CONVERSION_TARGET = 'draft-2020-12';
233233 * tolerant `.default(5).pipe(z.number().min(1).refine(async () => true))` stays
234234 * advertised as required — the probe goes async, and claiming IN-side tolerance
235235 * structurally would wrongly drop `.default(0).pipe(z.number().min(1).refine(
236- * async …))`. Neither structural direction is sound there, so the conservative
237- * stay-required posture (byte-parity with the pre-#2464 emission) wins.
236+ * async …))`. The same trade applies to async CHECKS attached to the
237+ * tolerance-granting node itself (`.default(0).refine(async () => true)`,
238+ * `z.any().refine(async …)`): the check re-validates the fill, so no
239+ * structural claim is sound and the async probe cannot decide. In both shapes
240+ * the conservative stay-required posture (byte-parity with the pre-#2464
241+ * emission) wins; async stages DOWNSTREAM of a check-free tolerant node
242+ * (`.default(0).transform(async …)`) keep the structural drop.
238243 * - Output schemas containing `.transform()`/`.pipe()`/`z.coerce` still advertise the
239244 * post-transform shape (`io: 'output'`) even though the server validates and ships
240245 * the raw pre-transform value — rewriting pipe nodes to their input side per-node
@@ -538,23 +543,26 @@ const ZOD_REGISTRY_REF_PATTERN = /^#\/\$defs\/[^/]+$/;
538543/**
539544 * Whether any reference keyword in the document carries a hand-authored value.
540545 * Hand-authored-ness is decided by value SHAPE and lexical CONTEXT together:
541- * outside `id` resources, anything but zod's own registry shapes (`#`,
542- * `#/$defs/<name>` — root-base JSON Pointers that never resolve through a
543- * draft-04 `id` base) is hand-authored; INSIDE a draft-04 `id`-carrying
544- * resource, EVERY ref counts — resolution there is base-relative on the
545- * cfworker engine (`schema.$id || schema.id` registration), so even a
546- * registry-shaped `$ref: '#'` addresses the resource, a spelling zod's emitter
547- * never produces at that position. `$recursiveRef` counts regardless of value
548- * or position — zod never emits the keyword at all.
546+ * `#/$defs/<name>` root-base JSON Pointers are always zod-exempt — zod's own
547+ * emitter places them INSIDE draft-04 `id`-carrying entries too (recursive
548+ * z.lazy self-refs, cross-registered schemas), they resolve through the
549+ * document root rather than the `id` base, and stripping the `id` around them
550+ * is strictly safe on both engines (Ajv compiles; on cfworker the kept `id`
551+ * would make them dangle base-relatively). Bare `#` is exempt only OUTSIDE
552+ * `id` resources: inside one it addresses the resource base-relatively on the
553+ * cfworker engine, and zod never emits that spelling there — it is
554+ * hand-authored and observes the strip. `$recursiveRef` counts regardless of
555+ * value or position — zod never emits the keyword at all.
549556 */
550557function hasHandAuthoredRefValues ( document : Record < string , unknown > ) : boolean {
551558 return someSchemaNode ( document , ( record , insideIdResource ) =>
552559 [ '$ref' , '$dynamicRef' , '$recursiveRef' ] . some ( refKey => {
553560 const value = record [ refKey ] ;
554561 if ( value === undefined ) return false ;
555562 if ( refKey === '$recursiveRef' ) return true ;
556- if ( typeof value !== 'string' || insideIdResource ) return true ;
557- return value !== '#' && ! ZOD_REGISTRY_REF_PATTERN . test ( value ) ;
563+ if ( typeof value !== 'string' ) return true ;
564+ if ( value === '#' ) return insideIdResource ;
565+ return ! ZOD_REGISTRY_REF_PATTERN . test ( value ) ;
558566 } )
559567 ) ;
560568}
@@ -669,12 +677,18 @@ function hasHandAuthoredReferenceConstructs(document: Record<string, unknown>):
669677 // loosening as a tightening the rename walk's lexical polarity skip
670678 // cannot see through the ref indirection.
671679 if ( underPolarityBoundary ) return true ;
672- // And they are loosen-safe only OUTSIDE draft-04 `id` resources: the
673- // cfworker engine resolves refs base-relatively there, and zod never
674- // emits a bare `#` inside an id-carrying entry — such refs are
675- // hand-authored and observe the strip/loosening.
676- if ( inIdResource ) return true ;
677- if ( value !== '#' && ! ZOD_REGISTRY_REF_PATTERN . test ( value ) ) return true ;
680+ // Bare `#` is zod-exempt only OUTSIDE draft-04 `id` resources: the
681+ // cfworker engine resolves it base-relatively there, and zod never
682+ // emits that spelling inside an id-carrying entry — it is
683+ // hand-authored and observes the strip/loosening. `#/$defs/<name>`
684+ // pointers stay exempt everywhere: zod itself emits them inside id
685+ // entries (recursive/cross-registered schemas), and they resolve
686+ // through the document root.
687+ if ( value === '#' ) {
688+ if ( inIdResource ) return true ;
689+ continue ;
690+ }
691+ if ( ! ZOD_REGISTRY_REF_PATTERN . test ( value ) ) return true ;
678692 }
679693 if ( record . $anchor !== undefined || record . $dynamicAnchor !== undefined || record . $id !== undefined ) return true ;
680694 // 2019-09 recursion keywords: zod never emits them, and the SDK's
@@ -852,22 +866,24 @@ const ENFORCED_JSON_SCHEMA_KEYWORDS: ReadonlySet<string> = new Set([
852866
853867/**
854868 * Whether a raw payload that omits this field still passes validation (zod treats a
855- * missing key as `undefined` — true for `.default()`/`.prefault()`, `z.any()`,
856- * `z.unknown()`, `z.undefined()`, and unions with them), so the wire schema must not
857- * advertise the field as `required`. A probe that throws, rejects, or goes async (a
858- * `.transform()` choking on `undefined` does all three depending on the zod version)
859- * cannot demonstrate tolerance, so such fields conservatively stay required.
869+ * missing key as `undefined` — true for a check-free `.default()`, `z.any()`,
870+ * `z.unknown()`, `z.undefined()`, and unions with them; `.prefault()` and any
871+ * checks-carrying node re-validate the filled value, so the probe decides those),
872+ * so the wire schema must not advertise the field as `required`. A probe that
873+ * throws, rejects, or goes async (a `.transform()` choking on `undefined` does all
874+ * three depending on the zod version) cannot demonstrate tolerance, so such fields
875+ * conservatively stay required.
860876 */
861877function fieldAcceptsMissingKey ( field : z . core . $ZodType | undefined ) : boolean {
862878 if ( field === undefined ) return false ;
863- // Defaulted fields accept a missing key by construction (zod fills the default
864- // before any refinement or transform runs ) — decide structurally, since an async
865- // stage (`.refine(async ...)`, `. transform(async ...)`) would push the probe
866- // below to a Promise and wrongly keep the field required. The walk also covers
867- // static `.catch()`, undefined-accepting leaves (`z.any ()`/`z.unknown()`),
868- // Symbol-/function-typed fields (JSON.stringify drops such keys entirely), union
869- // members, and defaults hidden inside a pipe (`.default(7).transform(...)`,
870- // `z.preprocess(fn, z.number().default(7))`) .
879+ // CHECK-FREE defaulted fields accept a missing key by construction (zod fills
880+ // the default; nothing re-validates it ) — decide those structurally, since an
881+ // async DOWNSTREAM stage (`.transform(async ...)` after the default node)
882+ // would push the probe below to a Promise and wrongly keep the field
883+ // required. The walk also covers check-free static `.catch ()` and
884+ // undefined-accepting leaves, Symbol-/function-typed fields (JSON.stringify
885+ // drops such keys entirely, checks or not), union members, and defaults
886+ // hidden inside a bare-transform pipe .
871887 if ( hasStructuralMissingKeyTolerance ( field ) ) return true ;
872888 try {
873889 const result = field [ '~standard' ] . validate ( undefined ) ;
@@ -884,14 +900,17 @@ function fieldAcceptsMissingKey(field: z.core.$ZodType | undefined): boolean {
884900
885901/**
886902 * Whether the field's def chain carries a node that makes a missing key tolerable by
887- * construction — `default`/`prefault` (the default fills), a static `catch` (any
888- * input, including `undefined`, is replaced by the fallback), `optional`, an
889- * undefined-accepting leaf (`z.any()`/`z.unknown()`/`z.undefined()`/`z.void()`, or a
890- * literal whose values include `undefined`), or a Symbol-/function-typed leaf
891- * (JSON.stringify drops such keys from the payload entirely) — unwinding pipe sides,
892- * lazies, transparent wrappers, union members (ANY tolerant member suffices: zod
893- * tries members and the tolerant one succeeds), and intersections with EVERY-side
894- * semantics (`undefined` must parse through both sides). Deciding structurally matters
903+ * construction — a CHECK-FREE `default` (the default fills; `.prefault()`
904+ * re-validates the fill and defers to the probe), a check-free static `catch` (any
905+ * input, including `undefined`, is replaced by the fallback), `optional`, a
906+ * check-free undefined-accepting leaf (`z.any()`/`z.unknown()`/`z.undefined()`/
907+ * `z.void()`, or a literal whose values include `undefined`), or a
908+ * Symbol-/function-typed leaf (JSON.stringify drops such keys from the payload
909+ * entirely, checks or not) — unwinding bare-transform pipe sides, lazies,
910+ * transparent wrappers, and union members (ANY tolerant member suffices: zod
911+ * tries members and the tolerant one succeeds). Intersections claim tolerance
912+ * only for the provably-mergeable disjoint-key plain-object-defaults shape —
913+ * everything else defers to the probe. Deciding structurally matters
895914 * because an async stage (`.refine(async ...)`, `.transform(async ...)`) pushes the
896915 * validate-probe to a Promise. All checks err loosen-only: a false positive merely
897916 * drops a field from the advertised `required`, which can never make a validating
@@ -913,11 +932,24 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
913932 left ?: unknown ;
914933 right ?: unknown ;
915934 values ?: unknown ;
935+ checks ?: unknown ;
916936 } ;
917937 } ;
918938 }
919939 ) . _zod ?. def ;
920940 if ( def === undefined || typeof def . type !== 'string' ) return false ;
941+ // Serialization-based tolerance first, checks or not: a symbol/function value
942+ // that PASSES its checks still never reaches the wire (JSON.stringify drops
943+ // the key).
944+ if ( def . type === 'symbol' || def . type === 'function' ) return true ;
945+ // A `.refine()`/`.check()` attaches to the SAME node (`def.checks`) and
946+ // re-validates the filled/accepted/passed-through value —
947+ // `.default(0).refine(v => v >= 1)` rejects a missing key — so NO
948+ // acceptance-based structural claim below is sound on a checks-carrying node:
949+ // defer to the validate(undefined) probe (sync verdicts are correct both
950+ // ways; async checks conservatively keep the field required, the documented
951+ // posture — see the async bullets in the Known residual gaps list).
952+ if ( Array . isArray ( def . checks ) && def . checks . length > 0 ) return false ;
921953 // `catch` and `optional` must be recognized BEFORE the wrapper unwind below
922954 // would step past the very node granting tolerance (bare `.optional()` fields
923955 // are already excluded from `required` by zod's emitter, but one inside a pipe
@@ -932,7 +964,6 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
932964 return false ;
933965 }
934966 if ( def . type === 'any' || def . type === 'unknown' || def . type === 'undefined' || def . type === 'void' ) return true ;
935- if ( def . type === 'symbol' || def . type === 'function' ) return true ;
936967 if ( def . type === 'literal' && Array . isArray ( def . values ) && def . values . includes ( undefined ) ) return true ;
937968 const path = new Set ( ancestors ) ;
938969 path . add ( field ) ;
@@ -1013,12 +1044,27 @@ function hasStructuralMissingKeyTolerance(field: unknown, ancestors: ReadonlySet
10131044 * Used where VALIDATION-based tolerance must not propagate but
10141045 * SERIALIZATION-based tolerance still applies (the `nonoptional` re-forbid).
10151046 */
1016- function hasSerializationDroppedLeaf ( field : unknown ) : boolean {
1017- if ( typeof field !== 'object' || field === null ) return false ;
1018- const def = ( field as { _zod ?: { def ?: { type ?: string ; innerType ?: unknown } } } ) . _zod ?. def ;
1047+ function hasSerializationDroppedLeaf ( field : unknown , ancestors : ReadonlySet < unknown > = new Set ( ) ) : boolean {
1048+ if ( typeof field !== 'object' || field === null || ancestors . has ( field ) ) return false ;
1049+ const def = ( field as { _zod ?: { def ?: { type ?: string ; innerType ?: unknown ; getter ?: unknown ; options ?: unknown } } } ) . _zod ?. def ;
10191050 if ( def === undefined || typeof def . type !== 'string' ) return false ;
10201051 if ( def . type === 'symbol' || def . type === 'function' ) return true ;
1021- if ( WRAPPER_ZOD_DEF_TYPES . has ( def . type ) && def . innerType !== undefined ) return hasSerializationDroppedLeaf ( def . innerType ) ;
1052+ const path = new Set ( ancestors ) ;
1053+ path . add ( field ) ;
1054+ // Mirror the main walk's coverage: ANY-member union semantics (whenever the
1055+ // symbol member is the matched one, the key vanishes from the wire) and
1056+ // lazy-getter following with the same cycle bound.
1057+ if ( def . type === 'union' && Array . isArray ( def . options ) ) {
1058+ return def . options . some ( option => hasSerializationDroppedLeaf ( option , path ) ) ;
1059+ }
1060+ if ( def . type === 'lazy' && typeof def . getter === 'function' ) {
1061+ try {
1062+ return hasSerializationDroppedLeaf ( ( def . getter as ( ) => unknown ) ( ) , path ) ;
1063+ } catch {
1064+ return false ;
1065+ }
1066+ }
1067+ if ( WRAPPER_ZOD_DEF_TYPES . has ( def . type ) && def . innerType !== undefined ) return hasSerializationDroppedLeaf ( def . innerType , path ) ;
10221068 return false ;
10231069}
10241070
@@ -1043,6 +1089,13 @@ function plainObjectDefaultFill(side: unknown): Record<string, unknown> | undefi
10431089 if ( def ?. type !== 'default' ) return undefined ;
10441090 const fill = def . defaultValue ;
10451091 if ( typeof fill !== 'object' || fill === null || Array . isArray ( fill ) ) return undefined ;
1092+ // Mirror zod's own isPlainObject gate in mergeValues: Dates, Maps, and class
1093+ // instances have zero own enumerable keys (vacuously "disjoint") yet zod
1094+ // throws 'Unmergable intersection' on them unless the values are equal —
1095+ // undecidable structurally, so the probe decides (correct both directions:
1096+ // distinct-timestamp Date fills stay required, same-timestamp ones drop).
1097+ const proto : unknown = Object . getPrototypeOf ( fill ) ;
1098+ if ( proto !== Object . prototype && proto !== null ) return undefined ;
10461099 return fill as Record < string , unknown > ;
10471100}
10481101
0 commit comments