test(v4): cover object union property inference#5978
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Important
Consider whether the first sub-block is worth keeping given existing coverage in generics.test.ts. The test is otherwise sound.
TL;DR — Adds a regression test asserting that z.infer<> of a union-typed object property matches the standalone union's inferred type, locking in the Zod 4 fix for #2654. Type assertions are correct; one sub-block partially duplicates existing coverage.
Key changes
- Object-property union inference regression test — Covers the original
string | string[]repro plus adjacent array-or-record and enum-or-record-with-optional cases from the issue thread.
Summary | 1 file | 1 commit | base: main ← bounty/zod-2654-object-union-regression
packages/zod/src/v4/classic/tests/object.test.ts
Verified the asserted shapes
Before: No test pinned the in-object-vs-standalone equivalence for these union shapes.
After: Three sub-blocks assert bothtoEqualTypeOf<EventName>()(cross-context equivalence) and the literal shape (string | string[],string[] | Record<string, string>, the enum-or-record union withundefined).
The dual-assertion pattern is good defense in depth — a regression that broke both contexts identically would slip past the cross-equivalence assertion alone, but the literal-shape assertion would still catch it.
The third block's asserted Record<string, "Administrator" | "Writer" | "Readonly" | undefined> is the literal Zod 4 output: $InferZodRecordOutput at packages/zod/src/v4/core/schemas.ts:2834-2839 returns Record<output<K>, output<V>> for non-$partial keys, and $ZodOptional outputs output<T> | undefined. So the test will pass under tsc.
Partial overlap with generics.test.ts
Before:
generics.test.ts:51-58("nested no undefined") already coversz.string().or(z.array(z.string()))insidez.object({ inner })and asserts the resulting shape.
After: The new block 1 (lines 250-259) repeats this. Blocks 2 and 3 (lines 261-284) are net-new coverage.
Not a blocker — co-locating the regression with object.test.ts and explicitly contrasting standalone vs in-object inference has documentary value. But block 1 could be trimmed without losing protection. Up to you.
packages/zod/src/v4/classic/tests/generics.test.ts
Claude Opus | 𝕏
| const EventNameSchema = z.string().or(z.array(z.string())); | ||
| const EventSchema = z.object({ | ||
| name: z.string().or(z.array(z.string())), | ||
| }); | ||
|
|
||
| type EventName = z.infer<typeof EventNameSchema>; | ||
| type EventNameFromObject = z.infer<typeof EventSchema>["name"]; | ||
|
|
||
| expectTypeOf<EventNameFromObject>().toEqualTypeOf<EventName>(); | ||
| expectTypeOf<EventNameFromObject>().toEqualTypeOf<string | string[]>(); |
There was a problem hiding this comment.
This sub-block duplicates generics.test.ts:51-58 ("nested no undefined"), which already pins z.string().or(z.array(z.string())) inside a z.object({ inner }) to { inner: string | string[] }. Not a blocker — the explicit standalone-vs-property contrast adds documentary value — but worth being aware of in case you'd rather trim it.

Summary
z.string().or(z.array(z.string()))object property infers the same output type as the standalone union schema.Validation
corepack pnpm vitest run packages/zod/src/v4/classic/tests/object.test.ts339test files,3813tests, no type errorsRefs #2654
/claim #2654