Skip to content

Commit ea356ea

Browse files
authored
refactor(zod,valibot): detect discriminator structurally, add v.variant() support
Agent-Logs-Url: https://github.com/hey-api/openapi-ts/sessions/59f614df-ff48-4d4e-9f5a-07be73a3f93e
1 parent 5c25117 commit ea356ea

11 files changed

Lines changed: 252 additions & 56 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
import * as v from 'valibot';
4+
5+
export const vFoo = v.object({
6+
id: v.string()
7+
});
8+
9+
export const vBar = v.intersect([vFoo, v.object({
10+
bar: v.optional(v.string()),
11+
id: v.literal('Bar')
12+
})]);
13+
14+
export const vBaz = v.intersect([vFoo, v.object({
15+
baz: v.optional(v.string()),
16+
id: v.literal('Baz')
17+
})]);
18+
19+
export const vQux = v.intersect([vFoo, v.object({
20+
qux: v.optional(v.boolean()),
21+
id: v.literal('Qux')
22+
})]);
23+
24+
export const vFooMapped = v.object({
25+
id: v.string()
26+
});
27+
28+
export const vBarMapped = v.intersect([vFooMapped, v.object({
29+
bar: v.optional(v.string()),
30+
id: v.literal('bar')
31+
})]);
32+
33+
export const vBazMapped = v.intersect([vFooMapped, v.object({
34+
baz: v.optional(v.string()),
35+
id: v.literal('baz')
36+
})]);
37+
38+
export const vQuxMapped = v.intersect([vFooMapped, v.object({
39+
qux: v.optional(v.boolean()),
40+
id: v.literal('QuxMapped')
41+
})]);
42+
43+
export const vBarUnion = v.object({
44+
id: v.optional(v.string()),
45+
bar: v.optional(v.string())
46+
});
47+
48+
export const vBazUnion = v.object({
49+
id: v.optional(v.string()),
50+
baz: v.optional(v.string())
51+
});
52+
53+
export const vFooUnion = v.variant('id', [v.intersect([v.object({
54+
id: v.literal('bar')
55+
}), vBarUnion]), v.intersect([v.object({
56+
id: v.literal('baz')
57+
}), vBazUnion])]);
58+
59+
export const vQuxExtend = vFooUnion;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
import * as v from 'valibot';
4+
5+
export const vQuux = v.picklist(['Bar', 'Baz']);
6+
7+
export const vQux = v.object({
8+
id: v.string(),
9+
type: vQuux
10+
});
11+
12+
export const vBaz = vQux;
13+
14+
export const vBar = vQux;
15+
16+
export const vFoo = v.variant('type', [v.intersect([v.object({
17+
type: v.optional(v.literal('Bar'))
18+
}), vBar]), v.intersect([v.object({
19+
type: v.optional(v.literal('Baz'))
20+
}), vBaz])]);
21+
22+
export const vSpæcial = vQux;
23+
24+
export const vQuuz = v.variant('type', [
25+
v.intersect([v.object({
26+
type: v.optional(v.literal('bar'))
27+
}), vBar]),
28+
v.intersect([v.object({
29+
type: v.optional(v.literal('baz'))
30+
}), vBaz]),
31+
v.intersect([v.object({
32+
type: v.optional(v.literal('non-ascii'))
33+
}), vSpæcial])
34+
]);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
import * as v from 'valibot';
4+
5+
export const vQuux = v.picklist(['Bar', 'Baz']);
6+
7+
export const vQux = v.object({
8+
id: v.string(),
9+
type: vQuux
10+
});
11+
12+
export const vBaz = vQux;
13+
14+
export const vBar = vQux;
15+
16+
export const vFoo = v.variant('type', [v.intersect([v.object({
17+
type: v.literal('Bar')
18+
}), vBar]), v.intersect([v.object({
19+
type: v.literal('Baz')
20+
}), vBaz])]);
21+
22+
export const vSpæcial = vQux;
23+
24+
export const vQuuz = v.variant('type', [
25+
v.intersect([v.object({
26+
type: v.literal('bar')
27+
}), vBar]),
28+
v.intersect([v.object({
29+
type: v.literal('baz')
30+
}), vBaz]),
31+
v.intersect([v.object({
32+
type: v.literal('non-ascii')
33+
}), vSpæcial])
34+
]);

packages/openapi-ts-tests/valibot/v1/test/3.1.x.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,27 @@ describe(`OpenAPI ${version}`, () => {
130130
}),
131131
description: 'anyOf string and binary string',
132132
},
133+
{
134+
config: createConfig({
135+
input: 'discriminator-all-of.yaml',
136+
output: 'discriminator-all-of',
137+
}),
138+
description: 'generates discriminated union for oneOf with discriminator mapping',
139+
},
140+
{
141+
config: createConfig({
142+
input: 'discriminator-any-of.yaml',
143+
output: 'discriminator-any-of',
144+
}),
145+
description: 'generates discriminated union for anyOf with discriminator mapping',
146+
},
147+
{
148+
config: createConfig({
149+
input: 'discriminator-one-of.yaml',
150+
output: 'discriminator-one-of',
151+
}),
152+
description: 'generates discriminated union for oneOf discriminator mapping',
153+
},
133154
{
134155
config: createConfig({
135156
input: 'time-format.yaml',

packages/openapi-ts/src/plugins/valibot/v1/toAst/union.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,47 @@ import type { CompositeHandlerResult, ValibotFinal, ValibotResult } from '../../
88
import type { ValibotPlugin } from '../../types';
99
import { identifiers } from '../constants';
1010

11+
/**
12+
* Returns the discriminator key if all non-null items follow the pattern
13+
* produced by the OpenAPI discriminator parser:
14+
* `{ logicalOperator: 'and', items: [discrimObj, ref] }`
15+
* where `discrimObj` is an object with exactly one const-valued property.
16+
*/
17+
function detectDiscriminatorKey(schemas: ReadonlyArray<IR.SchemaObject>): string | null {
18+
let key: string | undefined;
19+
20+
for (const schema of schemas) {
21+
if (schema.type === 'null' || schema.const === null) {
22+
continue;
23+
}
24+
25+
if (schema.logicalOperator !== 'and' || !schema.items || schema.items.length !== 2) {
26+
return null;
27+
}
28+
29+
const discrimPart = schema.items[0]!;
30+
31+
if (discrimPart.type !== 'object' || !discrimPart.properties) {
32+
return null;
33+
}
34+
35+
const props = Object.entries(discrimPart.properties);
36+
if (props.length !== 1 || props[0]![1].const === undefined) {
37+
return null;
38+
}
39+
40+
const propKey = props[0]![0];
41+
42+
if (key === undefined) {
43+
key = propKey;
44+
} else if (key !== propKey) {
45+
return null;
46+
}
47+
}
48+
49+
return key ?? null;
50+
}
51+
1152
function baseNode(ctx: UnionResolverContext): PipeResult {
1253
const { childResults, plugin, schemas, symbols } = ctx;
1354
const { v } = symbols;
@@ -28,6 +69,14 @@ function baseNode(ctx: UnionResolverContext): PipeResult {
2869
return nonNullItems[0]!.pipes;
2970
}
3071

72+
const discriminatorKey = detectDiscriminatorKey(schemas);
73+
if (discriminatorKey) {
74+
const itemNodes = nonNullItems.map((i) => pipesToNode(i.pipes, plugin));
75+
return $(v)
76+
.attr(identifiers.schemas.variant)
77+
.call($.literal(discriminatorKey), $.array(...itemNodes));
78+
}
79+
3180
const itemNodes = nonNullItems.map((i) => pipesToNode(i.pipes, plugin));
3281
return $(v)
3382
.attr(identifiers.schemas.union)

packages/openapi-ts/src/plugins/zod/shared/discriminated-union.ts

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,60 +8,83 @@ import type { Chain } from './chain';
88
import type { ZodResult } from './types';
99

1010
/**
11-
* Attempts to build a `z.discriminatedUnion()` expression when the parent
12-
* schema carries a discriminator and all non-null union items follow the
13-
* expected intersection pattern `{ discrimObject, ref }`.
11+
* Returns the discriminator key if all non-null items in the union follow the
12+
* pattern produced by the OpenAPI discriminator parser:
13+
* `{ logicalOperator: 'and', items: [discrimObj, ref] }`
14+
* where `discrimObj` is an object with exactly one const-valued property.
15+
*
16+
* Returns `null` when the pattern is not recognised.
17+
*/
18+
function detectDiscriminatorKey(schemas: ReadonlyArray<IR.SchemaObject>): string | null {
19+
let key: string | undefined;
20+
21+
for (const schema of schemas) {
22+
if (schema.type === 'null' || schema.const === null) {
23+
continue;
24+
}
25+
26+
if (schema.logicalOperator !== 'and' || !schema.items || schema.items.length !== 2) {
27+
return null;
28+
}
29+
30+
const discrimPart = schema.items[0]!;
31+
32+
if (discrimPart.type !== 'object' || !discrimPart.properties) {
33+
return null;
34+
}
35+
36+
const props = Object.entries(discrimPart.properties);
37+
if (props.length !== 1 || props[0]![1].const === undefined) {
38+
return null;
39+
}
40+
41+
const propKey = props[0]![0];
42+
43+
if (key === undefined) {
44+
key = propKey;
45+
} else if (key !== propKey) {
46+
// All items must share the same discriminator key
47+
return null;
48+
}
49+
}
50+
51+
return key ?? null;
52+
}
53+
54+
/**
55+
* Attempts to build a `z.discriminatedUnion()` expression when all non-null
56+
* union items follow the discriminator intersection pattern
57+
* `{ discrimObject, ref }` produced by the OpenAPI discriminator parser.
1458
*
1559
* Returns the expression on success, or `null` to fall back to `z.union()`.
1660
*/
1761
export function tryBuildDiscriminatedUnion({
1862
ctx,
1963
items,
20-
parentSchema,
2164
schemas,
2265
z,
2366
}: {
2467
ctx: SchemaVisitorContext<ZodPlugin['Instance']>;
2568
items: ReadonlyArray<ZodResult>;
26-
parentSchema: IR.SchemaObject;
2769
schemas: ReadonlyArray<IR.SchemaObject>;
2870
z: ReturnType<typeof ctx.plugin.external>;
2971
}): Chain | null {
30-
if (!parentSchema.discriminator) {
72+
const discriminatorKey = detectDiscriminatorKey(schemas);
73+
if (!discriminatorKey) {
3174
return null;
3275
}
3376

34-
const discriminatorKey = parentSchema.discriminator.propertyName;
35-
3677
const unionMembers: Array<Chain> = [];
3778

3879
for (let i = 0; i < schemas.length; i++) {
3980
const schema = schemas[i]!;
4081

41-
// Skip null types - handled separately by the null/nullable modifiers
4282
if (schema.type === 'null' || schema.const === null) {
4383
continue;
4484
}
4585

46-
// Each non-null item must be an intersection (`and`) of exactly 2 parts:
47-
// [0] discriminator-object, [1] schema reference
48-
if (schema.logicalOperator !== 'and' || !schema.items || schema.items.length !== 2) {
49-
return null;
50-
}
51-
52-
const discrimPart = schema.items[0]!;
53-
const refPart = schema.items[1]!;
54-
55-
// Discriminator part must be an object with a single const property
56-
const discrimValue = discrimPart.properties?.[discriminatorKey]?.const;
57-
if (discrimValue === undefined || discrimPart.type !== 'object') {
58-
return null;
59-
}
60-
61-
// Ref part must be a named schema reference (not an inline or lazy schema)
62-
if (!refPart.$ref && !refPart.symbolRef) {
63-
return null;
64-
}
86+
const refPart = schema.items![1]!;
87+
const discrimValue = schema.items![0]!.properties![discriminatorKey]!.const;
6588

6689
// Lazy references can't be used in a discriminated union directly
6790
if (items[i]!.meta.hasLazy) {
@@ -72,14 +95,16 @@ export function tryBuildDiscriminatedUnion({
7295
let refExpression: Chain;
7396
if (refPart.symbolRef) {
7497
refExpression = $(refPart.symbolRef);
75-
} else {
98+
} else if (refPart.$ref) {
7699
const query: SymbolMeta = {
77100
category: 'schema',
78101
resource: 'definition',
79-
resourceId: refPart.$ref!,
102+
resourceId: refPart.$ref,
80103
tool: 'zod',
81104
};
82105
refExpression = $(ctx.plugin.referenceSymbol(query));
106+
} else {
107+
return null;
83108
}
84109

85110
// Build: `zRef.extend({ [discriminatorKey]: z.literal(value) })`

packages/openapi-ts/src/plugins/zod/v3/walker.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,6 @@ export function createVisitor(
339339
const discriminatedExpression = tryBuildDiscriminatedUnion({
340340
ctx,
341341
items,
342-
parentSchema,
343342
schemas,
344343
z,
345344
});

packages/openapi-ts/src/plugins/zod/v4/walker.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,6 @@ export function createVisitor(
351351
const discriminatedExpression = tryBuildDiscriminatedUnion({
352352
ctx,
353353
items,
354-
parentSchema,
355354
schemas,
356355
z,
357356
});

packages/shared/src/ir/types.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,6 @@ export interface IRSchemaObject
145145
* properties altogether.
146146
*/
147147
additionalProperties?: IRSchemaObject | false;
148-
/**
149-
* When present, indicates this schema uses a discriminator for polymorphism.
150-
* Used by code generators to produce optimized discriminated union output
151-
* (e.g. `z.discriminatedUnion()`).
152-
*/
153-
discriminator?: {
154-
propertyName: string;
155-
};
156148
/**
157149
* Any string value is accepted as `format`.
158150
*/

0 commit comments

Comments
 (0)