Skip to content
Merged
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
---

**plugin(zod)**: fix: avoid invalid `.extend()` on `z.record()` when a discriminated union member is an empty object
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
13 changes: 13 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,12 @@ export interface DiscriminatedUnionData {
members: Array<DiscriminatedUnionMember>;
}

export 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 +63,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