Skip to content

fix(v4): align .merge() with .extend() refinement semantics#5856

Merged
colinhacks merged 1 commit into
colinhacks:mainfrom
solssak:fix/merge-throw-on-refinements
Apr 28, 2026
Merged

fix(v4): align .merge() with .extend() refinement semantics#5856
colinhacks merged 1 commit into
colinhacks:mainfrom
solssak:fix/merge-throw-on-refinements

Conversation

@solssak

@solssak solssak commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #5842

.merge() was silently dropping .refine() / .superRefine() callbacks from both operands by unconditionally setting checks: []. This is a surprising behavior — users expect a merge to either preserve refinements or explicitly fail, not silently discard them.

This PR aligns .merge() with the existing behavior of .pick(), .omit(), and .partial(), which all throw when called on refined schemas.

Changes

packages/zod/src/v4/core/util.ts

  • Added refinement checks for both operands (a and b) in the merge() function
  • Throws ".merge() cannot be used on object schemas containing refinements" if either schema has checks

packages/zod/src/v4/classic/tests/object.test.ts

  • Added 4 test cases covering:
    • First schema with .superRefine() → throws
    • First schema with .refine() → throws
    • Second schema with .refine() → throws
    • Both schemas with .refine() → throws

Before / After

const A = z.object({ age: z.number() }).refine(d => d.age >= 18, { message: "Must be 18+" });
const B = z.object({ name: z.string() });

// Before: silently drops refinement
A.merge(B).parse({ age: 5, name: "test" }); // => { age: 5, name: "test" } — no error

// After: throws immediately
A.merge(B); // => Error: ".merge() cannot be used on object schemas containing refinements"

Notes

  • The mini API's merge() is deprecated and delegates to util.extend(), which already has partial refinement protection. No changes needed there.
  • All 3583 existing tests pass with no type errors.

@colinhacks

Copy link
Copy Markdown
Owner

@pullfrog review

@pullfrog

pullfrog Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Reviewed PR #5856. The fix is correct — it adds refinement guards to merge() consistent with the existing behavior of pick()/omit()/partial(). Left two non-blocking style suggestions: consolidate the duplicated throw into a single guard, and trim test verbosity.

Task list (6/6 completed)
  • Checkout PR and read diff
  • Review changes in util.ts (merge logic)
  • Review test changes in object.test.ts
  • Verify alignment with pick/omit/partial pattern
  • Check for any missed impact areas
  • Submit review

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.

Important

Minor style consistency nits — the logic is correct and the fix is appropriate. Consider aligning the guard pattern with the existing pick/omit/partial convention and trimming test verbosity.

Task list (6/6 completed)
  • Checkout PR and read diff
  • Review changes in util.ts (merge logic)
  • Review test changes in object.test.ts
  • Verify alignment with pick/omit/partial pattern
  • Check for any missed impact areas
  • Submit review

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/zod/src/v4/core/util.ts Outdated
Comment thread packages/zod/src/v4/classic/tests/object.test.ts Outdated
@pullfrog

pullfrog Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Addressed both review comments: consolidated the duplicated refinement guard in util.ts into a compact aHasChecks/bHasChecks pattern, and trimmed the verbose superRefine test to a minimal 4-line example. All merge-related tests pass. Pushed as f79ba4c.

Task list (5/5 completed)
  • Checkout PR branch
  • Consolidate refinement guard in util.ts
  • Trim verbose superRefine test in object.test.ts
  • Run tests to verify changes
  • Commit, push, and reply to review threads

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

@pullfrog

pullfrog Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Both review suggestions have been applied in commit f79ba4c5: consolidated the merge() refinement guard into a compact aHasChecks/bHasChecks pattern (matching pick/omit/partial), and trimmed the verbose superRefine test to a minimal example. All 110 tests pass. Both threads resolved.

Task list (5/5 completed)
  • Checkout PR branch
  • Consolidate refinement guard in util.ts
  • Trim superRefine test to minimal example
  • Run tests to verify changes
  • Commit, push, and reply to review threads

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

…ments from second schema

`.merge(a, b)` spreads `b.shape` over `a.shape`, so `b` can clobber `a`'s
keys with arbitrary types. This makes `a`'s refinements unsound to carry
forward — previously they were silently dropped along with `b`'s. Now:

- Throw if the receiver has any refinements, with a pointer to `.safeExtend()`.
- Preserve refinements from the second schema (its shape is fully present
  in the merged result, so its checks remain sound).
- Catchall continues to come from `b` (unchanged).

This matches the soundness rule already enforced on `.extend()` while
extending it naturally to merge's two-schema signature. `.merge()` remains
deprecated in favor of `.extend()` / `.safeExtend()`.

Co-authored-by: solssak <solssak@users.noreply.github.com>
@pullfrog

pullfrog Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

TL;DR.merge() was silently discarding all .refine() / .superRefine() checks by hard-coding checks: []. This PR makes .merge() throw when the receiver has refinements (matching .pick() / .omit() / .partial() behavior) and preserves the second schema's checks instead of dropping them. Closes #5842.

Key changes

  • Throw on .merge() when receiver has refinements — the merge() utility in util.ts now checks a._zod.def.checks?.length and throws before merging, preventing silent refinement loss on the first operand.
  • Preserve second schema's refinements — instead of unconditionally setting checks: [], the merged def now uses b._zod.def.checks ?? [], so refinements on the second operand carry through.
  • Add test coverage for refinement handling in .merge() — three new tests verify the throw on .refine(), the throw on .superRefine(), and that the second schema's refinements are preserved post-merge.

Summary | 2 files | 1 commit | base: mainfix/merge-throw-on-refinements


Guard against merging refined receiver schemas

Before: .merge() silently dropped all refinements from both schemas by setting checks: [], so A.refine(...).merge(B) produced a schema with no refinement — parsing that should have failed would silently succeed.
After: .merge() throws immediately if the receiver (a) has any checks, aligning with .pick(), .omit(), and .partial(). The second schema's checks are preserved in the merged result.

The asymmetry is intentional: the receiver's shape is used as the base and its checks would reference the pre-merge shape (potentially invalid after merging new properties), while the second schema's checks reference only its own properties which all survive the merge. The error message suggests .safeExtend() as an alternative.

packages/zod/src/v4/core/util.ts · packages/zod/src/v4/classic/tests/object.test.ts

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

@colinhacks colinhacks changed the title fix: throw error when .merge() is called on schemas with refinements fix(v4): align .merge() with .extend() refinement semantics Apr 28, 2026

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

No new issues. Reviewed the following changes:

  • Rewrote merge() guard to only reject refinements on the receiver (a), preserving b's refinements in the merged result — sound because b's shape is fully present in the output
  • Changed checks: [] to checks: b._zod.def.checks ?? [] to carry b's refinements forward
  • Added test verifying b's refinements survive the merge
  • Trimmed superRefine test to a minimal 4-line example

Prior review feedback:

  • Trim verbose superRefine test — addressed (minimal form)
  • Align guard pattern with pick/omit/partial convention — addressed differently: maintainer opted for asymmetric guard (only a) with inline checks?.length rather than the consolidated aHasChecks/bHasChecks approach, which is the correct design given the semantic change

Pullfrog  | View workflow run | Using Claude Opus𝕏

@colinhacks colinhacks merged commit 15cafa1 into colinhacks:main Apr 28, 2026
7 checks passed
@colinhacks

Copy link
Copy Markdown
Owner

Landed in Zod 4.4

@solssak

solssak commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

@colinhacks Thank you!

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.

.merge() silently drops .refine() — should it throw like .pick()/.omit() do?

3 participants