From 6a36dcc752ea2157ab904545912c16d2b83459ff Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Wed, 31 Dec 2025 18:51:25 +0000 Subject: [PATCH 1/2] feat: add discriminator to IRUnion --- .../normalization/schema-normalizer.spec.ts | 33 +++++++++++++++++++ .../core/normalization/schema-normalizer.ts | 32 +++++++++++++++++- .../src/core/openapi-types-normalized.ts | 10 ++++++ .../src/core/openapi-types.ts | 3 +- .../src/test/ir-model.fixtures.test-utils.ts | 2 +- 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/openapi-code-generator/src/core/normalization/schema-normalizer.spec.ts b/packages/openapi-code-generator/src/core/normalization/schema-normalizer.spec.ts index e97a9edf0..aa8e172ed 100644 --- a/packages/openapi-code-generator/src/core/normalization/schema-normalizer.spec.ts +++ b/packages/openapi-code-generator/src/core/normalization/schema-normalizer.spec.ts @@ -512,6 +512,39 @@ describe("core/input - SchemaNormalizer", () => { }), ) }) + + it("handles a discriminator", () => { + const actual = schemaNormalizer.normalize({ + type: "object", + discriminator: { + propertyName: "type", + mapping: { + foo: "#/components/schemas/Foo", + bar: "#/components/schemas/Bar", + }, + }, + oneOf: [ + {$ref: "#/components/schemas/Foo"}, + {$ref: "#/components/schemas/Bar"}, + ], + }) + + expect(actual).toStrictEqual( + ir.union({ + discriminator: { + propertyName: "type", + mapping: { + foo: ir.ref("/components/schemas/Foo"), + bar: ir.ref("/components/schemas/Bar"), + }, + }, + schemas: [ + ir.ref("/components/schemas/Foo"), + ir.ref("/components/schemas/Bar"), + ], + }), + ) + }) }) describe("empty schemas / additionalProperties", () => { diff --git a/packages/openapi-code-generator/src/core/normalization/schema-normalizer.ts b/packages/openapi-code-generator/src/core/normalization/schema-normalizer.ts index 4b04acb2b..cf537cadf 100644 --- a/packages/openapi-code-generator/src/core/normalization/schema-normalizer.ts +++ b/packages/openapi-code-generator/src/core/normalization/schema-normalizer.ts @@ -3,6 +3,7 @@ import {generationLib} from "../generation-lib" import type {InputConfig} from "../input" import {logger} from "../logger" import type { + Discriminator, Reference, Schema, SchemaNumber, @@ -191,7 +192,11 @@ export class SchemaNormalizer { {...base, nullable}, hasOwnProperties ? [...allOf, result] : allOf, ) - const maybeUnion = this.union({...base, nullable}, [...oneOf, ...anyOf]) + const maybeUnion = this.union( + {...base, nullable}, + [...oneOf, ...anyOf], + schemaObject.discriminator, + ) if (maybeIntersection && maybeUnion) { return this.intersection({...base, nullable}, [ @@ -375,6 +380,29 @@ export class SchemaNormalizer { } } + private normalizeDiscriminator( + discriminator: Discriminator | undefined, + ): IRModelUnion["discriminator"] | undefined { + if (!discriminator) { + return undefined + } + + if (!discriminator.mapping) { + // todo: cna we support this if the discriminated property is an enum of one element? + logger.warn("discriminators without a mapping are ignored.") + return undefined + } + + return { + propertyName: discriminator.propertyName, + mapping: Object.fromEntries( + Object.entries(discriminator.mapping).map(([key, value]) => { + return [key, {$ref: value}] + }), + ), + } + } + private normalizeComposition( items: (Schema | Reference)[] = [], parent: SchemaObject, @@ -511,6 +539,7 @@ export class SchemaNormalizer { private union( base: IRModelBase, schemas: MaybeIRModel[], + discriminator: Discriminator | undefined, ): MaybeIRModel | IRModelUnion | undefined { // (A|B)|(C|D) is the same as (A|B|C|D) // todo: merge repeated in-line schemas @@ -530,6 +559,7 @@ export class SchemaNormalizer { return { ...base, type: "union", + discriminator: this.normalizeDiscriminator(discriminator), schemas, } } diff --git a/packages/openapi-code-generator/src/core/openapi-types-normalized.ts b/packages/openapi-code-generator/src/core/openapi-types-normalized.ts index 88260bab0..235fc57de 100644 --- a/packages/openapi-code-generator/src/core/openapi-types-normalized.ts +++ b/packages/openapi-code-generator/src/core/openapi-types-normalized.ts @@ -79,6 +79,16 @@ export interface IRModelUnion extends IRModelBase { // of this. type: "union" schemas: NonEmptyArray + + discriminator?: + | { + propertyName: string + mapping: { + [propertyValue: string]: IRRef + } + //todo: support defaultMapping + } + | undefined } export interface IRModelObject extends IRModelBase { diff --git a/packages/openapi-code-generator/src/core/openapi-types.ts b/packages/openapi-code-generator/src/core/openapi-types.ts index ce61d838e..d97e97b91 100644 --- a/packages/openapi-code-generator/src/core/openapi-types.ts +++ b/packages/openapi-code-generator/src/core/openapi-types.ts @@ -359,8 +359,9 @@ export interface xInternalPreproccess { export interface Discriminator { propertyName: string mapping?: { - [k: string]: string + [propertyValue: string]: string } + defaultMapping?: string } export interface Example { diff --git a/packages/openapi-code-generator/src/test/ir-model.fixtures.test-utils.ts b/packages/openapi-code-generator/src/test/ir-model.fixtures.test-utils.ts index 0db063304..e668cf702 100644 --- a/packages/openapi-code-generator/src/test/ir-model.fixtures.test-utils.ts +++ b/packages/openapi-code-generator/src/test/ir-model.fixtures.test-utils.ts @@ -115,6 +115,7 @@ const extension = { schemas: [base.any], nullable: false, default: undefined, + discriminator: undefined, "x-internal-preprocess": undefined, } satisfies IRModelUnion, null: { @@ -178,7 +179,6 @@ export const irFixture = { ref(path: string, file = ""): IRRef { return { $ref: `${file}#${path}`, - "x-internal-preprocess": undefined, } }, any(partial: Partial = {}): IRModelAny { From 57b475dd1ad7e9e2aa4847490d8d88987464715d Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sun, 4 Jan 2026 13:32:40 +0000 Subject: [PATCH 2/2] wip --- .../src/app/overview/compatibility/page.mdx | 72 +++++----- .../openapi-code-generator/src/core/input.ts | 2 +- .../parameter-normalizer.spec.ts | 6 +- .../normalization/schema-normalizer.spec.ts | 134 ++++++++++++++++-- .../core/normalization/schema-normalizer.ts | 44 ++++-- .../abstract-schema-builder.ts | 28 +++- .../schema-builders/joi-schema-builder.ts | 7 + .../schema-builders/zod-v3-schema-builder.ts | 16 +++ .../schema-builders/zod-v4-schema-builder.ts | 16 +++ .../zod-v4-schema-builder.unit.spec.ts | 47 ++++++ 10 files changed, 311 insertions(+), 61 deletions(-) diff --git a/packages/documentation/src/app/overview/compatibility/page.mdx b/packages/documentation/src/app/overview/compatibility/page.mdx index 094f1ed37..6e36fde39 100644 --- a/packages/documentation/src/app/overview/compatibility/page.mdx +++ b/packages/documentation/src/app/overview/compatibility/page.mdx @@ -244,42 +244,42 @@ openapi specification / json schema validation specifications. Most notable exception is `readOnly` / `writeOnly` which are currently ignored, planned to be addressed prior to v1. -| Attribute | Supported | Notes | -|:---------------------|:---------:|:---------------------------------------------------------------------------------------------------------------------------------------------------------| -| title | __N/A__ | | -| multipleOf | ✅ | Applies to `type: number` | -| maximum | ✅ | Applies to `type: number` | -| exclusiveMaximum | ✅ | Applies to `type: number` | -| minimum | ✅ | Applies to `type: number` | -| exclusiveMinimum | ✅ | Applies to `type: number` | -| maxLength | ✅ | Applies to `type: string` | -| minLength | ✅ | Applies to `type: string` | -| pattern | ✅ | Support for `type: string` | -| maxItems | ✅ | Applies to `type: array` | -| minItems | ✅ | Applies to `type: array` | -| uniqueItems | ✅ | Applies to `type: array` | -| maxProperties | 🚫 | Not yet supported | -| minProperties | 🚫 | Not yet supported | -| required | ✅ | Controls whether `undefined` is allowed for each value in `properties` | -| enum | ✅ | Applies to `type: number`, `type: string` and `type: boolean` | -| type | ✅ | | -| not | 🚫 | Not yet supported | -| allOf | ✅ | Produces a intersection type like `A & B` | -| oneOf | ✅ | Produces a union type like `A \| B` | -| anyOf | ✅ | Produces a union type like `A \| B` | -| items | ✅ | Applies to `type: array` | -| properties | ✅ | Applies to `type: object` | -| additionalProperties | ✅ | Fairly comprehensive support, produces `Record` or `unknown`/`any` (dependent on [`--ts-allow-any`](../reference/cli-options#--ts-allow-any)) | -| format | ✅/🚧 | Limited support for format `email` and `date-time` | -| default | ✅ | | -| nullable | ✅ | Also supports `type: null` as an alternative | -| discriminator | 🚫 | Ignored. Union / Intersection types are usd based on `anyOf` / `allOf` / `oneOf`. | -| readOnly | 🚫 | Not yet supported | -| writeOnly | 🚫 | Not yet supported | -| example | __N/A__ | Ignored | -| externalDocs | __N/A__ | Ignored | -| deprecated | 🚫 | Not yet supported | -| xml | 🚫 | Not yet supported | +| Attribute | Supported | Notes | +|:---------------------|:---------:|:---------------------------------------------------------------------------------------------------------------------------| +| title | __N/A__ | | +| multipleOf | ✅ | Applies to `type: number` | +| maximum | ✅ | Applies to `type: number` | +| exclusiveMaximum | ✅ | Applies to `type: number` | +| minimum | ✅ | Applies to `type: number` | +| exclusiveMinimum | ✅ | Applies to `type: number` | +| maxLength | ✅ | Applies to `type: string` | +| minLength | ✅ | Applies to `type: string` | +| pattern | ✅ | Support for `type: string` | +| maxItems | ✅ | Applies to `type: array` | +| minItems | ✅ | Applies to `type: array` | +| uniqueItems | ✅ | Applies to `type: array` | +| maxProperties | 🚫 | Not yet supported | +| minProperties | 🚫 | Not yet supported | +| required | ✅ | Controls whether `undefined` is allowed for each value in `properties` | +| enum | ✅ | Applies to `type: number`, `type: string` and `type: boolean` | +| type | ✅ | | +| not | 🚫 | Not yet supported | +| allOf | ✅ | Produces a intersection type like `A & B` | +| oneOf | ✅ | Produces a union type like `A \| B` | +| anyOf | ✅ | Produces a union type like `A \| B` | +| items | ✅ | Applies to `type: array` | +| properties | ✅ | Applies to `type: object` | +| additionalProperties | ✅ | Produces `Record` or `unknown`/`any` (dependent on [`--ts-allow-any`](../reference/cli-options#--ts-allow-any)) | +| format | ✅/🚧 | Limited support for format `binary`, `email` and `date-time` | +| default | ✅ | | +| nullable | ✅ | Also supports `type: null` as an alternative | +| discriminator | 🚫 | Ignored. Union / Intersection types are usd based on `anyOf` / `allOf` / `oneOf`. | +| readOnly | 🚫 | Not yet supported | +| writeOnly | 🚫 | Not yet supported | +| example | __N/A__ | Ignored | +| externalDocs | __N/A__ | Ignored | +| deprecated | 🚫 | Not yet supported | +| xml | 🚫 | Not yet supported | ### Encoding Object | Attribute | Supported | Notes | diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index 4dedf4dfb..f9a4d0bd0 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -48,7 +48,7 @@ export class Input implements ISchemaProvider { private loader: OpenapiLoader, readonly config: InputConfig, private readonly syntheticNameGenerator: SyntheticNameGenerator = defaultSyntheticNameGenerator, - private readonly schemaNormalizer = new SchemaNormalizer(config), + private readonly schemaNormalizer = new SchemaNormalizer(config, this), private readonly parameterNormalizer = new ParameterNormalizer( loader, schemaNormalizer, diff --git a/packages/openapi-code-generator/src/core/normalization/parameter-normalizer.spec.ts b/packages/openapi-code-generator/src/core/normalization/parameter-normalizer.spec.ts index f52ed1583..c90448024 100644 --- a/packages/openapi-code-generator/src/core/normalization/parameter-normalizer.spec.ts +++ b/packages/openapi-code-generator/src/core/normalization/parameter-normalizer.spec.ts @@ -5,9 +5,11 @@ import type {Parameter} from "../openapi-types" import {defaultSyntheticNameGenerator} from "../synthetic-name-generator" import {ParameterNormalizer} from "./parameter-normalizer" import {SchemaNormalizer} from "./schema-normalizer" +import { FakeSchemaProvider } from "../../test/fake-schema-provider" describe("ParameterNormalizer", () => { let loader: jest.Mocked + let fakeSchemaProvider: FakeSchemaProvider let schemaNormalizer: SchemaNormalizer let parameterNormalizer: ParameterNormalizer @@ -18,10 +20,12 @@ describe("ParameterNormalizer", () => { addVirtualType: jest.fn(), } as unknown as jest.Mocked + fakeSchemaProvider = new FakeSchemaProvider() + schemaNormalizer = new SchemaNormalizer({ extractInlineSchemas: true, enumExtensibility: "open", - }) + }, fakeSchemaProvider) parameterNormalizer = new ParameterNormalizer( loader, diff --git a/packages/openapi-code-generator/src/core/normalization/schema-normalizer.spec.ts b/packages/openapi-code-generator/src/core/normalization/schema-normalizer.spec.ts index aa8e172ed..6c7400fa9 100644 --- a/packages/openapi-code-generator/src/core/normalization/schema-normalizer.spec.ts +++ b/packages/openapi-code-generator/src/core/normalization/schema-normalizer.spec.ts @@ -1,12 +1,22 @@ -import {describe, expect, it} from "@jest/globals" +import {beforeEach, describe, expect, it} from "@jest/globals" +import {FakeSchemaProvider} from "../../test/fake-schema-provider" import {irFixture as ir} from "../../test/ir-model.fixtures.test-utils" import {generationLib} from "../generation-lib" import {SchemaNormalizer} from "./schema-normalizer" describe("core/input - SchemaNormalizer", () => { - const schemaNormalizer = new SchemaNormalizer({ - extractInlineSchemas: true, - enumExtensibility: "open", + let schemaProvider: FakeSchemaProvider + let schemaNormalizer: SchemaNormalizer + + beforeEach(() => { + schemaProvider = new FakeSchemaProvider() + schemaNormalizer = new SchemaNormalizer( + { + extractInlineSchemas: true, + enumExtensibility: "open", + }, + schemaProvider, + ) }) it("passes through $ref untouched", () => { @@ -512,10 +522,100 @@ describe("core/input - SchemaNormalizer", () => { }), ) }) + }) + + describe("discriminator", () => { + describe("all alternatives are $ref of type: object", () => { + it("supports mapping", () => { + schemaProvider.registerTestRef( + ir.ref("/components/schemas/Foo"), + ir.object({properties: {type: ir.string()}}), + ) + schemaProvider.registerTestRef( + ir.ref("/components/schemas/Bar"), + ir.object({properties: {type: ir.string()}}), + ) + + const actual = schemaNormalizer.normalize({ + type: "object", + discriminator: { + propertyName: "type", + mapping: { + foo: "#/components/schemas/Foo", + bar: "#/components/schemas/Bar", + }, + }, + oneOf: [ + {$ref: "#/components/schemas/Foo"}, + {$ref: "#/components/schemas/Bar"}, + ], + }) + + expect(actual).toStrictEqual( + ir.union({ + discriminator: { + propertyName: "type", + mapping: { + foo: ir.ref("/components/schemas/Foo"), + bar: ir.ref("/components/schemas/Bar"), + }, + }, + schemas: [ + ir.ref("/components/schemas/Foo"), + ir.ref("/components/schemas/Bar"), + ], + }), + ) + }) + + it("infers a mapping when none provided", () => { + schemaProvider.registerTestRef( + ir.ref("/components/schemas/Foo"), + ir.object({properties: {type: ir.string()}}), + ) + schemaProvider.registerTestRef( + ir.ref("/components/schemas/Bar"), + ir.object({properties: {type: ir.string()}}), + ) + + const actual = schemaNormalizer.normalize({ + type: "object", + discriminator: { + propertyName: "type", + }, + oneOf: [ + {$ref: "#/components/schemas/Foo"}, + {$ref: "#/components/schemas/Bar"}, + ], + }) - it("handles a discriminator", () => { + expect(actual).toStrictEqual( + ir.union({ + discriminator: { + propertyName: "type", + mapping: { + Foo: ir.ref("/components/schemas/Foo"), + Bar: ir.ref("/components/schemas/Bar"), + }, + }, + schemas: [ + ir.ref("/components/schemas/Foo"), + ir.ref("/components/schemas/Bar"), + ], + }), + ) + }) + }) + + it("ignores the discriminator property where some alternatives are not type: object", () => {}) + + it("ignores the discriminator property where no composition is defined", () => { const actual = schemaNormalizer.normalize({ type: "object", + properties: { + name: {type: "string"}, + type: {type: "string"}, + }, discriminator: { propertyName: "type", mapping: { @@ -523,23 +623,29 @@ describe("core/input - SchemaNormalizer", () => { bar: "#/components/schemas/Bar", }, }, + }) + + expect(actual).toStrictEqual( + ir.object({properties: {name: ir.string(), type: ir.string()}}), + ) + }) + + it("ignores the discriminator property where some alternatives are inline schemas", () => { + const actual = schemaNormalizer.normalize({ + type: "object", + discriminator: { + propertyName: "type", + }, oneOf: [ - {$ref: "#/components/schemas/Foo"}, + {type: "object", properties: {type: {type: "string"}}}, {$ref: "#/components/schemas/Bar"}, ], }) expect(actual).toStrictEqual( ir.union({ - discriminator: { - propertyName: "type", - mapping: { - foo: ir.ref("/components/schemas/Foo"), - bar: ir.ref("/components/schemas/Bar"), - }, - }, schemas: [ - ir.ref("/components/schemas/Foo"), + ir.object({properties: {type: ir.string()}}), ir.ref("/components/schemas/Bar"), ], }), diff --git a/packages/openapi-code-generator/src/core/normalization/schema-normalizer.ts b/packages/openapi-code-generator/src/core/normalization/schema-normalizer.ts index cf537cadf..6e78653c0 100644 --- a/packages/openapi-code-generator/src/core/normalization/schema-normalizer.ts +++ b/packages/openapi-code-generator/src/core/normalization/schema-normalizer.ts @@ -1,6 +1,6 @@ import {isNonEmptyArray} from "@nahkies/typescript-common-runtime/types" import {generationLib} from "../generation-lib" -import type {InputConfig} from "../input" +import type {InputConfig, ISchemaProvider} from "../input" import {logger} from "../logger" import type { Discriminator, @@ -25,10 +25,13 @@ import type { IRRef, MaybeIRModel, } from "../openapi-types-normalized" -import {isRef} from "../openapi-utils" +import {getRawNameFromRef, isRef} from "../openapi-utils" export class SchemaNormalizer { - constructor(readonly config: InputConfig) {} + constructor( + private readonly config: InputConfig, + private readonly schemaProvider: ISchemaProvider, + ) {} public isNormalized(schema: Schema | IRModel): schema is IRModel { return schema && Reflect.get(schema, "isIRModel") @@ -382,21 +385,46 @@ export class SchemaNormalizer { private normalizeDiscriminator( discriminator: Discriminator | undefined, + schemas: MaybeIRModel[], ): IRModelUnion["discriminator"] | undefined { if (!discriminator) { return undefined } - if (!discriminator.mapping) { - // todo: cna we support this if the discriminated property is an enum of one element? - logger.warn("discriminators without a mapping are ignored.") + const referencedSchemas = schemas.filter((it) => isRef(it)) + + const includesInlineSchemas = referencedSchemas.length !== schemas.length + + if (includesInlineSchemas) { + logger.info( + `ignoring 'discriminator' over propertyName '${discriminator.propertyName}' as the union includes inline schemas`, + ) + return undefined + } + + // todo: infinite loop possibility? might make more sense to go to the loader. + const everyAlternativeIsObject = referencedSchemas.every( + (it) => this.schemaProvider.schema(it).type === "object", + ) + + if (!everyAlternativeIsObject) { + logger.info( + `ignoring 'discriminator' over propertyName '${discriminator.propertyName}' as the union references non-object schemas`, + ) return undefined } + // note: mapping is optional, where the default is ${NAME} -> '#/components/schemas/${NAME}' + const mapping = + discriminator.mapping ?? + Object.fromEntries( + referencedSchemas.map((it) => [getRawNameFromRef(it), it.$ref]), + ) + return { propertyName: discriminator.propertyName, mapping: Object.fromEntries( - Object.entries(discriminator.mapping).map(([key, value]) => { + Object.entries(mapping).map(([key, value]) => { return [key, {$ref: value}] }), ), @@ -559,7 +587,7 @@ export class SchemaNormalizer { return { ...base, type: "union", - discriminator: this.normalizeDiscriminator(discriminator), + discriminator: this.normalizeDiscriminator(discriminator, schemas), schemas, } } diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts index 1612d3cbe..e3739a47e 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts @@ -179,6 +179,14 @@ export abstract class AbstractSchemaBuilder< let result: string if (isRef(maybeModel)) { + // if (!maybeModel.$ref) { + // return this.fromModel( + // this.input.schema(maybeModel), + // required, + // isAnonymous, + // nullable, + // ) + // } const name = this.add(maybeModel) result = name @@ -247,7 +255,20 @@ export abstract class AbstractSchemaBuilder< } case "union": { - result = this.union(model.schemas.map((it) => this.fromModel(it, true))) + if (model.discriminator) { + result = this.discriminatedUnion( + model.discriminator.propertyName, + Object.fromEntries( + Object.entries(model.discriminator.mapping).map( + ([value, ref]) => [value, this.fromModel(ref, true)], + ), + ), + ) + } else { + result = this.union( + model.schemas.map((it) => this.fromModel(it, true)), + ) + } break } @@ -353,6 +374,11 @@ export abstract class AbstractSchemaBuilder< protected abstract union(schemas: string[]): string + protected abstract discriminatedUnion( + propertyName: string, + mapping: Record, + ): string + protected abstract preprocess( schema: string, transformation: string | ((it: unknown) => unknown), diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts index 007c8a8e9..9b815fe9c 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts @@ -146,6 +146,13 @@ export class JoiBuilder extends AbstractSchemaBuilder< .join(".") } + protected discriminatedUnion( + propertyName: string, + mapping: Record, + ): string { + return this.union(Object.values(mapping)) + } + protected preprocess( schema: string, transformation: string | ((it: unknown) => unknown), diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts index 38bed1ba0..a09b3254f 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts @@ -154,6 +154,22 @@ export class ZodV3Builder extends AbstractSchemaBuilder< .join(".") } + protected discriminatedUnion( + propertyName: string, + mapping: Record, + ): string { + const schemas = Object.values(mapping) + + return [ + zod, + `discriminatedUnion("${propertyName}", [\n${schemas + .map((it) => `${it},`) + .join("\n")}\n])`, + ] + .filter(isDefined) + .join(".") + } + protected preprocess( schema: string, transformation: string | ((it: unknown) => unknown), diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts index 8ccc0587e..a7ae3b3be 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts @@ -156,6 +156,22 @@ export class ZodV4Builder extends AbstractSchemaBuilder< .join(".") } + protected discriminatedUnion( + propertyName: string, + mapping: Record, + ): string { + const schemas = Object.values(mapping) + + return [ + zod, + `discriminatedUnion("${propertyName}", [\n${schemas + .map((it) => `${it},`) + .join("\n")}\n])`, + ] + .filter(isDefined) + .join(".") + } + protected preprocess( schema: string, transformation: string | ((it: unknown) => unknown), diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.unit.spec.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.unit.spec.ts index 8eb73bf1e..be0cd625d 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.unit.spec.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.unit.spec.ts @@ -1105,6 +1105,53 @@ describe("typescript/common/schema-builders/zod-v4-schema-builder - unit tests", await expect(execute("some string")).resolves.toEqual("some string") }) + + it("can generate a discriminated union", async () => { + const {code, execute} = await getActual( + ir.union({ + schemas: [ + ir.object({ + properties: {kind: ir.string({enum: ["a"]}), foo: ir.string()}, + required: ["kind", "foo"], + }), + ir.object({ + properties: {kind: ir.string({enum: ["b"]}), bar: ir.string()}, + required: ["kind", "bar"], + }), + ], + discriminator: { + propertyName: "kind", + mapping: { + a: ir.object({ + properties: {kind: ir.string({enum: ["a"]}), foo: ir.string()}, + required: ["kind", "foo"], + }), + b: ir.object({ + properties: {kind: ir.string({enum: ["b"]}), bar: ir.string()}, + required: ["kind", "bar"], + }), + } as any, + }, + }), + ) + + expect(code).toMatchInlineSnapshot(` + "const x = z.discriminatedUnion("kind", [ + z.object({ kind: , foo: z.string() }), + z.object({ kind: z.literal("b"), bar: z.string() }), + ])" + `) + + await expect(execute({kind: "a", foo: "foo"})).resolves.toEqual({ + kind: "a", + foo: "foo", + }) + + await expect(execute({kind: "b", bar: "bar"})).resolves.toEqual({ + kind: "b", + bar: "bar", + }) + }) }) describe("intersections", () => {