Skip to content

fix(toJSONSchema): preserve default value through pipe/transform in input mode#6064

Open
VihAMBR wants to merge 1 commit into
colinhacks:mainfrom
VihaanAgarwal:fix/toJSONSchema-default-with-pipe
Open

fix(toJSONSchema): preserve default value through pipe/transform in input mode#6064
VihAMBR wants to merge 1 commit into
colinhacks:mainfrom
VihaanAgarwal:fix/toJSONSchema-default-with-pipe

Conversation

@VihAMBR

@VihAMBR VihAMBR commented Jun 5, 2026

Copy link
Copy Markdown

Problem

z.string().transform(s => s).default("hello") passed to z.toJSONSchema({ io: "input" }) silently drops the default keyword:

z.toJSONSchema(z.string().transform(s => s).default("hello"), { io: "input" })
// emits { type: "string" }  ← default: "hello" is lost

// without the transform it works correctly:
z.toJSONSchema(z.string().default("hello"), { io: "input" })
// { type: "string", default: "hello" }  ✓

Fixes #6049.

Root cause

process() in to-json-schema.ts runs isTransforming(schema) after every processor and unconditionally deletes result.schema.default when the check returns true. For a ZodDefault whose inner type is a ZodPipe containing a transform, isTransforming returns true — so the default value that defaultProcessor just set on the schema is immediately wiped.

The deletion was intended for cases where default is inherited from a pipe (output-side default), not for ZodDefault, which always declares an explicit input-side default.

Fix

Skip the delete result.schema.default step when def.type === "default". The ZodDefault processor always sets default intentionally; removing it here is wrong regardless of whether the inner type transforms.

// to-json-schema.ts
if (ctx.io === "input" && isTransforming(schema)) {
  delete result.schema.examples;
  // ZodDefault explicitly declares the input-side default — preserve it even through transforms
  if (def.type !== "default") {
    delete result.schema.default;
  }
}

Test changes

…nput mode

z.string().transform(s => s).default("hello") with io: "input" was silently
dropping the default keyword. The process() function unconditionally deleted
result.schema.default whenever isTransforming() returned true, which affected
ZodDefault schemas whose inner type contains any transform — even identity
transforms. Since ZodDefault explicitly declares the input-side default value,
the deletion should be skipped for it.

Fixes colinhacks#6049
@VihAMBR VihAMBR marked this pull request as ready for review June 8, 2026 08:14

@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 changes — fixes z.toJSONSchema() dropping default when a ZodDefault wraps a pipeline containing a transform in io: "input" mode. The isTransforming check recurses into ZodDefault's inner type, so any transform inside caused process() to unconditionally wipe the default value that defaultProcessor just set. The fix guards the delete with def.type !== "default".

  • Guard delete result.schema.default with def.type !== "default" in process()ZodDefault always declares an explicit input-side default, so removing it here is wrong regardless of whether the inner type transforms. ZodPrefault already had equivalent protection at L211 via result.schema.default ??= result.schema._prefault.
  • Add regression test for #6049 — covers both the reported z.string().transform(s => s).default("hello") case and the z.string().transform(...).pipe(z.string()).default("hello") variant.
  • Update existing inline snapshot for a.default(1234) with a type-changing transform to reflect the now-correctly-preserved default: 1234.

✅ No new issues found.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

@wahajahmed010 wahajahmed010 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A clean, well-documented fix. The root-cause analysis is excellent — isTransforming runs after every processor and the unconditional delete result.schema.default was colliding with defaultProcessor.

Logic: Correct. Checking def.type !== "default" is the right discriminant — only ZodDefault intentionally sets result.schema.default on the input side. One subtle edge case: if a future processor also sets default on input for a non-ZodDefault schema, this guard would not cover it. A snapshot approach ("was it set before?") would be more robust, but that is future-proofing, not a current bug.

Tests: Snapshot update and new #6049 repro test look correct. The pipe-through-transform test covering both sides is thorough.

Edge case: The static def.type check assumes ZodDefault def.type is always "default". From reading the Zod source — confirmed, it is. Safe.

Nice work — minimal, correct, well-explained.

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.

v4: toJSONSchema drops default when the defaulted schema contains a transform/pipe

3 participants