fix(v4): align .merge() with .extend() refinement semantics#5856
Conversation
|
@pullfrog review |
|
Reviewed PR #5856. The fix is correct — it adds refinement guards to Task list (6/6 completed)
|
There was a problem hiding this comment.
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
Claude Opus | 𝕏
|
Addressed both review comments: consolidated the duplicated refinement guard in Task list (5/5 completed)
|
|
Both review suggestions have been applied in commit Task list (5/5 completed)
|
…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>
f79ba4c to
b5468b2
Compare
|
TL;DR — Key changes
Summary | 2 files | 1 commit | base: Guard against merging refined receiver schemas
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
|
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Rewrote
merge()guard to only reject refinements on the receiver (a), preservingb's refinements in the merged result — sound becauseb's shape is fully present in the output - Changed
checks: []tochecks: b._zod.def.checks ?? []to carryb's refinements forward - Added test verifying
b's refinements survive the merge - Trimmed
superRefinetest to a minimal 4-line example
Prior review feedback:
- Trim verbose
superRefinetest — addressed (minimal form) - Align guard pattern with
pick/omit/partialconvention — addressed differently: maintainer opted for asymmetric guard (onlya) with inlinechecks?.lengthrather than the consolidatedaHasChecks/bHasChecksapproach, which is the correct design given the semantic change
Claude Opus | 𝕏
|
Landed in Zod 4.4 |
|
@colinhacks Thank you! |
Summary
Fixes #5842
.merge()was silently dropping.refine()/.superRefine()callbacks from both operands by unconditionally settingchecks: []. 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.tsaandb) in themerge()function".merge() cannot be used on object schemas containing refinements"if either schema has checkspackages/zod/src/v4/classic/tests/object.test.ts.superRefine()→ throws.refine()→ throws.refine()→ throws.refine()→ throwsBefore / After
Notes
miniAPI'smerge()is deprecated and delegates toutil.extend(), which already has partial refinement protection. No changes needed there.