Skip to content

Commit 9446b5c

Browse files
authored
fix: preserve undefined prefault outputs and object keys (#6587)
1 parent 0c483c5 commit 9446b5c

5 files changed

Lines changed: 110 additions & 16 deletions

File tree

‎packages/zod/src/v4/classic/tests/prefault.test.ts‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,76 @@
11
import { expect, expectTypeOf, test } from "vitest";
22
import { z } from "zod/v4";
33

4+
test.each([false, true])("undefined prefault preserves object keys (jitless: %s)", async (jitless) => {
5+
const previous = z.config().jitless;
6+
z.config({ jitless });
7+
try {
8+
let calls = 0;
9+
const field = z.union([z.string(), z.undefined()]).prefault(() => {
10+
calls++;
11+
return undefined;
12+
});
13+
const schema = z.object({ a: field });
14+
expectTypeOf<z.output<typeof field>>().toEqualTypeOf<string | undefined>();
15+
expectTypeOf<z.input<typeof schema>>().toEqualTypeOf<{ a?: string | undefined }>();
16+
expectTypeOf<z.output<typeof schema>>().toEqualTypeOf<{ a: string | undefined }>();
17+
expect(schema.parse({})).toStrictEqual({ a: undefined });
18+
expect(calls).toBe(1);
19+
expect(schema.parse({ a: undefined })).toStrictEqual({ a: undefined });
20+
expect(calls).toBe(2);
21+
expect(await schema.parseAsync({})).toStrictEqual({ a: undefined });
22+
expect(calls).toBe(3);
23+
expect(schema.parse({ a: "value" })).toStrictEqual({ a: "value" });
24+
expect(schema.safeParse({ a: 123 }).success).toBe(false);
25+
expect(calls).toBe(3);
26+
27+
const literal = z.prefault(z.string().optional(), undefined);
28+
expect(z.core.compileFn(z.object({ a: literal }))({})).toStrictEqual({ a: undefined });
29+
for (const wrapped of [literal, literal.readonly(), literal.nullable(), z.lazy(() => literal)]) {
30+
expect(z.object({ a: wrapped }).parse({})).toStrictEqual({ a: undefined });
31+
}
32+
expect(z.object({ a: literal.optional() }).parse({})).toStrictEqual({});
33+
expect(z.object({ a: literal.optional() }).parse({ a: undefined })).toStrictEqual({ a: undefined });
34+
expect(z.tuple([literal]).parse([])).toStrictEqual([undefined]);
35+
} finally {
36+
z.config({ jitless: previous });
37+
}
38+
});
39+
40+
test("prefault preserves transformed undefined output", async () => {
41+
const field = z
42+
.string()
43+
.transform(() => undefined)
44+
.prefault("fallback");
45+
expectTypeOf<z.output<typeof field>>().toEqualTypeOf<undefined>();
46+
expect(field.parse(undefined)).toBeUndefined();
47+
const schema = z.object({ a: field, optional: z.string().optional() });
48+
expectTypeOf<z.output<typeof schema>>().toEqualTypeOf<{ a: undefined; optional?: string | undefined }>();
49+
expect(schema.parse({})).toStrictEqual({ a: undefined });
50+
expect(z.core.compileFn(schema)({})).toStrictEqual({ a: undefined });
51+
expect(z.compile(schema).parse({})).toStrictEqual({ a: undefined });
52+
expect(
53+
await z
54+
.object({
55+
a: z
56+
.string()
57+
.transform(async () => undefined)
58+
.prefault("fallback"),
59+
})
60+
.parseAsync({})
61+
).toStrictEqual({ a: undefined });
62+
expect(
63+
z
64+
.object({
65+
a: z
66+
.string()
67+
.prefault("fallback")
68+
.transform(() => undefined),
69+
})
70+
.parse({})
71+
).toStrictEqual({ a: undefined });
72+
});
73+
474
test("basic prefault", () => {
575
const a = z.prefault(z.string().trim(), " default ");
676
expect(a).toBeInstanceOf(z.ZodPrefault);

‎packages/zod/src/v4/core/compile.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,9 +1270,9 @@ function generateObjectCheck(
12701270
}
12711271
// else: strip mode (no catchall) - unknown keys ignored, only include known keys
12721272

1273-
// Shape keys in declared order, then unknown keys in for...in order. A middle-rung key is included iff present on the input, else iff its output is not undefined.
1273+
// defaulted required outputs keep their keys even when undefined
12741274
const outputVar = newVar(ctx);
1275-
const hasConditionalKeys = allKeys.some((k) => mayOutputUndefined(propShape[k]!) || dropsWhenAbsent(propShape[k]!));
1275+
const hasConditionalKeys = allKeys.some((k) => mayOmitUndefined(propShape[k]!) || dropsWhenAbsent(propShape[k]!));
12761276

12771277
// Assert mode: every declared key is validated above, so the output literal and the unknown-key copy are pure waste. A `never` catchall already emitted its rejection loop; a schema catchall still has to validate the values it would otherwise have stored.
12781278
if (!buildsValue) {
@@ -1301,7 +1301,7 @@ function generateObjectCheck(
13011301
const out = propOutputs.get(k);
13021302
if (dropsWhenAbsent(propShape[k]!)) {
13031303
doc.write(`if (${kx} in ${accessor}) ${outputVar}[${kx}] = ${out};`);
1304-
} else if (mayOutputUndefined(propShape[k]!)) {
1304+
} else if (mayOmitUndefined(propShape[k]!)) {
13051305
doc.write(`if (${out} !== undefined || ${kx} in ${accessor}) ${outputVar}[${kx}] = ${out};`);
13061306
} else {
13071307
doc.write(`${outputVar}[${kx}] = ${out};`);
@@ -1464,9 +1464,11 @@ function dropsWhenAbsent(schema: SomeType): boolean {
14641464
return schema._zod.optin === "optional" && schema._zod.optout === "optional";
14651465
}
14661466

1467-
// Whether a schema's success-path output can be `undefined`. Object output
1468-
// assembly gives such props the runtime's value-or-presence inclusion rule;
1469-
// everything else keeps the unconditional object-literal slot.
1467+
function mayOmitUndefined(schema: SomeType): boolean {
1468+
return (schema._zod.optin !== "defaulted" || schema._zod.optout === "optional") && mayOutputUndefined(schema);
1469+
}
1470+
1471+
// whether a schema's success-path output can be undefined
14701472
function mayOutputUndefined(schema: SomeType): boolean {
14711473
const def = schema._zod.def as {
14721474
type: string;

‎packages/zod/src/v4/core/schemas.ts‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1983,7 +1983,7 @@ function handlePropertyResult(
19831983
}
19841984

19851985
if (result.value === undefined) {
1986-
if (isPresent) {
1986+
if (isPresent || (optin === "defaulted" && !isOptionalOut)) {
19871987
(final.value as any)[key] = undefined;
19881988
}
19891989
} else {
@@ -2314,16 +2314,16 @@ export const $ZodObjectJIT: core.$constructor<$ZodObject> = /*@__PURE__*/ core.$
23142314
doc.write(`
23152315
if (${id}.issues.length) {${prefixStr(id, k)}
23162316
}
2317-
2318-
if (${id}.value === undefined) {
2319-
if (${isPresent}) {
2320-
newResult[${k}] = undefined;
2321-
}
2322-
} else {
2317+
`);
2318+
if (optin === "defaulted") {
2319+
doc.write(`newResult[${k}] = ${id}.value;`);
2320+
} else {
2321+
doc.write(`
2322+
if (${id}.value !== undefined || ${isPresent}) {
23232323
newResult[${k}] = ${id}.value;
23242324
}
2325-
23262325
`);
2326+
}
23272327
}
23282328
}
23292329

@@ -4077,7 +4077,7 @@ export interface $ZodPrefaultDef<T extends SomeType = $ZodType> extends $ZodType
40774077
}
40784078

40794079
export interface $ZodPrefaultInternals<T extends SomeType = $ZodType>
4080-
extends $ZodTypeInternals<util.NoUndefined<core.output<T>>, core.input<T> | undefined> {
4080+
extends $ZodTypeInternals<core.output<T>, core.input<T> | undefined> {
40814081
def: $ZodPrefaultDef<T>;
40824082
optin: "defaulted";
40834083
optout?: "optional" | undefined;

‎packages/zod/src/v4/mini/schemas.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1597,7 +1597,7 @@ export const ZodMiniPrefault: core.$constructor<ZodMiniPrefault> = /*@__PURE__*/
15971597
// @__NO_SIDE_EFFECTS__
15981598
export function prefault<T extends SomeType>(
15991599
innerType: T,
1600-
defaultValue: util.NoUndefined<core.input<T>> | (() => util.NoUndefined<core.input<T>>)
1600+
defaultValue: core.input<T> | (() => core.input<T>)
16011601
): ZodMiniPrefault<T> {
16021602
return new ZodMiniPrefault({
16031603
type: "prefault",

‎packages/zod/src/v4/mini/tests/index.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,28 @@ test("z.iso.duration", () => {
165165
expect(z.safeParse(b, d2).success).toEqual(false);
166166
});
167167

168+
test("z.prefault preserves undefined output", async () => {
169+
const field = z.prefault(z.union([z.string(), z.undefined()]), () => undefined);
170+
const schema = z.object({ a: field });
171+
expectTypeOf<z.output<typeof field>>().toEqualTypeOf<string | undefined>();
172+
expectTypeOf<z.output<typeof schema>>().toEqualTypeOf<{ a: string | undefined }>();
173+
expect(z.parse(schema, {})).toStrictEqual({ a: undefined });
174+
expect(z.parse(schema, { a: undefined })).toStrictEqual({ a: undefined });
175+
expect(z.parse(schema, { a: "value" })).toStrictEqual({ a: "value" });
176+
expect(z.safeParse(schema, { a: 123 }).success).toBe(false);
177+
expect(await z.parseAsync(schema, {})).toStrictEqual({ a: undefined });
178+
expect(z.parse(z.object({ a: z.prefault(z.optional(z.string()), undefined) }), {})).toStrictEqual({ a: undefined });
179+
const transformed = z.prefault(
180+
z.pipe(
181+
z.string(),
182+
z.transform(() => undefined)
183+
),
184+
"fallback"
185+
);
186+
expectTypeOf<z.output<typeof transformed>>().toEqualTypeOf<undefined>();
187+
expect(z.parse(z.object({ a: transformed }), {})).toStrictEqual({ a: undefined });
188+
});
189+
168190
test("z.undefined", () => {
169191
const a = z.undefined();
170192
expect(z.parse(a, undefined)).toEqual(undefined);

0 commit comments

Comments
 (0)