Skip to content

feat(v4): clearer error for missing required object properties#5938

Open
dokson wants to merge 2 commits into
colinhacks:mainfrom
dokson:feat/clearer-missing-property-error
Open

feat(v4): clearer error for missing required object properties#5938
dokson wants to merge 2 commits into
colinhacks:mainfrom
dokson:feat/clearer-missing-property-error

Conversation

@dokson

@dokson dokson commented May 3, 2026

Copy link
Copy Markdown
Contributor

Refs #5917 (and the follow-up comment). Pure DX — does not change validation behavior.

Problem

When a required key is absent from an object input, Zod emits the same issue as an explicit z.nonoptional(...) rejecting undefined:

Invalid input: expected nonoptional, received undefined

Since v4.4.0 made z.undefined() properties required, this message has been recurring confusion (e.g. vercel/workflow#1901). Users read it as a Zod bug — "I never sent undefined, the key isn't there" — and don't immediately see that the fix is .optional() on their schema.

The two cases have different root causes but share one message:

Case Site Real meaning
Required key absent in z.object core/schemas.ts:1764, :2050 property must be present
z.nonoptional(...) receiving undefined core/schemas.ts:3760 value was sent and rejected

Fix

Distinguish the two by tagging the missing-key issue with received: "missing", and render that case as Invalid input: missing required property in the English locale. The explicit-undefined case keeps its current message — there, "received undefined" is accurate.

 // core/schemas.ts — both missing-key emit sites
 final.issues.push({
   code: "invalid_type",
   expected: "nonoptional",
   input: undefined,
   path: [key],
+  received: "missing",
 });
 // locales/en.ts
 case "invalid_type": {
+  if (issue.received === "missing") {
+    return `Invalid input: missing required property`;
+  }
   const expected = TypeDictionary[issue.expected] ?? issue.expected;
   const receivedType = util.parsedType(issue.input);
   const received = TypeDictionary[receivedType] ?? receivedType;
   return `Invalid input: expected ${expected}, received ${received}`;
 }

$ZodIssueInvalidType.received already existed informally (used by primitive parsers to mark "NaN", "Infinity", "Invalid Date"); this PR formalizes it on the type and adds "missing".

Effect on the Vercel Workflow repro

const Run = z.discriminatedUnion("status", [
  z.object({
    status: z.enum(["pending", "running"]),
    output: z.undefined(),
    error: z.undefined(),
    completedAt: z.undefined(),
  }),
  // ...
]);
Run.parse({ status: "running" });

Before:

run.output: Invalid input: expected nonoptional, received undefined
run.error: Invalid input: expected nonoptional, received undefined
run.completedAt: Invalid input: expected nonoptional, received undefined

After:

run.output: Invalid input: missing required property
run.error: Invalid input: missing required property
run.completedAt: Invalid input: missing required property

The actual fix for that case still belongs to vercel/workflow (those fields need .optional() per the v4.4.0 release notes), but at least the error now points the user there instead of looking like a Zod bug.

Backward compatibility

  • code stays "invalid_type", expected stays "nonoptional" — consumers matching by code/expected keep working.
  • received is optional and additive on $ZodIssueInvalidType.
  • Other locales fall back to the previous message until updated — no breakage there either.

Tests

packages/zod/src/v4/classic/tests/missing-required-property.test.ts:

  1. Missing key under JIT → new message + received: "missing".
  2. Missing key under jitless → identical issue (covers both emit sites).
  3. Explicit z.string().optional().nonoptional() rejecting undefined → unchanged message, no received field (non-regression).
  4. Discriminated-union repro from Vercel Workflow → all three missing fields get the new message.

Updated existing snapshots that asserted the old string for missing-key cases:

  • optional.test.ts ("object absent keys require optin optional")
  • partial.test.ts ("catch/prefault/default")

nonoptional.test.ts snapshots are deliberately untouched: they go through handleNonOptionalResult (explicit-undefined path), not the missing-key path.

Local results:

Test Files  341 passed
     Tests  3809 passed
Type Errors no errors

pnpm --filter zod build → ✅

(One flaky redos checker test fails locally on Windows, same flake mentioned in #5923 / fixed upstream by #5919 — unrelated.)

Note about CI

Test with TypeScript latest will likely show red for the same baseUrl deprecation that affects every PR opened since e58ea4d; addressed by #5921.

…hacks#5917)

When a required key is absent from an object, the `invalid_type` issue
now carries `received: "missing"` and the English locale renders it as
`Invalid input: missing required property`.

Previously every missing-key case shared the same message as an explicit
`z.nonoptional()` rejecting `undefined` ("expected nonoptional, received
undefined"), even though the user never sent any value. Since the
v4.4.0 change that makes `z.undefined()` properties required, this
ambiguity has surfaced repeatedly (e.g. vercel/workflow#1901): users
read the error as a Zod bug rather than as "this key must be present".

Behavior:
- Missing key (`{ value: z.undefined() }` parsed against `{}`):
  `Invalid input: missing required property` + `received: "missing"`.
- Explicit `z.nonoptional(...)` receiving `undefined`: unchanged
  ("expected nonoptional, received undefined"), since the value really
  was received.

The new field is additive on `$ZodIssueInvalidType`. Code stays
`"invalid_type"` and `expected` stays `"nonoptional"`, so consumers
matching by code/expected keep working. Other locales fall back to the
existing message until updated.
@pullfrog

pullfrog Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor

TL;DR — When a required object key is absent from the input, Zod now emits "Invalid input: missing required property" instead of the confusing "expected nonoptional, received undefined". The explicit z.nonoptional() path (where undefined was actually sent) keeps its existing message. Pure DX improvement — no behavior change.

Key changes

  • Tag missing-key issues with received: "missing" — both the JIT and jitless emit sites in $ZodObject now set this field, letting the locale distinguish "key absent" from "value is undefined".
  • Render a clearer message in the English locale — the invalid_type handler short-circuits on received === "missing" to produce a human-friendly string.
  • Formalize received on $ZodIssueInvalidType — adds the optional field to the type with known string literals ("missing", "NaN", "Infinity", "Invalid Date").
  • Add dedicated test file for missing-required-property — covers JIT, jitless, explicit-nonoptional non-regression, and a discriminated-union repro.
  • Update existing snapshotsoptional.test.ts and partial.test.ts snapshots reflect the new message.

Summary | 6 files | 2 commits | base: mainfeat/clearer-missing-property-error


Distinct error for absent keys vs. explicit undefined

Before: Both "key missing from input" and "value sent as undefined" produced Invalid input: expected nonoptional, received undefined.
After: Missing keys now emit Invalid input: missing required property with received: "missing" on the issue object; the explicit-undefined path is unchanged.

The fix tags both emit sites in schemas.ts (the interpreted path at line 1774 and the JIT-compiled path at line 2055) with received: "missing". The English locale checks this field first and returns the new message, falling through to the existing format for all other invalid_type issues. The $ZodIssueInvalidType interface gains an optional received field that formalizes values already used informally by primitive parsers.

Why two emit sites? Object validation has a JIT-compiled fast path that generates code at schema-creation time, and a fallback interpreted path used when JIT is disabled. Both must emit the same issue shape for consistency.

core/schemas.ts · core/errors.ts · locales/en.ts · missing-required-property.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.

Pullfrog  | View workflow run | Using Claude Opus𝕏

…g-property-error

# Conflicts:
#	packages/zod/src/v4/classic/tests/partial.test.ts
@dokson

dokson commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

@colinhacks just rebased on top of main to resolve the conflicts from #5937/#5941.

I think this PR still has merit even after those landed: #5941 made catch/default/prefault/preprocess tolerant of absent keys, so they no longer reach the error path at all. That actually narrows the scope of this PR's received: "missing" to the cases where an error should be produced — i.e. genuinely required fields without a fallback (e.g. z.string() or z.undefined() on an absent key). The message is now strictly reserved for true missing-required cases, which makes it semantically more precise than before.

Happy to adjust if you'd prefer a different framing.

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