Skip to content

fix(v4): align object and tuple optionality handling#5661

Merged
colinhacks merged 11 commits intocolinhacks:mainfrom
Cyjin-jani:fix/5229-tuple-default-values
Apr 29, 2026
Merged

fix(v4): align object and tuple optionality handling#5661
colinhacks merged 11 commits intocolinhacks:mainfrom
Cyjin-jani:fix/5229-tuple-default-values

Conversation

@Cyjin-jani
Copy link
Copy Markdown
Contributor

@Cyjin-jani Cyjin-jani commented Jan 24, 2026

This PR started as a focused fix for #5229, but I slightly hijacked it while reviewing the related optionality semantics.

It now aligns object and tuple handling around the internal optin / optout split:

  • Tuple parsing still uses optin for input-length validation and optout for output shaping, so trailing .default() / .prefault() elements materialize while purely optional tails still trim away.
  • Object parsing now also requires optin === "optional" before treating an absent key as valid. Schemas like z.undefined(), z.union([z.string(), z.undefined()]), and required .catch() fields no longer make a missing object key acceptable just because parsing undefined can succeed or be recovered.
  • optout continues to control whether absent optional output is omitted, including the .optional() / .exactOptional() cases.

The net effect is that absence has to be explicitly represented by input-side optionality. Defaults still bubble through missing object keys and tuple slots; bare .catch() still recovers explicit invalid values, but no longer acts like a missing-key default unless the field is also optional.


Original PR Body

Summary

Fixes #5229

When parsing tuples with missing trailing elements that have .default() values, the default values were not being applied. Instead, the elements were simply skipped.

Before (As-is)

const myTuple = z.tuple([z.string(), z.string().default('bravo')]);
myTuple.parse(['alpha']); // => ['alpha'] (default ignored)

After (To-be)

const myTuple = z.tuple([z.string(), z.string().default('bravo')]);
myTuple.parse(['alpha']); // => ['alpha', 'bravo'] (default applied)

Changes

Modified the tuple parsing logic to run the schema for default type elements even when the input array is shorter, allowing $ZodDefault to apply its default value.
File: packages/zod/src/v4/core/schemas.ts

- if (i >= input.length) if (i >= optStart) continue;
+ if (i >= input.length && i >= optStart && item._zod.def.type !== "default") continue;

Test Plan

✅ Added test case for tuple with default elements in tuple.test.ts
✅ All existing tests pass

Verified Issue Examples

All examples from #5229 now work correctly:

// Example 1: Basic tuple with default
z.tuple([z.string(), z.string().default('bravo')]).parse(['alpha']);
// ✅ Now returns: ['alpha', 'bravo']

// Example 2: Skipped - this correctly throws an error (expected behavior, not a bug)

// Example 3: ZodFunction with correct API
z.function().input([z.string(), z.string().default('bravo')])
.implement((name, company) => console.log(name, company));
// ✅ Now logs: "alpha bravo"

// Example 4: Destructuring
const [name, company] = z.tuple([z.string(), z.string().default('bravo')]).parse(['alpha']);
// ✅ Now: name = "alpha", company = "bravo"

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.

Medium priority — The fix correctly addresses the reported issue for .default() but misses .prefault(), which has identical default-providing semantics. This is a real edge case that users could hit.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment thread packages/zod/src/v4/core/schemas.ts Outdated
Comment thread packages/zod/src/v4/classic/tests/tuple.test.ts Outdated
Tuple elements with `.default()` / `.prefault()` were silently dropped
when the input array was shorter than the tuple, because the parser
skipped any item past `input.length` whose slot was within `optStart`.

Gate the skip on `optout === "optional"` instead. That distinguishes
schemas that produce `undefined` for missing input (`.optional()`,
`z.undefined()`) — for which skipping is a no-op — from schemas that
produce a defined value. `optout` already bubbles through `nullable` /
`readonly` / `catch` / `pipe` / `union`, so chains like
`.default("x").nullable()` are covered without a type-name allowlist.

Closes colinhacks#5229.
`expectTypeOf<...>().toEqualTypeOf<[string, string?]>()` rejects the
inferred `[string, (string | undefined)?]` under TypeScript >=6 because
the optional element widens to `T | undefined` in the source position.
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 29, 2026

TL;DR — Fixes #5229 by restructuring the tuple parser to materialize .default()/.prefault() values for missing trailing elements, and tightens object property handling so that absent keys are only silently accepted when the schema is optional on both input and output. Also removes the implicit optin/optout = "optional" from z.undefined(), making it a required-input type that must be explicitly passed.

Key changes

  • Run defaulted tuple elements instead of skipping them — Removes the old i >= optStart skip guard and eagerly runs every item schema, collecting results into an indexed array. A new handleTupleResults post-processor decides what to keep based on optout — only truly optional elements are trimmed, while elements carrying a default materialize.
  • Extract handleTupleResults post-processor — The inline finalize logic is hoisted into a top-level function that handles issue swallowing on absent-optional rejection, result truncation, and trailing-undefined trimming — all in one ordered pass.
  • Preserve explicit undefined inside input.length — The trailing trim floors at input.length so that a caller-supplied undefined (e.g. ["alpha", undefined] with z.string().or(z.undefined())) is kept in the output.
  • Distinguish optin from optout in object property handlinghandlePropertyResult now receives an isOptionalIn parameter. Absent keys only have errors swallowed when the schema is optional on both input and output. When optin is not "optional" (e.g. z.string().catch("x"), z.undefined()), absent keys now emit an invalid_type / nonoptional error. The JIT path ($ZodObjectJIT) gets a matching third code branch.
  • Remove implicit optionality from z.undefined()$ZodUndefined no longer sets optin = "optional" or optout = "optional", making it a required-input type. z.object({ x: z.undefined() }).parse({}) now fails instead of silently accepting the absent key.
  • Update tests across catch, optional, partial, and tuple suites — Catch and partial tests now expect safeParse failures for absent keys with .catch()/.prefault() on non-optional schemas. New optional test covers the optin-required semantics for z.undefined(), z.union([..., z.undefined()]), and .default(). Tuple tests cover trailing defaults, dense arrays, absent-optional rejection truncation, async parsing, and explicit-undefined preservation.

Summary | 5 files | 11 commits | base: mainfix/5229-tuple-default-values


Tuple default-value resolution for missing trailing elements

Before: Tuple elements beyond the input array's length were unconditionally skipped once past optStart, so .default("bravo") was never invoked and the output array stayed short.
After: Every item schema runs eagerly (including against undefined for absent slots). A new handleTupleResults function walks results in order, swallowing issues for absent optional-out slots and truncating on the first rejection — but letting default-bearing schemas materialize their values.

The key insight is that optout already distinguishes "produces undefined" ("optional") from "produces a concrete default value" (anything else). The post-processor uses this to decide what to keep without hard-coding type names, naturally handling chains like .default("x").nullable().readonly().

The trailing-trim loop floors at i >= input.length so that an explicit undefined passed by the caller (at an index inside the input) is preserved in the output. This prevents z.tuple([z.string(), z.string().or(z.undefined())]) from collapsing ["alpha", undefined] down to ["alpha"].

Why was the inline closure extracted? The old code used an inline finalize closure that captured loop variables and mixed result collection with post-processing. Extracting handleTupleResults as a standalone function separates the two concerns — item-level parsing vs. output shaping — and makes the truncation/trim logic easier to reason about for both sync and async paths.

schemas.ts · tuple.test.ts


Object property handling: require optin for absent-key tolerance

Before: handlePropertyResult checked only isOptionalOut — any schema whose output type included undefined (including z.undefined(), z.string().catch("x")) silently accepted absent object keys.
After: Absent keys are only tolerated when the schema is optional on both input and output (isOptionalIn && isOptionalOut). When optin is not "optional", an absent key emits an invalid_type / nonoptional issue — or, if the schema already produced issues of its own, those are surfaced instead.

This affects three code paths: the non-JIT handlePropertyResult helper, the handleCatchall caller, and the JIT codegen in $ZodObjectJIT (which gets a new !isOptionalIn branch that mirrors the non-JIT logic). The change ensures that z.object({ x: z.string().catch("fallback") }).parse({}) now correctly fails, since .catch() sets optout but not optin.

schemas.ts · optional.test.ts · catch.test.ts · partial.test.ts


z.undefined() is no longer implicitly optional

Before: $ZodUndefined set both optin and optout to "optional", making z.undefined() behave as optional input in objects and tuples — z.object({ x: z.undefined() }).parse({}) succeeded silently.
After: z.undefined() has no optin/optout flags, meaning the key must be present in the input (the value must then be undefined). parse({}) fails; parse({ x: undefined }) succeeds.

This is a semantic correction: z.undefined() means "the value must be undefined", not "the key may be absent". Absence should be expressed via .optional() or .default().

schemas.ts · optional.test.ts

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

The previous fix for colinhacks#5229 left a sparse hole when an `.optional()`
slot sat between the input boundary and a later `.default()` — e.g.
`tuple([s, s.optional(), s.default("z")]).parse(["a"])` produced
`["a", <empty>, "z"]`, which fails `1 in r`, JSON-serializes to
`null`, and skips iteration.

Walk trailing items from the end to find the highest index whose
schema fills missing input (`optout !== "optional"`). Run every slot
up to that point so `.optional()` items reached while padding for a
later default produce explicit `undefined`. When the tail is purely
optional, the length-shortening behavior is preserved.
Drop the bespoke `runUntil` reverse-find scan in favor of the same
shape used by `$ZodObject`: run every item with `value: input[i]`
(undefined past the input boundary), let each schema decide what
undefined means, and have `handleTupleResult` swallow errors from
absent optional-out slots — the tuple-index analog of
`handlePropertyResult`'s `key in input` check.

A small post-loop trim drops trailing slots that produced `undefined`
for absent input (preserves the existing length-shortening behaviour
for purely-optional tails like `[s, s.optional()] / [a]`).

Behaviour identical to the previous fix on every case in the suite,
but the parser now reads alongside the object parser instead of
introducing a second, structurally-identical reverse-find boundary.
When a tuple slot past `optStart` rejects `undefined` (e.g. an
`.optional()` chain with a refine that bans `undefined`), swallow the
issue and truncate the result there. Critically, also stop processing
later items so subsequent `.default()` slots do NOT materialize on top
of an already-malformed tail.

Previously the parser swallowed the absent-optional error but kept
running, letting a later default produce a value at an index past the
"missing" slot and yielding e.g. `["alpha", undefined, "d"]` for input
`["alpha"]`. Now the result is `["alpha"]`, matching the array-analog
of $ZodObject's absent-optional-key behaviour.

Implementation: parse all items in parallel, collect into an indexed
results array, then iterate in order during finalize — break on the
first absent-optional rejection, then run the trailing-undefined trim
so optional slots between the last real input and the rejected slot
also collapse away.
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.

No new issues. Reviewed the following changes:

  • Restructured tuple parsing into a two-phase collect-then-finalize architecture: item schemas now always run (collecting results into itemResults[]), then finalize() walks results in order, breaking on the first absent-optional rejection and truncating subsequent slots — preventing later defaults from materializing after a rejected position
  • Removed the handleTupleResult integration for item schemas (kept only for rest elements) and replaced the old inline skip logic (i >= input.length && i >= optStart && item._zod.def.type !== "default") with the more general isOptionalOut && !isPresent check in finalize
  • Added comprehensive edge-case tests: absent-optional rejection with truncation, async variant, dense array assertions for interleaved optional/default, and a guard test confirming required slots still surface issues
  • Added .prefault() parity test

Prior review feedback:

  • .prefault() support — addressed (the new architecture handles it generically via optout rather than checking def.type)
  • .prefault() test coverage — addressed (line 189)

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

// tuple is malformed at that index and any later defaults must NOT fire.
const itemResults: ParsePayload[] = new Array(items.length);
for (let i = 0; i < items.length; i++) {
const r = items[i]._zod.run({ value: input[i], issues: [] }, ctx);
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.

Minor performance note: the old code skipped absent optional schemas entirely (continue), while the new code runs every schema unconditionally — including purely-optional trailing slots that just pass undefined through. This is necessary for .default()/.prefault() to work, but means schemas with expensive refinements on optional trailing elements now do unnecessary work when absent. For typical use this is negligible, but worth being aware of if anyone reports perf regressions on tuples with many trailing optional + refine combinations.

Not blocking — the correctness gain outweighs the marginal cost.

Lock in that:
- multiple trailing `.optional()` elements still trim back to the input
  length (we don't fill the tail with literal `undefined`s)
- explicit `undefined` inside `input.length` IS preserved
- trailing optionals after a default that fires are still trimmed
The post-processing for tuple parse results (in-order walk with
break-on-absent-optional-error, then trailing-undefined trim) was
defined as a closure inside the parse hot path. Hoist it to a
top-level `handleTupleResults` helper, mirroring the `handle*Result`
convention already used for objects and rest items.

Also document why `optStart` is intentionally NOT consulted in
finalize: it's an input-length concern handled by the `too_small`
precheck at the top of parse. Output shaping uses `optout` instead so
that a `.default()` tail item — which sits inside the optStart region
but materializes a defined value — is correctly preserved rather than
dropped or swallowed.

Adds a regression test that explicit `undefined` inside the input is
preserved even when the element schema produces `undefined` as a
valid output (e.g. `z.string().or(z.undefined())`,
`z.string().optional()`, `z.undefined()`). The trim's
\`i >= input.length\` floor is what guards this.
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.

No new issues. Reviewed the following changes:

  • Extracted the inline finalize closure into a standalone handleTupleResults function — pure refactor, no behavioral change, improves readability
  • Added edge-case test ("tuple preserves explicit undefined inside input even for optional-out schemas") verifying that explicit undefined within input.length survives the trailing-trim loop for .or(z.undefined()), .optional(), z.undefined(), and mid-tuple positions
  • Updated comments to reference handleTupleResults and explain the i >= input.length floor in the trailing-trim loop

Pullfrog  | View workflow run | Using Claude Opus𝕏

Bundled into colinhacks#4769 alongside the legitimate `optin = "optional"` fix
for colinhacks#4768 without separate justification. The test added in the same
commit literally has `// z.undefined should NOT be optional` directly
above an assertion saying it is.

`optout` is read by the object parser's absent-key error-swallow path
(colinhacks#5589) and the tuple parser's `optStart`. Marking `z.undefined()`
optout-optional conflates "value type is undefined" with "key may be
absent in inferred output," which is upstream of the confusion in
colinhacks#5654 and colinhacks#5661.

Inference doesn't move: `\$ZodUndefinedInternals` doesn't promote
`optin`/`optout` to required fields, so `z.object({ a: z.undefined() })`
already infers as `{ a: undefined }` regardless. `optin = "optional"`
stays — that one is the JSON-schema `required`-array fix from colinhacks#4768.
Same conflation as the previous commit, on the input side. The motivating
JSON-schema bug from colinhacks#4768 is `.catch()`-specific; the analogous extension
to `z.undefined()` was opportunistic. Under strict semantics
`z.object({ a: z.undefined() })` infers as `{ a: undefined }` (required
key) and the JSON-schema `required` array should agree. Runtime stays
permissive — `z.undefined().parse(undefined)` succeeds whether the source
was an absent key or an explicit `undefined`.
After dropping z.undefined()'s `optin`/`optout = "optional"` flags, a
trailing `z.undefined()` slot is required input — omitting it triggers
`too_small` rather than trimming. Update the assertions accordingly,
and lock in the error shape with inline snapshots since the precheck
abort behaviour (single `too_small`, no element-level errors) is the
exact piece worth pinning.

Also tighten the "required slot fails past input length" test to
snapshot the issue list instead of just `success === false`, so the
shape — single `too_small` from the precheck rather than e.g. an
`invalid_type` from the per-item run — is unambiguous.
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.

Important

The z.undefined() optionality removal is semantically correct but is a breaking change that extends beyond the original tuple-defaults fix. Users relying on z.undefined() or z.union([..., z.undefined()]) to infer key/slot optionality in objects will now get validation errors for absent keys. This should be an intentional decision by a maintainer — flagging for awareness.

Reviewed the following changes:

  • Removed optin/optout = "optional" from $ZodUndefined, making z.undefined() a pure value constraint ("must be undefined") rather than an optionality marker ("slot can be absent") — aligns with z.void() and z.null() which also don't set optionality
  • Updated optional.test.ts assertions: z.undefined() and z.union([z.string(), z.undefined()]) now have optin/optout of undefined (not "optional")
  • Updated tuple tests: z.tuple([z.string(), z.undefined()]).parse(["alpha"]) now correctly produces a too_small error (slot is required), explicit undefined still passes

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

@@ -1358,8 +1358,6 @@ export const $ZodUndefined: core.$constructor<$ZodUndefined> = /*@__PURE__*/ cor
$ZodType.init(inst, def);
inst._zod.pattern = regexes.undefined;
inst._zod.values = new Set([undefined]);
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.

This is the right semantic distinction — z.undefined() is a type constraint ("value must be undefined"), not an optionality marker ("key/slot can be absent"). Consistent with z.void() and z.null() which also don't set optionality.

However, this is a user-visible breaking change: z.object({ key: z.undefined() }) will now require the key to be present (with an explicit undefined value), and z.union([z.string(), z.undefined()]) will no longer make object keys optional. Worth calling out in a changelog or migration note if this PR lands.

Object parsing should only treat key absence as acceptable when the input-side optionality flag says so. Keep optout for output shaping, but stop letting schemas that merely accept or catch undefined make a required key disappear.
@colinhacks colinhacks changed the title fix: apply default values for missing tuple elements fix(v4): align object and tuple optionality handling Apr 29, 2026
@colinhacks colinhacks merged commit b6066b3 into colinhacks:main Apr 29, 2026
6 checks passed
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.

Tuple Ignoring Default Values

2 participants