fix(v4): allow dynamic .catch() under unrepresentable: "any" (#5273)#5925
Conversation
|
TL;DR — Dynamic Key changes
Summary | 3 files | 1 commit | base: Dynamic
|
There was a problem hiding this comment.
Reviewed — no issues found.
Claude Opus | 𝕏
There was a problem hiding this comment.
Reviewed — no issues found.
Claude Opus | 𝕏
5f7d7eb to
934a8aa
Compare
|
@pullfrog drop the doc changes then merge |
|
Dropped the
|
934a8aa to
189a633
Compare
…inhacks#5273) `catchProcessor` evaluates `def.catchValue(undefined)` to derive a static `default`. When the callback inspects its `ctx` argument it throws, and `z.toJSONSchema()` surfaced that as a hard error even when the caller opted into lossy conversion. Gate the rethrow on `unrepresentable === "throw"`. Under `"any"`, skip the `default` and fall through with the inner schema (already populated by the preceding `process(def.innerType)` call) — same passthrough shape `pipe(transform, ...)` produces today. Default behaviour is unchanged. Adds a snapshot test for the new path.
189a633 to
e07c268
Compare
Closes #5273.
Summary
z.toJSONSchema()crashes on any schema that uses a dynamic.catch((ctx) => …), even when the caller has opted into lossy conversion viaunrepresentable: "any". This makes the option unusable for any tree containing a context-dependent catch — which is the exact scenario the option was added to handle.catchProcessorderives a staticdefaultby invokingdef.catchValue(undefined). When the callback inspects itsctxargument it throws, and the processor unconditionally re-throws"Dynamic catch values are not supported in JSON Schema".Fix
Gate the re-throw on
ctx.unrepresentable === "throw". Under"any", skip thedefaultand fall through with the inner schema — which has already been populated by the precedingprocess(def.innerType, …)call. This is the same passthrough-on-the-representable-side shape thatpipe(transform, …)already produces today under"any", so no new flag value is introduced.Behaviour matrix
unrepresentablez.string().catch("x")"throw"(default){ type: "string", default: "x" }z.string().catch((ctx) => …)"throw"(default)z.string().catch((ctx) => …)"any"{ type: "string" }z.bigint().catch((ctx) => …)"any"{}z.optional(z.string().catch(dynFn))"any"{ type: "string" }(with optional)Default behaviour is fully unchanged; the new path is opt-in via the existing flag.
On
"passthrough"vs"any"The issue author notes their patch is "more like
unrepresentable: 'passthrough'(which does not exist yet) rather thanunrepresentable: 'any'." Agreed semantically, but adding a new union variant would need to be applied coherently across every unrepresentable processor and is a separate API decision. The current PR reuses the existing"any"flag — the same pattern thatpipe(transform, …)already relies on for de-facto passthrough behaviour — and avoids growing the surface area. Happy to follow up with a"passthrough"proposal if preferred.Changes
packages/zod/src/v4/core/json-schema-processors.ts— gate the re-throw onunrepresentable === "throw".packages/zod/src/v4/classic/tests/to-json-schema.test.ts— inline-snapshot test for the"any"path next to the existing static-vs-dynamic catch tests.packages/docs/content/json-schema.mdx— new "Dynamic.catch()values" sub-section underunrepresentable, documenting the three cases (the docs caveat the issue explicitly asked for).Test plan
pnpm vitest run to-json-schema— 334/334 passing locally.