mirror of
https://github.com/hanzoai/zod.git
synced 2026-09-24 17:04:49 +00:00
* Make z.preprocess defer optionality to inner schema (#5917) `z.preprocess(fn, schema)` desugared to `pipe(transform(fn), schema)`, so the resulting pipe's `optin` was inherited from `ZodTransform` (undefined) instead of from the inner schema. When the inner schema was itself optional and the preprocess sat as an object property, the object compiler took the `!isOptionalIn` branch and synthesized a `nonoptional` issue for missing keys — making the position of `.optional()` change presence semantics. Introduce `ZodPreprocess` as a structural subtype of `ZodPipe` (same `def.type === "pipe"`, `instanceof ZodPipe` still true). It pins `def.in` to a permissive `z.unknown()` and re-installs the four lazy metadata props so `optin`, `optout`, `values`, and `propValues` all defer to `def.out` (the inner schema). Backward direction throws, matching today's behavior. The two JSON-schema sites that special-cased the old `pipe(transform, ...)` shape (`pipeProcessor` and `isTransforming`) now also detect preprocess via `_zod.traits`. * Stop deferring values/propValues from inner schema `values` and `propValues` describe the *input* set a schema accepts. Preprocess opens that set to anything `fn` can map to a B-accepted value, so deferring these to B was unsound: - discriminated unions use `propValues` as a fast-path disc map; with B's propValues exposed, an option claims to match only B's literal inputs and silently routes wrong (e.g. preprocess(toUpperCase, z.literal("A")) would fail to match input "a"). Now reverts to throwing at construction, matching pre-PR behavior. - `z.record()` uses `values` to enumerate expected keys; with B's values exposed it short-circuits before the preprocess fn ever runs on input keys. Presence semantics (`optin`/`optout`) describe whether the *outer* container can omit this slot; preprocess is transparent to those, so they continue to defer to B (the original #5917 fix). * Add type-level tests for ZodPreprocess assignability Document the invariant that ZodPreprocess<B> is structurally assignable to ZodPipe<$ZodType, B>, and that the optin/optout override surfaces B's declared type (narrowed for schemas like ZodOptional that hardcode it, open `"optional" | undefined` for schemas like ZodString that inherit it). * Use traits.has consistently in pipeProcessor `pipeProcessor` was mixing detection styles: `traits.has("$ZodPreprocess")` for the new subtype and `def.in._zod.def.type === "transform"` for the legacy pipe(transform, ...) form. Both check "input contributes no validation, use B for input-side JSON schema." Converge on traits. * Treat ZodCodec as transforming for JSON schema example/default stripping Same diagnosis as the preprocess case: `isTransforming` recurses into `def.in` and `def.out` looking for `def.type === "transform"`, but `ZodCodec` embeds an implicit transform fn (its `decode`/`encode`) while `def.in` and `def.out` are validating schemas. The recursion finds no transform and returns false even though the codec absolutely is transforming, causing output-side examples/defaults to leak into the input JSON schema. Detect via `_zod.traits.has("$ZodCodec")` alongside the preprocess check. * Document ZodPreprocess in inheritance hierarchy Surface ZodPreprocess everywhere the codebase enumerates first-party types as a structural sibling of ZodCodec under ZodPipe: - core.mdx: $ZodTypes union comment ("$ZodCodec and $ZodPreprocess extend this") and the inheritance diagram - assignability.test.ts: explicit `satisfies` checks confirming the type-level relationship to $ZodPreprocess and $ZodPipe (with generics specified — the bare-generic form bites variance because inherited reverseTransform is contravariant in B) The $ZodTypes union itself doesn't need a new entry — preprocess is covered transitively via $ZodPipe, same as $ZodCodec — but documenting the relationship in prose and tests makes the hierarchy discoverable. * Simplify ZodPreprocess to a pure metadata-override subtype The bug is purely a static-metadata problem: $ZodPipeInternals.optin was inheriting from A (the leading transform, which has no optin) instead of from B (the inner schema). The runtime parse path for pipe(transform(fn), schema) was already correct. So the simpler design is: keep the runtime structure exactly as the legacy form (def.in is a real ZodTransform), and just override the optin/optout lazies in a thin subtype. What this eliminates from the previous design: - Custom parse function in $ZodPreprocess.init (forward direction with embedded transform fn) - Custom parse function in classic ZodPreprocess.init (addIssue injection — the inner ZodTransform's classic parse override already does this) - The synthetic z.unknown() input slot - The required `transform` field on the def - traits.has("$ZodPreprocess") check in pipeProcessor and isTransforming — def.in is a real ZodTransform, so the existing detection paths fire correctly The traits.has("$ZodCodec") check in isTransforming stays — codec embeds its transform fn directly on the def (different shape) and needs its own detection. * Trim verbose comments to match house style