Skip to content

Commit 36fe14e

Browse files
Fix optionality of schemas (#4769)
* Mark catch as input optional * merge all changes into one PR * add output optional example * Improvements * WIP * WIP * Remove never changes --------- Co-authored-by: Colin McDonnell <[email protected]>
1 parent 7a5838d commit 36fe14e

File tree

5 files changed

+112
-10
lines changed

5 files changed

+112
-10
lines changed

packages/zod/src/v4/classic/tests/optional.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,26 @@ test("optionality", () => {
3737
const e = z.string().default("asdf").nullable();
3838
expect(e._zod.optin).toEqual("optional");
3939
expect(e._zod.optout).toEqual(undefined);
40+
41+
// z.undefined should NOT be optional
42+
const f = z.undefined();
43+
expect(f._zod.optin).toEqual("optional");
44+
expect(f._zod.optout).toEqual("optional");
45+
expectTypeOf<typeof f._zod.optin>().toEqualTypeOf<"optional" | undefined>();
46+
expectTypeOf<typeof f._zod.optout>().toEqualTypeOf<"optional" | undefined>();
47+
48+
// z.union should be optional if any of the types are optional
49+
const g = z.union([z.string(), z.undefined()]);
50+
expect(g._zod.optin).toEqual("optional");
51+
expect(g._zod.optout).toEqual("optional");
52+
expectTypeOf<typeof g._zod.optin>().toEqualTypeOf<"optional" | undefined>();
53+
expectTypeOf<typeof g._zod.optout>().toEqualTypeOf<"optional" | undefined>();
54+
55+
const h = z.union([z.string(), z.optional(z.string())]);
56+
expect(h._zod.optin).toEqual("optional");
57+
expect(h._zod.optout).toEqual("optional");
58+
expectTypeOf<typeof h._zod.optin>().toEqualTypeOf<"optional">();
59+
expectTypeOf<typeof h._zod.optout>().toEqualTypeOf<"optional">();
4060
});
4161

4262
test("pipe optionality", () => {

packages/zod/src/v4/classic/tests/to-json-schema.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe("toJSONSchema", () => {
3131
expect(z.toJSONSchema(z.undefined())).toMatchInlineSnapshot(`
3232
{
3333
"$schema": "https://json-schema.org/draft/2020-12/schema",
34-
"type": "null",
34+
"not": {},
3535
}
3636
`);
3737
expect(z.toJSONSchema(z.any())).toMatchInlineSnapshot(`
@@ -1856,6 +1856,11 @@ test("input type", () => {
18561856
c: z.string().default("hello"),
18571857
d: z.string().nullable(),
18581858
e: z.string().prefault("hello"),
1859+
f: z.string().catch("hello"),
1860+
g: z.never(),
1861+
h: z.undefined(),
1862+
i: z.union([z.string(), z.number().default(2)]),
1863+
j: z.union([z.string(), z.string().optional()]),
18591864
});
18601865
expect(z.toJSONSchema(schema, { io: "input" })).toMatchInlineSnapshot(`
18611866
{
@@ -1885,10 +1890,42 @@ test("input type", () => {
18851890
"default": "hello",
18861891
"type": "string",
18871892
},
1893+
"f": {
1894+
"default": "hello",
1895+
"type": "string",
1896+
},
1897+
"g": {
1898+
"not": {},
1899+
},
1900+
"h": {
1901+
"not": {},
1902+
},
1903+
"i": {
1904+
"anyOf": [
1905+
{
1906+
"type": "string",
1907+
},
1908+
{
1909+
"default": 2,
1910+
"type": "number",
1911+
},
1912+
],
1913+
},
1914+
"j": {
1915+
"anyOf": [
1916+
{
1917+
"type": "string",
1918+
},
1919+
{
1920+
"type": "string",
1921+
},
1922+
],
1923+
},
18881924
},
18891925
"required": [
18901926
"a",
18911927
"d",
1928+
"g",
18921929
],
18931930
"type": "object",
18941931
}
@@ -1921,12 +1958,46 @@ test("input type", () => {
19211958
"e": {
19221959
"type": "string",
19231960
},
1961+
"f": {
1962+
"default": "hello",
1963+
"type": "string",
1964+
},
1965+
"g": {
1966+
"not": {},
1967+
},
1968+
"h": {
1969+
"not": {},
1970+
},
1971+
"i": {
1972+
"anyOf": [
1973+
{
1974+
"type": "string",
1975+
},
1976+
{
1977+
"default": 2,
1978+
"type": "number",
1979+
},
1980+
],
1981+
},
1982+
"j": {
1983+
"anyOf": [
1984+
{
1985+
"type": "string",
1986+
},
1987+
{
1988+
"type": "string",
1989+
},
1990+
],
1991+
},
19241992
},
19251993
"required": [
19261994
"a",
19271995
"c",
19281996
"d",
19291997
"e",
1998+
"f",
1999+
"g",
2000+
"i",
19302001
],
19312002
"type": "object",
19322003
}

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,6 +1238,8 @@ export const $ZodUndefined: core.$constructor<$ZodUndefined> = /*@__PURE__*/ cor
12381238
$ZodType.init(inst, def);
12391239
inst._zod.pattern = regexes.undefined;
12401240
inst._zod.values = new Set([undefined]);
1241+
inst._zod.optin = "optional";
1242+
inst._zod.optout = "optional";
12411243

12421244
inst._zod.parse = (payload, _ctx) => {
12431245
const input = payload.value;
@@ -1374,7 +1376,6 @@ export interface $ZodNever extends $ZodType {
13741376

13751377
export const $ZodNever: core.$constructor<$ZodNever> = /*@__PURE__*/ core.$constructor("$ZodNever", (inst, def) => {
13761378
$ZodType.init(inst, def);
1377-
13781379
inst._zod.parse = (payload, _ctx) => {
13791380
payload.issues.push({
13801381
expected: "never",
@@ -1882,11 +1883,17 @@ export interface $ZodUnionDef<Options extends readonly SomeType[] = readonly $Zo
18821883
options: Options;
18831884
}
18841885

1886+
type IsOptionalIn<T extends SomeType> = T extends OptionalInSchema ? true : false;
1887+
type IsOptionalOut<T extends SomeType> = T extends OptionalOutSchema ? true : false;
1888+
18851889
export interface $ZodUnionInternals<T extends readonly SomeType[] = readonly $ZodType[]>
18861890
extends $ZodTypeInternals<$InferUnionOutput<T[number]>, $InferUnionInput<T[number]>> {
18871891
def: $ZodUnionDef<T>;
18881892
isst: errors.$ZodIssueInvalidUnion;
18891893
pattern: T[number]["_zod"]["pattern"];
1894+
// if any element in the union is optional, then the union is optional
1895+
optin: IsOptionalIn<T[number]> extends false ? "optional" | undefined : "optional";
1896+
optout: IsOptionalOut<T[number]> extends false ? "optional" | undefined : "optional";
18901897
}
18911898

18921899
export interface $ZodUnion<T extends readonly SomeType[] = readonly $ZodType[]> extends $ZodType {
@@ -1914,6 +1921,14 @@ function handleUnionResults(results: ParsePayload[], final: ParsePayload, inst:
19141921
export const $ZodUnion: core.$constructor<$ZodUnion> = /*@__PURE__*/ core.$constructor("$ZodUnion", (inst, def) => {
19151922
$ZodType.init(inst, def);
19161923

1924+
util.defineLazy(inst._zod, "optin", () =>
1925+
def.options.some((o) => o._zod.optin === "optional") ? "optional" : undefined
1926+
);
1927+
1928+
util.defineLazy(inst._zod, "optout", () =>
1929+
def.options.some((o) => o._zod.optout === "optional") ? "optional" : undefined
1930+
);
1931+
19171932
util.defineLazy(inst._zod, "values", () => {
19181933
if (def.options.every((o) => o._zod.values)) {
19191934
return new Set<util.Primitive>(def.options.flatMap((option) => Array.from(option._zod.values!)));
@@ -3272,7 +3287,7 @@ export interface $ZodCatch<T extends SomeType = $ZodType> extends $ZodType {
32723287

32733288
export const $ZodCatch: core.$constructor<$ZodCatch> = /*@__PURE__*/ core.$constructor("$ZodCatch", (inst, def) => {
32743289
$ZodType.init(inst, def);
3275-
util.defineLazy(inst._zod, "optin", () => def.innerType._zod.optin);
3290+
inst._zod.optin = "optional";
32763291
util.defineLazy(inst._zod, "optout", () => def.innerType._zod.optout);
32773292
util.defineLazy(inst._zod, "values", () => def.innerType._zod.values);
32783293

packages/zod/src/v4/core/to-json-schema.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,6 @@ export class JSONSchemaGenerator {
207207
}
208208
break;
209209
}
210-
case "undefined": {
211-
const json = _json as JSONSchema.NullSchema;
212-
json.type = "null";
213-
break;
214-
}
215210
case "null": {
216211
_json.type = "null";
217212
break;
@@ -222,6 +217,7 @@ export class JSONSchemaGenerator {
222217
case "unknown": {
223218
break;
224219
}
220+
case "undefined":
225221
case "never": {
226222
_json.not = {};
227223
break;

play.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
import * as z from "zod/v4";
1+
import { z } from "zod/v4";
22

3-
z;
3+
console.dir(z.object({ a: z.never() }).parse({}), { depth: null });

0 commit comments

Comments
 (0)