Skip to content

Commit 6cd823c

Browse files
authored
feat: add discriminator to IR; use it in Zod/Valibot instead of structural detection
Agent-Logs-Url: https://github.com/hey-api/openapi-ts/sessions/b227e969-7812-4a3e-8d16-e20a684d95a5
1 parent ea356ea commit 6cd823c

7 files changed

Lines changed: 45 additions & 93 deletions

File tree

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

Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -8,49 +8,8 @@ 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-
5211
function baseNode(ctx: UnionResolverContext): PipeResult {
53-
const { childResults, plugin, schemas, symbols } = ctx;
12+
const { childResults, parentSchema, plugin, schemas, symbols } = ctx;
5413
const { v } = symbols;
5514

5615
const nonNullItems: Array<ValibotResult> = [];
@@ -69,7 +28,7 @@ function baseNode(ctx: UnionResolverContext): PipeResult {
6928
return nonNullItems[0]!.pipes;
7029
}
7130

72-
const discriminatorKey = detectDiscriminatorKey(schemas);
31+
const discriminatorKey = parentSchema.discriminator?.propertyName;
7332
if (discriminatorKey) {
7433
const itemNodes = nonNullItems.map((i) => pipesToNode(i.pipes, plugin));
7534
return $(v)

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

Lines changed: 18 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,68 +8,28 @@ import type { Chain } from './chain';
88
import type { ZodResult } from './types';
99

1010
/**
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.
11+
* Attempts to build a `z.discriminatedUnion()` expression when the parent
12+
* schema carries a `discriminator` set by the IR parser. Each non-null union
13+
* item is expected to follow the pattern
14+
* `{ logicalOperator: 'and', items: [discrimObj, ref] }`
15+
* produced by the OpenAPI discriminator parser.
5816
*
5917
* Returns the expression on success, or `null` to fall back to `z.union()`.
6018
*/
6119
export function tryBuildDiscriminatedUnion({
6220
ctx,
6321
items,
22+
parentSchema,
6423
schemas,
6524
z,
6625
}: {
6726
ctx: SchemaVisitorContext<ZodPlugin['Instance']>;
6827
items: ReadonlyArray<ZodResult>;
28+
parentSchema: IR.SchemaObject;
6929
schemas: ReadonlyArray<IR.SchemaObject>;
7030
z: ReturnType<typeof ctx.plugin.external>;
7131
}): Chain | null {
72-
const discriminatorKey = detectDiscriminatorKey(schemas);
32+
const discriminatorKey = parentSchema.discriminator?.propertyName;
7333
if (!discriminatorKey) {
7434
return null;
7535
}
@@ -83,8 +43,16 @@ export function tryBuildDiscriminatedUnion({
8343
continue;
8444
}
8545

86-
const refPart = schema.items![1]!;
87-
const discrimValue = schema.items![0]!.properties![discriminatorKey]!.const;
46+
if (schema.logicalOperator !== 'and' || !schema.items || schema.items.length !== 2) {
47+
return null;
48+
}
49+
50+
const refPart = schema.items[1]!;
51+
const discrimValue = schema.items[0]!.properties?.[discriminatorKey]?.const;
52+
53+
if (discrimValue === undefined) {
54+
return null;
55+
}
8856

8957
// Lazy references can't be used in a discriminated union directly
9058
if (items[i]!.meta.hasLazy) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ export function createVisitor(
339339
const discriminatedExpression = tryBuildDiscriminatedUnion({
340340
ctx,
341341
items,
342+
parentSchema,
342343
schemas,
343344
z,
344345
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ export function createVisitor(
351351
const discriminatedExpression = tryBuildDiscriminatedUnion({
352352
ctx,
353353
items,
354+
parentSchema,
354355
schemas,
355356
z,
356357
});

packages/shared/src/ir/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ export interface IRSchemaObject
145145
* properties altogether.
146146
*/
147147
additionalProperties?: IRSchemaObject | false;
148+
/**
149+
* When present on a union schema, indicates that this union uses a
150+
* discriminator for polymorphism.
151+
*/
152+
discriminator?: {
153+
propertyName: string;
154+
};
148155
/**
149156
* Any string value is accepted as `format`.
150157
*/

packages/shared/src/openApi/3.0.x/parser/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,10 @@ function parseAnyOf({
848848
}
849849
}
850850

851+
if (schema.discriminator && irSchema.logicalOperator === 'or') {
852+
irSchema.discriminator = { propertyName: schema.discriminator.propertyName };
853+
}
854+
851855
return irSchema;
852856
}
853857

@@ -1030,6 +1034,10 @@ function parseOneOf({
10301034
}
10311035
}
10321036

1037+
if (schema.discriminator && irSchema.logicalOperator === 'or') {
1038+
irSchema.discriminator = { propertyName: schema.discriminator.propertyName };
1039+
}
1040+
10331041
return irSchema;
10341042
}
10351043

packages/shared/src/openApi/3.1.x/parser/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,10 @@ function parseAnyOf({
932932
}
933933
}
934934

935+
if (schema.discriminator && irSchema.logicalOperator === 'or') {
936+
irSchema.discriminator = { propertyName: schema.discriminator.propertyName };
937+
}
938+
935939
return irSchema;
936940
}
937941

@@ -1105,6 +1109,10 @@ function parseOneOf({
11051109
}
11061110
}
11071111

1112+
if (schema.discriminator && irSchema.logicalOperator === 'or') {
1113+
irSchema.discriminator = { propertyName: schema.discriminator.propertyName };
1114+
}
1115+
11081116
return irSchema;
11091117
}
11101118

0 commit comments

Comments
 (0)