Skip to content

fix(v4): reject tuple holes before required defaults - #5900

Merged
colinhacks merged 4 commits into
mainfrom
fix/tuple-exact-optional-default
Apr 29, 2026
Merged

colinhacks merged 4 commits into
mainfrom
fix/tuple-exact-optional-default

Conversation

@colinhacks

Copy link
Copy Markdown
Owner

Summary

  • Reject absent optional-output tuple slots when a later slot has required output, such as a defaulted element.
  • Add regression coverage for exactOptional() before .default() plus refined optional async/sync parity.

Test plan

  • pnpm vitest run packages/zod/src/v4/classic/tests/tuple.test.ts
  • pnpm vitest run packages/zod/src/v4/classic/tests/optional.test.ts

@pullfrog

pullfrog Bot commented Apr 29, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR — Fixes tuple parsing to reject absent optional-output slots when a later slot has required output (e.g. .default()), rather than incorrectly truncating the result and silently dropping required elements.

Key changes

  • Separate optin / optout start indices via getTupleOptStart helper — Replaces the single reverse-findIndex with a shared helper that computes the first required index for either dimension, clarifying the distinction between input-length validation and output-shaping.
  • Gate truncation on optoutStart instead of per-element isOptionalOut — The truncation branch now fires only when the absent slot is at or past optoutStart, ensuring slots before a required-output tail (e.g. .default()) surface validation errors instead of silently trimming.
  • Add regression tests for exactOptional() before .default() — Covers sync, async, and mixed optional/default tuple scenarios including the new exactOptional + default combination.

Summary | 2 files | 4 commits | base: main ← fix/tuple-exact-optional-default


Reject tuple holes that precede required output

Before: An absent optional-output slot always triggered truncation—even when a later slot had required output (e.g. .default("d")). This silently produced a shorter tuple that violated the declared output type.
After: Truncation is only applied when the absent slot is at or past the optoutStart index—the first position from which the entire remaining tail has optional output. Otherwise the absence surfaces as a validation error at the correct path index.

The fix introduces a getTupleOptStart(items, key) helper that computes the boundary for both "optin" (used by the too_small precheck) and "optout" (used by the truncation logic). The handleTupleResults function now receives optoutStart and checks i >= optoutStart rather than inspecting isOptionalOut per-element, which correctly prevents truncation when any later slot would produce required output.

Why is the per-element check insufficient? The old code checked whether the current slot itself had optional output. But truncation drops all later slots too—including ones with required output like `.default()`. The positional `optoutStart` boundary captures the correct invariant: truncation is safe only when nothing after the current index requires materialization.

packages/zod/src/v4/core/schemas.ts · packages/zod/src/v4/classic/tests/tuple.test.ts

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.

Task list (4/4 completed)
  • Checkout PR and read the diff
  • Read changed files and trace logic
  • Draft review comments
  • Submit review

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.

No new issues. Reviewed the following changes:

  • Replaced inline hasRequiredOutputAfter boolean array with a pre-computed optoutStart index, eliminating an O(n) allocation in handleTupleResults
  • Extracted getTupleOptStart helper to compute both optinStart (input-length validation) and optoutStart (output-shaping truncation) without the [...items].reverse().findIndex() copy+allocation
  • Added optoutStart parameter to handleTupleResults; truncation condition changed from !hasRequiredOutputAfter[i] to the equivalent i >= optoutStart
  • Added test for .exactOptional() before a defaulted slot, asserting parse failure and correct type inference

Pullfrog  | View workflow run | Using Claude Opus | 𝕏

@colinhacks
colinhacks merged commit cede2c6 into main Apr 29, 2026
6 of 7 checks passed
@colinhacks
colinhacks deleted the fix/tuple-exact-optional-default branch April 29, 2026 23:08
@colinhacks

Copy link
Copy Markdown
Owner Author

Landed in Zod 4.4

This branch was successfully deployed

1 active deployment
Preview – zod-v4 — 985d37ba Deployed Apr 29, 2026 by vercel[bot]
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.

1 participant