diff --git a/docs/openapi-ts/plugins/zod.md b/docs/openapi-ts/plugins/zod.md index df445e65dd..ad6db1ae22 100644 --- a/docs/openapi-ts/plugins/zod.md +++ b/docs/openapi-ts/plugins/zod.md @@ -236,6 +236,34 @@ export default { ::: +## Nullish + +By default, non-required object properties generate `.optional()`, which accepts `undefined` values. If you'd like non-required properties to also accept `null`, you can set `useNullish` to `true`. This generates `.nullish()` instead of `.optional()`. + +::: code-group + +```ts [example] +const zFoo = z.object({ + bar: z.nullish(z.string()), +}); +``` + +```js [config] +export default { + input: 'hey-api/backend', // sign up at app.heyapi.dev + output: 'src/client', + plugins: [ + // ...other plugins + { + name: 'zod', + useNullish: true, // [!code ++] + }, + ], +}; +``` + +::: + ## Metadata It's often useful to associate a schema with some additional [metadata](https://zod.dev/metadata) for documentation, code generation, AI structured outputs, form validation, and other purposes. If this is your use case, you can set `metadata` to `true` to generate additional metadata about schemas. diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..f9cff5d06e --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/validators-nullish/zod.gen.ts @@ -0,0 +1,23 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4-mini'; + +export const zBaz = z._default(z.readonly(z.string().check(z.regex(/foo\nbar/))), 'baz'); + +export const zFoo = z._default(z.union([ + z.object({ + foo: z.nullish(z.string().check(z.regex(/^\d{3}-\d{2}-\d{4}$/))), + get bar() { + return z.nullish(z.lazy((): any => zBar)); + }, + get baz() { + return z.nullish(z.array(z.lazy((): any => zFoo))); + }, + qux: z._default(z.nullish(z.int().check(z.gt(0))), 0) + }), + z.null() +]), null); + +export const zBar = z.object({ + foo: z.nullish(zFoo) +}); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..e7f6a981af --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/validators-nullish/zod.gen.ts @@ -0,0 +1,19 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); + +export const zFoo: z.ZodTypeAny = z.union([ + z.object({ + foo: z.string().regex(/^\d{3}-\d{2}-\d{4}$/).nullish(), + bar: z.lazy(() => zBar).nullish(), + baz: z.array(z.lazy(() => zFoo)).nullish(), + qux: z.number().int().gt(0).nullish().default(0) + }), + z.null() +]).default(null); + +export const zBar = z.object({ + foo: zFoo.nullish() +}); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..ea0ddcda3c --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/validators-nullish/zod.gen.ts @@ -0,0 +1,23 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v4'; + +export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); + +export const zFoo = z.union([ + z.object({ + foo: z.nullish(z.string().regex(/^\d{3}-\d{2}-\d{4}$/)), + get bar() { + return z.nullish(z.lazy((): any => zBar)); + }, + get baz() { + return z.nullish(z.array(z.lazy((): any => zFoo))); + }, + qux: z.nullish(z.int().gt(0)).default(0) + }), + z.null() +]).default(null); + +export const zBar = z.object({ + foo: z.nullish(zFoo) +}); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..13144aff2c --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/validators-nullish/zod.gen.ts @@ -0,0 +1,64 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4-mini'; + +export const zBaz = z._default(z.readonly(z.string().check(z.regex(/foo\nbar/))), 'baz'); + +export const zQux = z.record(z.string(), z.object({ + qux: z.nullish(z.string()) +})); + +/** + * This is Foo schema. + */ +export const zFoo = z._default(z.union([ + z.object({ + foo: z.nullish(z.string().check(z.regex(/^\d{3}-\d{2}-\d{4}$/))), + get bar() { + return z.nullish(z.lazy((): any => zBar)); + }, + get baz() { + return z.nullish(z.array(z.lazy((): any => zFoo))); + }, + qux: z._default(z.nullish(z.int().check(z.gt(0))), 0) + }), + z.null() +]), null); + +/** + * This is Bar schema. + */ +export const zBar = z.object({ + foo: z.nullish(zFoo) +}); + +/** + * This is Foo parameter. + */ +export const zFoo2 = z.string(); + +export const zFoo3 = z.object({ + foo: z.nullish(zBar) +}); + +export const zPatchFooData = z.object({ + body: z.object({ + foo: z.nullish(z.string()) + }), + path: z.nullish(z.never()), + query: z.nullish(z.object({ + foo: z.nullish(z.string()), + bar: z.nullish(zBar), + baz: z.nullish(z.object({ + baz: z.nullish(z.string()) + })), + qux: z.nullish(z.iso.date()), + quux: z.nullish(z.iso.datetime()) + })) +}); + +export const zPostFooData = z.object({ + body: zFoo3, + path: z.nullish(z.never()), + query: z.nullish(z.never()) +}); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..8669a24cea --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/validators-nullish/zod.gen.ts @@ -0,0 +1,60 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); + +export const zQux = z.record(z.object({ + qux: z.string().nullish() +})); + +/** + * This is Foo schema. + */ +export const zFoo: z.ZodTypeAny = z.union([ + z.object({ + foo: z.string().regex(/^\d{3}-\d{2}-\d{4}$/).nullish(), + bar: z.lazy(() => zBar).nullish(), + baz: z.array(z.lazy(() => zFoo)).nullish(), + qux: z.number().int().gt(0).nullish().default(0) + }), + z.null() +]).default(null); + +/** + * This is Bar schema. + */ +export const zBar = z.object({ + foo: zFoo.nullish() +}); + +/** + * This is Foo parameter. + */ +export const zFoo2 = z.string(); + +export const zFoo3 = z.object({ + foo: zBar.nullish() +}); + +export const zPatchFooData = z.object({ + body: z.object({ + foo: z.string().nullish() + }), + path: z.never().nullish(), + query: z.object({ + foo: z.string().nullish(), + bar: zBar.nullish(), + baz: z.object({ + baz: z.string().nullish() + }).nullish(), + qux: z.string().date().nullish(), + quux: z.string().datetime().nullish() + }).nullish() +}); + +export const zPostFooData = z.object({ + body: zFoo3, + path: z.never().nullish(), + query: z.never().nullish() +}); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..02a9cd37d9 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/validators-nullish/zod.gen.ts @@ -0,0 +1,64 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v4'; + +export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); + +export const zQux = z.record(z.string(), z.object({ + qux: z.nullish(z.string()) +})); + +/** + * This is Foo schema. + */ +export const zFoo = z.union([ + z.object({ + foo: z.nullish(z.string().regex(/^\d{3}-\d{2}-\d{4}$/)), + get bar() { + return z.nullish(z.lazy((): any => zBar)); + }, + get baz() { + return z.nullish(z.array(z.lazy((): any => zFoo))); + }, + qux: z.nullish(z.int().gt(0)).default(0) + }), + z.null() +]).default(null); + +/** + * This is Bar schema. + */ +export const zBar = z.object({ + foo: z.nullish(zFoo) +}); + +/** + * This is Foo parameter. + */ +export const zFoo2 = z.string(); + +export const zFoo3 = z.object({ + foo: z.nullish(zBar) +}); + +export const zPatchFooData = z.object({ + body: z.object({ + foo: z.nullish(z.string()) + }), + path: z.nullish(z.never()), + query: z.nullish(z.object({ + foo: z.nullish(z.string()), + bar: z.nullish(zBar), + baz: z.nullish(z.object({ + baz: z.nullish(z.string()) + })), + qux: z.nullish(z.iso.date()), + quux: z.nullish(z.iso.datetime()) + })) +}); + +export const zPostFooData = z.object({ + body: zFoo3, + path: z.nullish(z.never()), + query: z.nullish(z.never()) +}); diff --git a/packages/openapi-ts-tests/zod/v3/test/3.0.x.test.ts b/packages/openapi-ts-tests/zod/v3/test/3.0.x.test.ts index b9c080a5f2..f5a122ceb7 100644 --- a/packages/openapi-ts-tests/zod/v3/test/3.0.x.test.ts +++ b/packages/openapi-ts-tests/zod/v3/test/3.0.x.test.ts @@ -48,6 +48,20 @@ for (const zodVersion of zodVersions) { }), description: 'generates validator schemas', }, + { + config: createConfig({ + input: 'validators.json', + output: 'validators-nullish', + plugins: [ + { + compatibilityVersion: zodVersion.compatibilityVersion, + name: 'zod', + useNullish: true, + }, + ], + }), + description: 'generates validator schemas with nullish', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts b/packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts index ca5a271525..814cfa7f86 100644 --- a/packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts +++ b/packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts @@ -48,6 +48,20 @@ for (const zodVersion of zodVersions) { }), description: 'generates validator schemas', }, + { + config: createConfig({ + input: 'validators.yaml', + output: 'validators-nullish', + plugins: [ + { + compatibilityVersion: zodVersion.compatibilityVersion, + name: 'zod', + useNullish: true, + }, + ], + }), + description: 'generates validator schemas with nullish', + }, { config: createConfig({ input: 'validators.yaml', diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..b29f0dfba6 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-nullish/zod.gen.ts @@ -0,0 +1,23 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +export const zBaz = z._default(z.readonly(z.string().check(z.regex(/foo\nbar/))), 'baz'); + +export const zFoo = z._default(z.union([ + z.object({ + foo: z.nullish(z.string().check(z.regex(/^\d{3}-\d{2}-\d{4}$/))), + get bar() { + return z.nullish(z.lazy((): any => zBar)); + }, + get baz() { + return z.nullish(z.array(z.lazy((): any => zFoo))); + }, + qux: z._default(z.nullish(z.int().check(z.gt(0))), 0) + }), + z.null() +]), null); + +export const zBar = z.object({ + foo: z.nullish(zFoo) +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..95d920f50b --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-nullish/zod.gen.ts @@ -0,0 +1,19 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); + +export const zFoo: z.ZodTypeAny = z.union([ + z.object({ + foo: z.string().regex(/^\d{3}-\d{2}-\d{4}$/).nullish(), + bar: z.lazy(() => zBar).nullish(), + baz: z.array(z.lazy(() => zFoo)).nullish(), + qux: z.number().int().gt(0).nullish().default(0) + }), + z.null() +]).default(null); + +export const zBar = z.object({ + foo: zFoo.nullish() +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..535dd9c79a --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-nullish/zod.gen.ts @@ -0,0 +1,23 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); + +export const zFoo = z.union([ + z.object({ + foo: z.nullish(z.string().regex(/^\d{3}-\d{2}-\d{4}$/)), + get bar() { + return z.nullish(z.lazy((): any => zBar)); + }, + get baz() { + return z.nullish(z.array(z.lazy((): any => zFoo))); + }, + qux: z.nullish(z.int().gt(0)).default(0) + }), + z.null() +]).default(null); + +export const zBar = z.object({ + foo: z.nullish(zFoo) +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..d77facff17 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-nullish/zod.gen.ts @@ -0,0 +1,64 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +export const zBaz = z._default(z.readonly(z.string().check(z.regex(/foo\nbar/))), 'baz'); + +export const zQux = z.record(z.string(), z.object({ + qux: z.nullish(z.string()) +})); + +/** + * This is Foo schema. + */ +export const zFoo = z._default(z.union([ + z.object({ + foo: z.nullish(z.string().check(z.regex(/^\d{3}-\d{2}-\d{4}$/))), + get bar() { + return z.nullish(z.lazy((): any => zBar)); + }, + get baz() { + return z.nullish(z.array(z.lazy((): any => zFoo))); + }, + qux: z._default(z.nullish(z.int().check(z.gt(0))), 0) + }), + z.null() +]), null); + +/** + * This is Bar schema. + */ +export const zBar = z.object({ + foo: z.nullish(zFoo) +}); + +/** + * This is Foo parameter. + */ +export const zFoo2 = z.string(); + +export const zFoo3 = z.object({ + foo: z.nullish(zBar) +}); + +export const zPatchFooData = z.object({ + body: z.object({ + foo: z.nullish(z.string()) + }), + path: z.nullish(z.never()), + query: z.nullish(z.object({ + foo: z.nullish(z.string()), + bar: z.nullish(zBar), + baz: z.nullish(z.object({ + baz: z.nullish(z.string()) + })), + qux: z.nullish(z.iso.date()), + quux: z.nullish(z.iso.datetime()) + })) +}); + +export const zPostFooData = z.object({ + body: zFoo3, + path: z.nullish(z.never()), + query: z.nullish(z.never()) +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..243b28a5e1 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-nullish/zod.gen.ts @@ -0,0 +1,60 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); + +export const zQux = z.record(z.object({ + qux: z.string().nullish() +})); + +/** + * This is Foo schema. + */ +export const zFoo: z.ZodTypeAny = z.union([ + z.object({ + foo: z.string().regex(/^\d{3}-\d{2}-\d{4}$/).nullish(), + bar: z.lazy(() => zBar).nullish(), + baz: z.array(z.lazy(() => zFoo)).nullish(), + qux: z.number().int().gt(0).nullish().default(0) + }), + z.null() +]).default(null); + +/** + * This is Bar schema. + */ +export const zBar = z.object({ + foo: zFoo.nullish() +}); + +/** + * This is Foo parameter. + */ +export const zFoo2 = z.string(); + +export const zFoo3 = z.object({ + foo: zBar.nullish() +}); + +export const zPatchFooData = z.object({ + body: z.object({ + foo: z.string().nullish() + }), + path: z.never().nullish(), + query: z.object({ + foo: z.string().nullish(), + bar: zBar.nullish(), + baz: z.object({ + baz: z.string().nullish() + }).nullish(), + qux: z.string().date().nullish(), + quux: z.string().datetime().nullish() + }).nullish() +}); + +export const zPostFooData = z.object({ + body: zFoo3, + path: z.never().nullish(), + query: z.never().nullish() +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-nullish/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-nullish/zod.gen.ts new file mode 100644 index 0000000000..e78ad99469 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-nullish/zod.gen.ts @@ -0,0 +1,64 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz'); + +export const zQux = z.record(z.string(), z.object({ + qux: z.nullish(z.string()) +})); + +/** + * This is Foo schema. + */ +export const zFoo = z.union([ + z.object({ + foo: z.nullish(z.string().regex(/^\d{3}-\d{2}-\d{4}$/)), + get bar() { + return z.nullish(z.lazy((): any => zBar)); + }, + get baz() { + return z.nullish(z.array(z.lazy((): any => zFoo))); + }, + qux: z.nullish(z.int().gt(0)).default(0) + }), + z.null() +]).default(null); + +/** + * This is Bar schema. + */ +export const zBar = z.object({ + foo: z.nullish(zFoo) +}); + +/** + * This is Foo parameter. + */ +export const zFoo2 = z.string(); + +export const zFoo3 = z.object({ + foo: z.nullish(zBar) +}); + +export const zPatchFooData = z.object({ + body: z.object({ + foo: z.nullish(z.string()) + }), + path: z.nullish(z.never()), + query: z.nullish(z.object({ + foo: z.nullish(z.string()), + bar: z.nullish(zBar), + baz: z.nullish(z.object({ + baz: z.nullish(z.string()) + })), + qux: z.nullish(z.iso.date()), + quux: z.nullish(z.iso.datetime()) + })) +}); + +export const zPostFooData = z.object({ + body: zFoo3, + path: z.nullish(z.never()), + query: z.nullish(z.never()) +}); diff --git a/packages/openapi-ts-tests/zod/v4/test/3.0.x.test.ts b/packages/openapi-ts-tests/zod/v4/test/3.0.x.test.ts index b9c080a5f2..f5a122ceb7 100644 --- a/packages/openapi-ts-tests/zod/v4/test/3.0.x.test.ts +++ b/packages/openapi-ts-tests/zod/v4/test/3.0.x.test.ts @@ -48,6 +48,20 @@ for (const zodVersion of zodVersions) { }), description: 'generates validator schemas', }, + { + config: createConfig({ + input: 'validators.json', + output: 'validators-nullish', + plugins: [ + { + compatibilityVersion: zodVersion.compatibilityVersion, + name: 'zod', + useNullish: true, + }, + ], + }), + description: 'generates validator schemas with nullish', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts b/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts index 8d67fe7e23..5953f4cad3 100644 --- a/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts +++ b/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts @@ -55,6 +55,20 @@ for (const zodVersion of zodVersions) { }), description: 'generates validator schemas', }, + { + config: createConfig({ + input: 'validators.yaml', + output: 'validators-nullish', + plugins: [ + { + compatibilityVersion: zodVersion.compatibilityVersion, + name: 'zod', + useNullish: true, + }, + ], + }), + description: 'generates validator schemas with nullish', + }, { config: createConfig({ input: 'validators.yaml', diff --git a/packages/openapi-ts/src/plugins/zod/config.ts b/packages/openapi-ts/src/plugins/zod/config.ts index 6fc57bc03b..8f6082b974 100644 --- a/packages/openapi-ts/src/plugins/zod/config.ts +++ b/packages/openapi-ts/src/plugins/zod/config.ts @@ -14,6 +14,7 @@ export const defaultConfig: ZodPlugin['Config'] = { comments: true, exportFromIndex: false, metadata: false, + useNullish: false, }, handler, name: 'zod', diff --git a/packages/openapi-ts/src/plugins/zod/constants.ts b/packages/openapi-ts/src/plugins/zod/constants.ts index 58875dacaa..340755dbd2 100644 --- a/packages/openapi-ts/src/plugins/zod/constants.ts +++ b/packages/openapi-ts/src/plugins/zod/constants.ts @@ -1,6 +1,8 @@ // TODO: this is inaccurate, it combines identifiers for all supported versions export const identifiers = { + ZodMiniNullish: 'ZodMiniNullish', ZodMiniOptional: 'ZodMiniOptional', + ZodNullish: 'ZodNullish', ZodOptional: 'ZodOptional', _default: '_default', and: 'and', @@ -39,6 +41,7 @@ export const identifiers = { never: 'never', null: 'null', nullable: 'nullable', + nullish: 'nullish', number: 'number', object: 'object', optional: 'optional', diff --git a/packages/openapi-ts/src/plugins/zod/mini/plugin.ts b/packages/openapi-ts/src/plugins/zod/mini/plugin.ts index f4092b3724..3b635292a5 100644 --- a/packages/openapi-ts/src/plugins/zod/mini/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/mini/plugin.ts @@ -139,8 +139,11 @@ export const irSchemaToAst = ({ } if (optional) { - ast.expression = $(z).attr(identifiers.optional).call(ast.expression); - ast.typeName = identifiers.ZodMiniOptional; + const method = plugin.config.useNullish ? identifiers.nullish : identifiers.optional; + ast.expression = $(z).attr(method).call(ast.expression); + ast.typeName = plugin.config.useNullish + ? identifiers.ZodMiniNullish + : identifiers.ZodMiniOptional; } if (schema.default !== undefined) { diff --git a/packages/openapi-ts/src/plugins/zod/types.ts b/packages/openapi-ts/src/plugins/zod/types.ts index 83ec136639..52fbb1f949 100644 --- a/packages/openapi-ts/src/plugins/zod/types.ts +++ b/packages/openapi-ts/src/plugins/zod/types.ts @@ -338,6 +338,14 @@ export type UserConfig = Plugin.Name<'zod'> & enabled?: boolean; }; }; + /** + * Use `.nullish()` instead of `.optional()` for non-required object + * properties? When enabled, non-required properties will accept both + * `null` and `undefined` values. + * + * @default false + */ + useNullish?: boolean; /** * Configuration for webhook-specific Zod schemas. * @@ -550,6 +558,14 @@ export type Config = Plugin.Name<'zod'> & case: Casing; }; }; + /** + * Use `.nullish()` instead of `.optional()` for non-required object + * properties? When enabled, non-required properties will accept both + * `null` and `undefined` values. + * + * @default false + */ + useNullish: boolean; /** * Configuration for webhook-specific Zod schemas. * diff --git a/packages/openapi-ts/src/plugins/zod/v3/plugin.ts b/packages/openapi-ts/src/plugins/zod/v3/plugin.ts index dc272312dd..4b737351a4 100644 --- a/packages/openapi-ts/src/plugins/zod/v3/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v3/plugin.ts @@ -131,7 +131,8 @@ export const irSchemaToAst = ({ } if (optional) { - ast.expression = ast.expression.attr(identifiers.optional).call(); + const method = plugin.config.useNullish ? identifiers.nullish : identifiers.optional; + ast.expression = ast.expression.attr(method).call(); } if (schema.default !== undefined) { diff --git a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts index 40df25da0d..6bb3e1e34d 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts @@ -142,8 +142,9 @@ export const irSchemaToAst = ({ } if (optional) { - ast.expression = $(z).attr(identifiers.optional).call(ast.expression); - ast.typeName = identifiers.ZodOptional; + const method = plugin.config.useNullish ? identifiers.nullish : identifiers.optional; + ast.expression = $(z).attr(method).call(ast.expression); + ast.typeName = plugin.config.useNullish ? identifiers.ZodNullish : identifiers.ZodOptional; } if (schema.default !== undefined) {