Skip to content

fix: Handle z.union([]) and z.xor([])#5869

Merged
colinhacks merged 1 commit into
colinhacks:mainfrom
tjenkinson:empty-union
Apr 28, 2026
Merged

fix: Handle z.union([]) and z.xor([])#5869
colinhacks merged 1 commit into
colinhacks:mainfrom
tjenkinson:empty-union

Conversation

@tjenkinson
Copy link
Copy Markdown
Contributor

The types already resolve to never, but parse would throw.

Think it makes sense to treat as never when parsing?

Fixes #5868

`z.union([])`, `z.xor([])`, and `z.discriminatedUnion(_, [])` (plus
their `zod/mini` counterparts) all crashed at construction time with
`Cannot read properties of undefined (reading '_zod')` because the
$ZodUnion / $ZodXor initializers eagerly read `def.options[0]._zod.run`.

Guard the cached `first` runner so it's only captured when there's
exactly one option. The existing `handleUnionResults` /
`handleExclusiveUnionResults` helpers already do the right thing for
zero results: they fall through to `invalid_union` with `errors: []`.
$ZodDiscriminatedUnion inherits the construction fix transitively
through its $ZodUnion.init call, and its own parse path already emits
an `invalid_union` for unmatched discriminators.

Empty unions now construct successfully and reject every input at
parse time, matching the type-level inference of `never`.
@colinhacks
Copy link
Copy Markdown
Owner

Thanks for the report and the PR. Pushed a different fix on top — wanted to flag what changed and why.

Approach: instead of adding a special-case invalid_type issue with expected: "never", I just guarded the cached first runner so it's only captured when def.options.length === 1. The existing handleUnionResults / handleExclusiveUnionResults helpers already emit invalid_union with errors: [] when given an empty results array, so no new parse branch is needed. Net diff in core/schemas.ts is −6 / +4.

Why not expected: "never": that issue shape collides with what z.never() actually emits, so consumers can't distinguish "input rejected by z.never()" from "input rejected by an empty union." invalid_union with errors: [] is consistent with how non-empty unions report a no-match.

Coverage: also fixes z.discriminatedUnion("...", []) (and zod/mini equivalents), which crashed for the same reason via $ZodUnion.init. Tests added for all six surfaces.

Going to merge as-is.

Comment written with AI assistance.

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 28, 2026

Reviewed PR #5869. Approved — the fix correctly prevents the out-of-bounds access on empty union/xor options by combining the single/first variables into a single conditional. Traced the empty-results path through both handleUnionResults and handleExclusiveUnionResults to confirm correct invalid_union error production. Review

Task list (5/5 completed)
  • Read the PR diff and understand the changes
  • Read the affected source file (schemas.ts) for context
  • Trace data flow and verify correctness
  • Check for edge cases and potential issues
  • Submit review

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

@colinhacks colinhacks merged commit fffe99b into colinhacks:main Apr 28, 2026
6 checks passed
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 28, 2026

TL;DRz.union([]), z.xor([]), and z.discriminatedUnion(_, []) previously threw an internal error when parsing because the code unconditionally accessed def.options[0]. This PR guards that access so empty unions construct cleanly and reject all input with an invalid_union issue — matching the never type they already infer.

Key changes

  • Guard against empty options in $ZodUnion and $ZodXor parse setup — collapses the separate single boolean and first accessor into a single nullable assignment, preventing the crash when options is empty.
  • Add tests for empty z.union([]), z.xor([]), and z.discriminatedUnion(_, []) in classic and mini — verifies construction succeeds and that parsing any value produces the expected invalid_union issue with an empty errors array.

Summary | 3 files | 1 commit | base: mainempty-union


Empty union/xor no longer crashes at parse time

Before: calling z.union([]).safeParse(value) or z.xor([]).safeParse(value) threw a runtime error — def.options[0]._zod.run accessed index 0 of an empty array.
After: both schemas construct without error and reject every input with an invalid_union issue containing an empty errors list, consistent with the never type already inferred at the type level.

The fix is a one-line change applied identically in both $ZodUnion and $ZodXor: the single flag and eager first dereference are merged into a single conditional expression (def.options.length === 1 ? def.options[0]._zod.run : null). When first is null, the fast-path if (first) branch is skipped and the normal multi-option loop runs — which naturally produces the correct invalid_union error for zero options.

z.discriminatedUnion with an empty array already survived construction (it delegates to $ZodUnion.init) but the tests confirm it also reports the right issue shape.

packages/zod/src/v4/core/schemas.ts · packages/zod/src/v4/classic/tests/union.test.ts · packages/zod/src/v4/mini/tests/index.test.ts

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

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

Reviewed — no issues found.

Clean fix. Combining single and first into a single conditional assignment is the right approach — it avoids the out-of-bounds access on def.options[0] when the array is empty, while preserving the single-option fast path. The empty-results case flows correctly through both handleUnionResults (produces invalid_union with errors: []) and handleExclusiveUnionResults (same). Test coverage is thorough.

Task list (5/5 completed)
  • Read the PR diff and understand the changes
  • Read the affected source file (schemas.ts) for context
  • Trace data flow and verify correctness
  • Check for edge cases and potential issues
  • Submit review

Pullfrog  | View workflow run | Using Claude Opus𝕏

@colinhacks
Copy link
Copy Markdown
Owner

Landed in Zod 4.4

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.

z.union([]) and z.xor([]) throw internal error on parse

2 participants