diff --git a/.changeset/fix-zod-discriminated-union-multi-value.md b/.changeset/fix-zod-discriminated-union-multi-value.md new file mode 100644 index 0000000000..90b83b0597 --- /dev/null +++ b/.changeset/fix-zod-discriminated-union-multi-value.md @@ -0,0 +1,5 @@ +--- +'@hey-api/openapi-ts': patch +--- + +fix(zod): generate z.discriminatedUnion when discriminator maps multiple values to the same schema diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..b3327ba5b3 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4-mini'; + +export const zBar = z.object({ + code: z.optional(z.union([z.literal(1), z.literal(2)])) +}); + +export const zBaz = z.object({ + code: z.optional(z.literal(3)) +}); + +export const zFoo = z.discriminatedUnion('code', [ + z.extend(zBar, { code: z.union([z.literal(1), z.literal(2)]) }), + z.extend(zBaz, { code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..a7e388b534 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4-mini'; + +export const zBar = z.object({ + foo: z.optional(z.enum(['one', 'two'])) +}); + +export const zBaz = z.object({ + foo: z.optional(z.enum(['three'])) +}); + +export const zSpæcial = z.object({ + foo: z.optional(z.enum(['four'])) +}); + +export const zFoo = z.discriminatedUnion('foo', [ + z.extend(zBar, { foo: z.enum(['one', 'two']) }), + z.extend(zBaz, { foo: z.literal('three') }), + z.extend(zSpæcial, { foo: z.literal('four') }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..b9fa614597 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,17 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zBar = z.object({ + code: z.union([z.literal(1), z.literal(2)]).optional() +}); + +export const zBaz = z.object({ + code: z.literal(3).optional() +}); + +export const zFoo = z.discriminatedUnion('code', [ + zBar.extend({ code: z.literal(1) }), + zBar.extend({ code: z.literal(2) }), + zBaz.extend({ code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..5b51ab9abe --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zBar = z.object({ + foo: z.enum(['one', 'two']).optional() +}); + +export const zBaz = z.object({ + foo: z.enum(['three']).optional() +}); + +export const zSpæcial = z.object({ + foo: z.enum(['four']).optional() +}); + +export const zFoo = z.discriminatedUnion('foo', [ + zBar.extend({ foo: z.enum(['one', 'two']) }), + zBaz.extend({ foo: z.literal('three') }), + zSpæcial.extend({ foo: z.literal('four') }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..0417778aea --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4'; + +export const zBar = z.object({ + code: z.union([z.literal(1), z.literal(2)]).optional() +}); + +export const zBaz = z.object({ + code: z.literal(3).optional() +}); + +export const zFoo = z.discriminatedUnion('code', [ + zBar.extend({ code: z.union([z.literal(1), z.literal(2)]) }), + zBaz.extend({ code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..9994b91a08 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4'; + +export const zBar = z.object({ + foo: z.enum(['one', 'two']).optional() +}); + +export const zBaz = z.object({ + foo: z.enum(['three']).optional() +}); + +export const zSpæcial = z.object({ + foo: z.enum(['four']).optional() +}); + +export const zFoo = z.discriminatedUnion('foo', [ + zBar.extend({ foo: z.enum(['one', 'two']) }), + zBaz.extend({ foo: z.literal('three') }), + zSpæcial.extend({ foo: z.literal('four') }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..b3327ba5b3 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4-mini'; + +export const zBar = z.object({ + code: z.optional(z.union([z.literal(1), z.literal(2)])) +}); + +export const zBaz = z.object({ + code: z.optional(z.literal(3)) +}); + +export const zFoo = z.discriminatedUnion('code', [ + z.extend(zBar, { code: z.union([z.literal(1), z.literal(2)]) }), + z.extend(zBaz, { code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..a7e388b534 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4-mini'; + +export const zBar = z.object({ + foo: z.optional(z.enum(['one', 'two'])) +}); + +export const zBaz = z.object({ + foo: z.optional(z.enum(['three'])) +}); + +export const zSpæcial = z.object({ + foo: z.optional(z.enum(['four'])) +}); + +export const zFoo = z.discriminatedUnion('foo', [ + z.extend(zBar, { foo: z.enum(['one', 'two']) }), + z.extend(zBaz, { foo: z.literal('three') }), + z.extend(zSpæcial, { foo: z.literal('four') }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..b9fa614597 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,17 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zBar = z.object({ + code: z.union([z.literal(1), z.literal(2)]).optional() +}); + +export const zBaz = z.object({ + code: z.literal(3).optional() +}); + +export const zFoo = z.discriminatedUnion('code', [ + zBar.extend({ code: z.literal(1) }), + zBar.extend({ code: z.literal(2) }), + zBaz.extend({ code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..5b51ab9abe --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zBar = z.object({ + foo: z.enum(['one', 'two']).optional() +}); + +export const zBaz = z.object({ + foo: z.enum(['three']).optional() +}); + +export const zSpæcial = z.object({ + foo: z.enum(['four']).optional() +}); + +export const zFoo = z.discriminatedUnion('foo', [ + zBar.extend({ foo: z.enum(['one', 'two']) }), + zBaz.extend({ foo: z.literal('three') }), + zSpæcial.extend({ foo: z.literal('four') }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..0417778aea --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4'; + +export const zBar = z.object({ + code: z.union([z.literal(1), z.literal(2)]).optional() +}); + +export const zBaz = z.object({ + code: z.literal(3).optional() +}); + +export const zFoo = z.discriminatedUnion('code', [ + zBar.extend({ code: z.union([z.literal(1), z.literal(2)]) }), + zBaz.extend({ code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..9994b91a08 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4'; + +export const zBar = z.object({ + foo: z.enum(['one', 'two']).optional() +}); + +export const zBaz = z.object({ + foo: z.enum(['three']).optional() +}); + +export const zSpæcial = z.object({ + foo: z.enum(['four']).optional() +}); + +export const zFoo = z.discriminatedUnion('foo', [ + zBar.extend({ foo: z.enum(['one', 'two']) }), + zBaz.extend({ foo: z.literal('three') }), + zSpæcial.extend({ foo: z.literal('four') }) +]); 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 f498a05473..d2418c0cb3 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 @@ -34,6 +34,22 @@ for (const zodVersion of zodVersions) { }), description: 'generates circular schemas', }, + { + config: createConfig({ + input: 'discriminator-mapped-many.yaml', + output: 'discriminator-mapped-many', + }), + description: + 'generates discriminated union when multiple mapping values point to same schema', + }, + { + config: createConfig({ + input: 'discriminator-mapped-many-number.yaml', + output: 'discriminator-mapped-many-number', + }), + description: + 'generates discriminated union when multiple number mapping values point to same schema', + }, { config: createConfig({ input: 'enum-null.json', 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 5761d3ba64..ed01c96e5f 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 @@ -152,6 +152,22 @@ for (const zodVersion of zodVersions) { }), description: 'handles oneOf discriminator (falls back to z.union when needed)', }, + { + config: createConfig({ + input: 'discriminator-mapped-many.yaml', + output: 'discriminator-mapped-many', + }), + description: + 'generates discriminated union when multiple mapping values point to same schema', + }, + { + config: createConfig({ + input: 'discriminator-mapped-many-number.yaml', + output: 'discriminator-mapped-many-number', + }), + description: + 'generates discriminated union when multiple number mapping values point to same schema', + }, { config: createConfig({ input: 'enum-null.json', diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..5fc0bdc133 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +export const zBar = z.object({ + code: z.optional(z.union([z.literal(1), z.literal(2)])) +}); + +export const zBaz = z.object({ + code: z.optional(z.literal(3)) +}); + +export const zFoo = z.discriminatedUnion('code', [ + z.extend(zBar, { code: z.union([z.literal(1), z.literal(2)]) }), + z.extend(zBaz, { code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..a365be0f13 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +export const zBar = z.object({ + foo: z.optional(z.enum(['one', 'two'])) +}); + +export const zBaz = z.object({ + foo: z.optional(z.enum(['three'])) +}); + +export const zSpæcial = z.object({ + foo: z.optional(z.enum(['four'])) +}); + +export const zFoo = z.discriminatedUnion('foo', [ + z.extend(zBar, { foo: z.enum(['one', 'two']) }), + z.extend(zBaz, { foo: z.literal('three') }), + z.extend(zSpæcial, { foo: z.literal('four') }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..e8fbc4a26e --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,17 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +export const zBar = z.object({ + code: z.union([z.literal(1), z.literal(2)]).optional() +}); + +export const zBaz = z.object({ + code: z.literal(3).optional() +}); + +export const zFoo = z.discriminatedUnion('code', [ + zBar.extend({ code: z.literal(1) }), + zBar.extend({ code: z.literal(2) }), + zBaz.extend({ code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..1b25c19cad --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +export const zBar = z.object({ + foo: z.enum(['one', 'two']).optional() +}); + +export const zBaz = z.object({ + foo: z.enum(['three']).optional() +}); + +export const zSpæcial = z.object({ + foo: z.enum(['four']).optional() +}); + +export const zFoo = z.discriminatedUnion('foo', [ + zBar.extend({ foo: z.enum(['one', 'two']) }), + zBaz.extend({ foo: z.literal('three') }), + zSpæcial.extend({ foo: z.literal('four') }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..b11c5e2390 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod'; + +export const zBar = z.object({ + code: z.union([z.literal(1), z.literal(2)]).optional() +}); + +export const zBaz = z.object({ + code: z.literal(3).optional() +}); + +export const zFoo = z.discriminatedUnion('code', [ + zBar.extend({ code: z.union([z.literal(1), z.literal(2)]) }), + zBaz.extend({ code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..facaac6e38 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod'; + +export const zBar = z.object({ + foo: z.enum(['one', 'two']).optional() +}); + +export const zBaz = z.object({ + foo: z.enum(['three']).optional() +}); + +export const zSpæcial = z.object({ + foo: z.enum(['four']).optional() +}); + +export const zFoo = z.discriminatedUnion('foo', [ + zBar.extend({ foo: z.enum(['one', 'two']) }), + zBaz.extend({ foo: z.literal('three') }), + zSpæcial.extend({ foo: z.literal('four') }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..5fc0bdc133 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +export const zBar = z.object({ + code: z.optional(z.union([z.literal(1), z.literal(2)])) +}); + +export const zBaz = z.object({ + code: z.optional(z.literal(3)) +}); + +export const zFoo = z.discriminatedUnion('code', [ + z.extend(zBar, { code: z.union([z.literal(1), z.literal(2)]) }), + z.extend(zBaz, { code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..a365be0f13 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +export const zBar = z.object({ + foo: z.optional(z.enum(['one', 'two'])) +}); + +export const zBaz = z.object({ + foo: z.optional(z.enum(['three'])) +}); + +export const zSpæcial = z.object({ + foo: z.optional(z.enum(['four'])) +}); + +export const zFoo = z.discriminatedUnion('foo', [ + z.extend(zBar, { foo: z.enum(['one', 'two']) }), + z.extend(zBaz, { foo: z.literal('three') }), + z.extend(zSpæcial, { foo: z.literal('four') }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..e8fbc4a26e --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,17 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +export const zBar = z.object({ + code: z.union([z.literal(1), z.literal(2)]).optional() +}); + +export const zBaz = z.object({ + code: z.literal(3).optional() +}); + +export const zFoo = z.discriminatedUnion('code', [ + zBar.extend({ code: z.literal(1) }), + zBar.extend({ code: z.literal(2) }), + zBaz.extend({ code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..1b25c19cad --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +export const zBar = z.object({ + foo: z.enum(['one', 'two']).optional() +}); + +export const zBaz = z.object({ + foo: z.enum(['three']).optional() +}); + +export const zSpæcial = z.object({ + foo: z.enum(['four']).optional() +}); + +export const zFoo = z.discriminatedUnion('foo', [ + zBar.extend({ foo: z.enum(['one', 'two']) }), + zBaz.extend({ foo: z.literal('three') }), + zSpæcial.extend({ foo: z.literal('four') }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/discriminator-mapped-many-number/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/discriminator-mapped-many-number/zod.gen.ts new file mode 100644 index 0000000000..b11c5e2390 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/discriminator-mapped-many-number/zod.gen.ts @@ -0,0 +1,16 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod'; + +export const zBar = z.object({ + code: z.union([z.literal(1), z.literal(2)]).optional() +}); + +export const zBaz = z.object({ + code: z.literal(3).optional() +}); + +export const zFoo = z.discriminatedUnion('code', [ + zBar.extend({ code: z.union([z.literal(1), z.literal(2)]) }), + zBaz.extend({ code: z.literal(3) }) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/discriminator-mapped-many/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/discriminator-mapped-many/zod.gen.ts new file mode 100644 index 0000000000..facaac6e38 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/discriminator-mapped-many/zod.gen.ts @@ -0,0 +1,21 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod'; + +export const zBar = z.object({ + foo: z.enum(['one', 'two']).optional() +}); + +export const zBaz = z.object({ + foo: z.enum(['three']).optional() +}); + +export const zSpæcial = z.object({ + foo: z.enum(['four']).optional() +}); + +export const zFoo = z.discriminatedUnion('foo', [ + zBar.extend({ foo: z.enum(['one', 'two']) }), + zBaz.extend({ foo: z.literal('three') }), + zSpæcial.extend({ foo: z.literal('four') }) +]); 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 18659712b5..0d004290f3 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 @@ -42,6 +42,22 @@ for (const zodVersion of zodVersions) { description: 'falls back to z.union() when discriminated union members have allOf (intersection)', }, + { + config: createConfig({ + input: 'discriminator-mapped-many.yaml', + output: 'discriminator-mapped-many', + }), + description: + 'generates discriminated union when multiple mapping values point to same schema', + }, + { + config: createConfig({ + input: 'discriminator-mapped-many-number.yaml', + output: 'discriminator-mapped-many-number', + }), + description: + 'generates discriminated union when multiple number mapping values point to same schema', + }, { config: createConfig({ input: 'enum-null.json', 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 703c51f7ab..3232387292 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 @@ -185,6 +185,22 @@ for (const zodVersion of zodVersions) { }), description: 'handles oneOf discriminator (falls back to z.union when needed)', }, + { + config: createConfig({ + input: 'discriminator-mapped-many.yaml', + output: 'discriminator-mapped-many', + }), + description: + 'generates discriminated union when multiple mapping values point to same schema', + }, + { + config: createConfig({ + input: 'discriminator-mapped-many-number.yaml', + output: 'discriminator-mapped-many-number', + }), + description: + 'generates discriminated union when multiple number mapping values point to same schema', + }, { config: createConfig({ input: 'enum-null.json', diff --git a/packages/openapi-ts/src/plugins/zod/mini/toAst/union.ts b/packages/openapi-ts/src/plugins/zod/mini/toAst/union.ts index 183ce382c8..4efb5b83ee 100644 --- a/packages/openapi-ts/src/plugins/zod/mini/toAst/union.ts +++ b/packages/openapi-ts/src/plugins/zod/mini/toAst/union.ts @@ -2,7 +2,10 @@ import { $ } from '../../../../ts-dsl'; import { identifiers } from '../../constants'; import type { UnionResolverContext } from '../../resolvers'; import type { Chain } from '../../shared/chain'; -import { tryBuildDiscriminatedUnion } from '../../shared/discriminated-union'; +import { + buildDiscriminatorExpression, + tryBuildDiscriminatedUnion, +} from '../../shared/discriminated-union'; import type { ZodResult } from '../../shared/types'; type UnionToAstOptions = Pick< @@ -42,6 +45,9 @@ function baseNode(ctx: UnionResolverContext): Chain { }); if (discriminatedExpression) { + // z.discriminatedUnion requires each branch discriminator to be a type that + // z.discriminatedUnion's internal getDiscriminator can extract a value from. + // See buildDiscriminatorExpression for the three cases handled. const unionMembers = discriminatedExpression.members.map((member) => $(z) .attr(identifiers.extend) @@ -49,7 +55,7 @@ function baseNode(ctx: UnionResolverContext): Chain { member.refExpression, $.object().prop( discriminatedExpression.discriminatorKey, - $(z).attr(identifiers.literal).call($.fromValue(member.discriminatedValue)), + buildDiscriminatorExpression(z, member.discriminatedValue), ), ), ); diff --git a/packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts b/packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts index 4e85cc1a13..685c2705df 100644 --- a/packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts +++ b/packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts @@ -1,7 +1,8 @@ -import type { SymbolMeta } from '@hey-api/codegen-core'; +import type { Symbol, SymbolMeta } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; import { $ } from '../../../ts-dsl'; +import { identifiers } from '../constants'; import type { ZodPlugin } from '../types'; import type { Chain } from './chain'; import type { ZodMeta, ZodResult } from './types'; @@ -40,7 +41,22 @@ export function tryBuildDiscriminatedUnion({ if (schema.logicalOperator !== 'and' || !schema.items || schema.items.length !== 2) return null; const refPart = schema.items[1]!; - const discriminatedValue = schema.items[0]!.properties?.[discriminatorKey]?.const; + const discriminatorProp = schema.items[0]!.properties?.[discriminatorKey]; + let discriminatedValue: unknown; + if (discriminatorProp?.const !== undefined) { + // Single const value — the common case: { type: "cat" } + discriminatedValue = discriminatorProp.const; + } else if ( + discriminatorProp?.logicalOperator === 'or' && + discriminatorProp.items?.length && + discriminatorProp.items.every((item) => item.const !== undefined) + ) { + // OR-of-consts — the discriminator maps multiple values to one schema. + // Every item must have a const; a future IR change that violates this would + // silently fall through to `discriminatedValue === undefined` below, which + // degrades the output from z.discriminatedUnion to a plain z.union. + discriminatedValue = discriminatorProp.items.map((item) => item.const); + } // lazy references can't be used in a discriminated union directly if (discriminatedValue === undefined || items[index]!.meta.hasLazy) return null; @@ -75,3 +91,34 @@ export function tryBuildDiscriminatedUnion({ members, }; } + +/** + * Builds the Zod AST node for a discriminator branch value. + * + * Zod's discriminatedUnion requires each branch discriminator to be a type that + * getDiscriminator can extract a value set from (ZodLiteral, ZodEnum, or in v4+ + * ZodUnion of literals). The shape to emit depends on the value: + * + * "cat" → z.literal("cat") + * ["x"] → z.literal("x") single-element: literal is more ergonomic + * ["a", "b"] → z.enum(["a", "b"]) all strings: enum is more ergonomic than union + * [1, 2] → z.union([z.literal(1), z.literal(2)]) + */ +export function buildDiscriminatorExpression(z: Symbol, value: unknown): Chain { + if (!Array.isArray(value)) { + return $(z).attr(identifiers.literal).call($.fromValue(value)); + } + + // Single-element array: prefer z.literal over a single-item enum/union + if (value.length === 1) { + return $(z).attr(identifiers.literal).call($.fromValue(value[0])); + } + + if (value.length > 0 && value.every((v) => typeof v === 'string')) { + return $(z).attr(identifiers.enum).call($.fromValue(value)); + } + + return $(z) + .attr(identifiers.union) + .call($.array(...value.map((v) => $(z).attr(identifiers.literal).call($.fromValue(v))))); +} diff --git a/packages/openapi-ts/src/plugins/zod/v3/toAst/union.ts b/packages/openapi-ts/src/plugins/zod/v3/toAst/union.ts index d588b84f28..13b58c744b 100644 --- a/packages/openapi-ts/src/plugins/zod/v3/toAst/union.ts +++ b/packages/openapi-ts/src/plugins/zod/v3/toAst/union.ts @@ -2,7 +2,10 @@ import { $ } from '../../../../ts-dsl'; import { identifiers } from '../../constants'; import type { UnionResolverContext } from '../../resolvers'; import type { Chain } from '../../shared/chain'; -import { tryBuildDiscriminatedUnion } from '../../shared/discriminated-union'; +import { + buildDiscriminatorExpression, + tryBuildDiscriminatedUnion, +} from '../../shared/discriminated-union'; import type { ZodResult } from '../../shared/types'; type UnionToAstOptions = Pick< @@ -42,16 +45,41 @@ function baseNode(ctx: UnionResolverContext): Chain { }); if (discriminatedExpression) { - const unionMembers = discriminatedExpression.members.map((member) => - member.refExpression - .attr(identifiers.extend) - .call( - $.object().prop( - discriminatedExpression.discriminatorKey, - $(z).attr(identifiers.literal).call($.fromValue(member.discriminatedValue)), + // getDiscriminator in zod v3 only recognises ZodLiteral and ZodEnum — ZodUnion + // is not supported. When a member maps multiple non-string values to one schema, + // expand it into one branch per literal so every entry resolves to a ZodLiteral. + // [1, 2] → two branches: extend({ code: z.literal(1) }), extend({ code: z.literal(2) }) + // ["a", "b"] → one branch: extend({ code: z.enum(["a", "b"]) }) (ZodEnum is fine) + // "cat" → one branch: extend({ code: z.literal("cat") }) + const unionMembers = discriminatedExpression.members.flatMap((member) => { + const isNonStringArray = + Array.isArray(member.discriminatedValue) && + !member.discriminatedValue.every((v) => typeof v === 'string'); + + if (isNonStringArray) { + return (member.discriminatedValue as unknown[]).map((v) => + member.refExpression + .attr(identifiers.extend) + .call( + $.object().prop( + discriminatedExpression.discriminatorKey, + $(z).attr(identifiers.literal).call($.fromValue(v)), + ), + ), + ); + } + + return [ + member.refExpression + .attr(identifiers.extend) + .call( + $.object().prop( + discriminatedExpression.discriminatorKey, + buildDiscriminatorExpression(z, member.discriminatedValue), + ), ), - ), - ); + ]; + }); return $(z) .attr(identifiers.discriminatedUnion) diff --git a/packages/openapi-ts/src/plugins/zod/v4/toAst/union.ts b/packages/openapi-ts/src/plugins/zod/v4/toAst/union.ts index 7ec7667344..d0754838ae 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/toAst/union.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/toAst/union.ts @@ -2,7 +2,10 @@ import { $ } from '../../../../ts-dsl'; import { identifiers } from '../../constants'; import type { UnionResolverContext } from '../../resolvers'; import type { Chain } from '../../shared/chain'; -import { tryBuildDiscriminatedUnion } from '../../shared/discriminated-union'; +import { + buildDiscriminatorExpression, + tryBuildDiscriminatedUnion, +} from '../../shared/discriminated-union'; import type { ZodResult } from '../../shared/types'; type UnionToAstOptions = Pick< @@ -40,13 +43,16 @@ function baseNode(ctx: UnionResolverContext): Chain { }); if (discriminatedExpression) { + // In v4-native, z.union([z.literal(1), z.literal(2)]) is a valid discriminator + // branch because $ZodDiscriminatedUnion derives per-branch values via + // option._zod.values, which flattens through ZodUnion of literals. const unionMembers = discriminatedExpression.members.map((member) => member.refExpression .attr(identifiers.extend) .call( $.object().prop( discriminatedExpression.discriminatorKey, - $(z).attr(identifiers.literal).call($.fromValue(member.discriminatedValue)), + buildDiscriminatorExpression(z, member.discriminatedValue), ), ), ); diff --git a/specs/3.0.x/discriminator-mapped-many-number.yaml b/specs/3.0.x/discriminator-mapped-many-number.yaml new file mode 100644 index 0000000000..6c5fc9e8ba --- /dev/null +++ b/specs/3.0.x/discriminator-mapped-many-number.yaml @@ -0,0 +1,31 @@ +openapi: 3.0.0 +info: + title: OpenAPI 3.0.0 discriminator mapped many (number) example + version: 1 +components: + schemas: + Foo: + oneOf: + - $ref: '#/components/schemas/Bar' + - $ref: '#/components/schemas/Baz' + discriminator: + propertyName: code + mapping: + '1': '#/components/schemas/Bar' + '2': '#/components/schemas/Bar' + '3': '#/components/schemas/Baz' + Bar: + type: object + properties: + code: + type: integer + enum: + - 1 + - 2 + Baz: + type: object + properties: + code: + type: integer + enum: + - 3 diff --git a/specs/3.1.x/discriminator-mapped-many-number.yaml b/specs/3.1.x/discriminator-mapped-many-number.yaml new file mode 100644 index 0000000000..54fa8ae32e --- /dev/null +++ b/specs/3.1.x/discriminator-mapped-many-number.yaml @@ -0,0 +1,31 @@ +openapi: 3.1.0 +info: + title: OpenAPI 3.1.0 discriminator mapped many (number) example + version: 1 +components: + schemas: + Foo: + oneOf: + - $ref: '#/components/schemas/Bar' + - $ref: '#/components/schemas/Baz' + discriminator: + propertyName: code + mapping: + '1': '#/components/schemas/Bar' + '2': '#/components/schemas/Bar' + '3': '#/components/schemas/Baz' + Bar: + type: object + properties: + code: + type: integer + enum: + - 1 + - 2 + Baz: + type: object + properties: + code: + type: integer + enum: + - 3