Skip to content

fix(v4): allow dynamic .catch() under unrepresentable: "any" (#5273)#5925

Merged
colinhacks merged 1 commit into
colinhacks:mainfrom
dokson:fix/dynamic-catch-json-schema
May 4, 2026
Merged

fix(v4): allow dynamic .catch() under unrepresentable: "any" (#5273)#5925
colinhacks merged 1 commit into
colinhacks:mainfrom
dokson:fix/dynamic-catch-json-schema

Conversation

@dokson

@dokson dokson commented May 1, 2026

Copy link
Copy Markdown
Contributor

Closes #5273.

Summary

z.toJSONSchema() crashes on any schema that uses a dynamic .catch((ctx) => …), even when the caller has opted into lossy conversion via unrepresentable: "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.

catchProcessor derives a static default by invoking def.catchValue(undefined). When the callback inspects its ctx argument 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 the default and fall through with the inner schema — which has already been populated by the preceding process(def.innerType, …) call. This is the same passthrough-on-the-representable-side shape that pipe(transform, …) already produces today under "any", so no new flag value is introduced.

const schema = z.string().catch((ctx) => `${ctx.issues.length} issues`);

z.toJSONSchema(schema);                              // throws (unchanged)
z.toJSONSchema(schema, { unrepresentable: "any" });  // { type: "string" }

Behaviour matrix

Schema unrepresentable Result Status
z.string().catch("x") "throw" (default) { type: "string", default: "x" } unchanged
z.string().catch((ctx) => …) "throw" (default) throws unchanged
z.string().catch((ctx) => …) "any" { type: "string" } new
z.bigint().catch((ctx) => …) "any" {} inner already unrepresentable
z.optional(z.string().catch(dynFn)) "any" { type: "string" } (with optional) new, transitive

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 than unrepresentable: '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 that pipe(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 on unrepresentable === "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 under unrepresentable, documenting the three cases (the docs caveat the issue explicitly asked for).

Test plan

  • pnpm vitest run to-json-schema — 334/334 passing locally.
  • Full suite on CI.

@pullfrog

pullfrog Bot commented May 1, 2026

Copy link
Copy Markdown
Contributor

TL;DR — Dynamic .catch((ctx) => …) now gracefully falls through to the inner schema under unrepresentable: "any" instead of unconditionally throwing, making that option usable for schema trees containing context-dependent catch values.

Key changes

  • Gate catchProcessor throw on unrepresentable: "throw" — under "any", the dynamic catch is skipped and the already-processed inner schema is emitted without a default.
  • Add inline-snapshot test for the "any" path — validates that a dynamic catch schema produces { type: "string" } when the flag is set.
  • Document dynamic .catch() behaviour in JSON Schema docs — new sub-section under unrepresentable showing the three cases (static, dynamic-throw, dynamic-any).

Summary | 3 files | 1 commit | base: mainfix/dynamic-catch-json-schema


Dynamic .catch() passthrough under unrepresentable: "any"

Before: catchProcessor always threw when def.catchValue(undefined) raised — even if the caller explicitly opted into lossy conversion.
After: The throw is gated on ctx.unrepresentable === "throw"; under "any", the processor returns early and the inner schema (already populated by the preceding process() call) is emitted as-is.

This follows the same passthrough pattern that pipe(transform, …) already uses under "any" — no new flag value is introduced.

json-schema-processors.ts · to-json-schema.test.ts · json-schema.mdx

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

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

Pullfrog  | View workflow run | Using Claude Opus𝕏

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

Pullfrog  | View workflow run | Using Claude Opus𝕏

@colinhacks

Copy link
Copy Markdown
Owner

@pullfrog drop the doc changes then merge

@pullfrog

pullfrog Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

Dropped the packages/docs/content/json-schema.mdx changes and force-pushed to fix/dynamic-catch-json-schema (preserving @dokson's authorship). Remaining diff is the processor fix + snapshot test only; pnpm vitest run to-json-schema.test.ts passes (158/158). I don't have a merge tool — kicking back to you to click merge on #5925.

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

@pullfrog pullfrog Bot force-pushed the fix/dynamic-catch-json-schema branch from 934a8aa to 189a633 Compare May 4, 2026 16:34
…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.
@pullfrog pullfrog Bot force-pushed the fix/dynamic-catch-json-schema branch from 189a633 to e07c268 Compare May 4, 2026 16:36
@colinhacks colinhacks merged commit fbe8ad1 into colinhacks:main May 4, 2026
6 checks passed
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.

Workaround for "Dynamic catch values are not supported in JSON Schema"

2 participants