diff --git a/.changeset/zod-discriminated-union-empty-object.md b/.changeset/zod-discriminated-union-empty-object.md new file mode 100644 index 0000000000..2b8b58e8e0 --- /dev/null +++ b/.changeset/zod-discriminated-union-empty-object.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**fix**: avoid invalid `.extend()` on `z.record()` when a discriminated union member is an empty object. The union now falls back to `z.union()` for these branches so the generated schema compiles. diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..13bba1669f --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/mini/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4-mini'; + +export const zEmpty = z.record(z.string(), z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.intersection(z.object({ + type: z.literal('Empty') + }), zEmpty), + z.intersection(z.object({ + type: z.literal('WithValue') + }), zWithValue) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..cae901b658 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v3/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zEmpty = z.record(z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.object({ + type: z.literal('Empty') + }).and(zEmpty), + z.object({ + type: z.literal('WithValue') + }).and(zWithValue) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..4e0ac9dc25 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.0.x/v4/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4'; + +export const zEmpty = z.record(z.string(), z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.object({ + type: z.literal('Empty') + }).and(zEmpty), + z.object({ + type: z.literal('WithValue') + }).and(zWithValue) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..13bba1669f --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/mini/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4-mini'; + +export const zEmpty = z.record(z.string(), z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.intersection(z.object({ + type: z.literal('Empty') + }), zEmpty), + z.intersection(z.object({ + type: z.literal('WithValue') + }), zWithValue) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..cae901b658 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v3/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zEmpty = z.record(z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.object({ + type: z.literal('Empty') + }).and(zEmpty), + z.object({ + type: z.literal('WithValue') + }).and(zWithValue) +]); diff --git a/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..4e0ac9dc25 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v3/__snapshots__/3.1.x/v4/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/v4'; + +export const zEmpty = z.record(z.string(), z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.object({ + type: z.literal('Empty') + }).and(zEmpty), + z.object({ + type: z.literal('WithValue') + }).and(zWithValue) +]); 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..3a84a97efe 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,14 @@ for (const zodVersion of zodVersions) { }), description: 'generates circular schemas', }, + { + config: createConfig({ + input: 'discriminator-empty-object-member.yaml', + output: 'discriminator-empty-object-member', + }), + description: + 'falls back to z.union() when a discriminated union member is an empty object (z.record cannot be extended)', + }, { 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 8c0440548b..5c294ed27f 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 @@ -138,6 +138,14 @@ for (const zodVersion of zodVersions) { }), description: 'generates discriminated union for oneOf with discriminator mapping', }, + { + config: createConfig({ + input: 'discriminator-empty-object-member.yaml', + output: 'discriminator-empty-object-member', + }), + description: + 'falls back to z.union() when a discriminated union member is an empty object (z.record cannot be extended)', + }, { config: createConfig({ input: 'discriminator-any-of.yaml', diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..784bbc5d37 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +export const zEmpty = z.record(z.string(), z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.intersection(z.object({ + type: z.literal('Empty') + }), zEmpty), + z.intersection(z.object({ + type: z.literal('WithValue') + }), zWithValue) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..4312bc77dd --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +export const zEmpty = z.record(z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.object({ + type: z.literal('Empty') + }).and(zEmpty), + z.object({ + type: z.literal('WithValue') + }).and(zWithValue) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..7132db1494 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod'; + +export const zEmpty = z.record(z.string(), z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.object({ + type: z.literal('Empty') + }).and(zEmpty), + z.object({ + type: z.literal('WithValue') + }).and(zWithValue) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..784bbc5d37 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +export const zEmpty = z.record(z.string(), z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.intersection(z.object({ + type: z.literal('Empty') + }), zEmpty), + z.intersection(z.object({ + type: z.literal('WithValue') + }), zWithValue) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..4312bc77dd --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +export const zEmpty = z.record(z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.object({ + type: z.literal('Empty') + }).and(zEmpty), + z.object({ + type: z.literal('WithValue') + }).and(zWithValue) +]); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/discriminator-empty-object-member/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/discriminator-empty-object-member/zod.gen.ts new file mode 100644 index 0000000000..7132db1494 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/discriminator-empty-object-member/zod.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod'; + +export const zEmpty = z.record(z.string(), z.unknown()); + +export const zWithValue = z.object({ + value: z.string() +}); + +export const zTestResponse = z.union([ + z.object({ + type: z.literal('Empty') + }).and(zEmpty), + z.object({ + type: z.literal('WithValue') + }).and(zWithValue) +]); 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..7c597a670a 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,14 @@ for (const zodVersion of zodVersions) { description: 'falls back to z.union() when discriminated union members have allOf (intersection)', }, + { + config: createConfig({ + input: 'discriminator-empty-object-member.yaml', + output: 'discriminator-empty-object-member', + }), + description: + 'falls back to z.union() when a discriminated union member is an empty object (z.record cannot be extended)', + }, { 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 79714ad4c9..9ac98a9462 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 @@ -171,6 +171,14 @@ for (const zodVersion of zodVersions) { description: 'falls back to z.union() when discriminated union members have allOf (intersection)', }, + { + config: createConfig({ + input: 'discriminator-empty-object-member.yaml', + output: 'discriminator-empty-object-member', + }), + description: + 'falls back to z.union() when a discriminated union member is an empty object (z.record cannot be extended)', + }, { config: createConfig({ input: 'discriminator-any-of.yaml', 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..ef29ba5902 100644 --- a/packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts +++ b/packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts @@ -16,6 +16,19 @@ export interface DiscriminatedUnionData { members: Array; } +/** + * A schema is emitted as `z.record(...)` rather than `z.object({...})` when it + * has no `properties` but does declare `additionalProperties`. Such a schema + * cannot be extended with `.extend({...})`, so it cannot participate in a + * `z.discriminatedUnion` via the `ref.extend({ discriminator: literal })` + * pattern this builder produces. + */ +function isRecordShaped(schema: IR.SchemaObject | undefined): boolean { + if (!schema || schema.type !== 'object') return false; + const hasProperties = schema.properties && Object.keys(schema.properties).length > 0; + return !hasProperties && Boolean(schema.additionalProperties); +} + export function tryBuildDiscriminatedUnion({ items, parentSchema, @@ -57,6 +70,13 @@ export function tryBuildDiscriminatedUnion({ tool: 'zod', }; if ((plugin.querySymbol(query)?.meta as unknown as ZodMeta)?.isIntersection) return null; + let resolved: IR.SchemaObject | undefined; + try { + resolved = plugin.context.resolveIrRef(refPart.$ref); + } catch { + // unresolvable refs fall through and will surface elsewhere + } + if (isRecordShaped(resolved)) return null; refExpression = $(plugin.referenceSymbol(query)); } else { return null; diff --git a/specs/3.0.x/discriminator-empty-object-member.yaml b/specs/3.0.x/discriminator-empty-object-member.yaml new file mode 100644 index 0000000000..2824d6f1ab --- /dev/null +++ b/specs/3.0.x/discriminator-empty-object-member.yaml @@ -0,0 +1,24 @@ +openapi: 3.0.3 +info: + title: OpenAPI 3.0.3 discriminator empty object member example + version: 1 +components: + schemas: + Empty: + type: object + WithValue: + type: object + required: + - value + properties: + value: + type: string + TestResponse: + oneOf: + - $ref: '#/components/schemas/Empty' + - $ref: '#/components/schemas/WithValue' + discriminator: + propertyName: type + mapping: + Empty: '#/components/schemas/Empty' + WithValue: '#/components/schemas/WithValue' diff --git a/specs/3.1.x/discriminator-empty-object-member.yaml b/specs/3.1.x/discriminator-empty-object-member.yaml new file mode 100644 index 0000000000..c59c485113 --- /dev/null +++ b/specs/3.1.x/discriminator-empty-object-member.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: OpenAPI 3.1.0 discriminator empty object member example + version: 1 +components: + schemas: + Empty: + type: object + WithValue: + type: object + required: + - value + properties: + value: + type: string + TestResponse: + oneOf: + - $ref: '#/components/schemas/Empty' + - $ref: '#/components/schemas/WithValue' + discriminator: + propertyName: type + mapping: + Empty: '#/components/schemas/Empty' + WithValue: '#/components/schemas/WithValue'