Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/zod-discriminated-union-empty-object.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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)
]);
Original file line number Diff line number Diff line change
@@ -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)
]);
Original file line number Diff line number Diff line change
@@ -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)
]);
Original file line number Diff line number Diff line change
@@ -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)
]);
Original file line number Diff line number Diff line change
@@ -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)
]);
Original file line number Diff line number Diff line change
@@ -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)
]);
8 changes: 8 additions & 0 deletions packages/openapi-ts-tests/zod/v3/test/3.0.x.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions packages/openapi-ts-tests/zod/v3/test/3.1.x.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
]);
Original file line number Diff line number Diff line change
@@ -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)
]);
Original file line number Diff line number Diff line change
@@ -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)
]);
Original file line number Diff line number Diff line change
@@ -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)
]);
Original file line number Diff line number Diff line change
@@ -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)
]);
Original file line number Diff line number Diff line change
@@ -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)
]);
8 changes: 8 additions & 0 deletions packages/openapi-ts-tests/zod/v4/test/3.0.x.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 8 additions & 0 deletions packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
20 changes: 20 additions & 0 deletions packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ export interface DiscriminatedUnionData {
members: Array<DiscriminatedUnionMember>;
}

/**
* 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,
Expand Down Expand Up @@ -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<IR.SchemaObject>(refPart.$ref);
} catch {
// unresolvable refs fall through and will surface elsewhere
}
if (isRecordShaped(resolved)) return null;
refExpression = $(plugin.referenceSymbol(query));
} else {
return null;
Expand Down
24 changes: 24 additions & 0 deletions specs/3.0.x/discriminator-empty-object-member.yaml
Original file line number Diff line number Diff line change
@@ -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'
24 changes: 24 additions & 0 deletions specs/3.1.x/discriminator-empty-object-member.yaml
Original file line number Diff line number Diff line change
@@ -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'
Loading