diff --git a/biome.jsonc b/biome.jsonc index 2278711fe..25f161f98 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -51,7 +51,7 @@ "test": "recommended" }, "rules": { - "recommended": true, + "preset": "recommended", "complexity": { "noForEach": "error" }, diff --git a/e2e/src/generated/client/axios/models.ts b/e2e/src/generated/client/axios/models.ts index 8f5780af3..2f7a64380 100644 --- a/e2e/src/generated/client/axios/models.ts +++ b/e2e/src/generated/client/axios/models.ts @@ -10,7 +10,12 @@ export type UnknownEnumStringValue = string & { _brand: "unknown enum string value" } -export type t_Animal = t_Cat | t_Dog +export type t_Animal = + | t_Cat + | t_Dog + | { + type: UnknownEnumStringValue + } export type t_Cat = { lives?: number | undefined diff --git a/e2e/src/generated/client/axios/schemas.ts b/e2e/src/generated/client/axios/schemas.ts index 3b0d235f5..6dc34fe8a 100644 --- a/e2e/src/generated/client/axios/schemas.ts +++ b/e2e/src/generated/client/axios/schemas.ts @@ -49,7 +49,15 @@ export const s_RandomNumber = z.object({ .optional(), }) -export const s_Animal = z.discriminatedUnion("type", [s_Cat, s_Dog]) +export const s_Animal = z.discriminatedUnion("type", [ + s_Cat, + s_Dog, + z.object({ + type: z + .string() + .transform((it) => it as typeof it & UnknownEnumStringValue), + }), +]) export const s_GetHeadersUndeclared200Response = z.object({ rawHeaders: z.record(z.string(), z.unknown()).optional(), diff --git a/e2e/src/generated/client/fetch/models.ts b/e2e/src/generated/client/fetch/models.ts index 8f5780af3..2f7a64380 100644 --- a/e2e/src/generated/client/fetch/models.ts +++ b/e2e/src/generated/client/fetch/models.ts @@ -10,7 +10,12 @@ export type UnknownEnumStringValue = string & { _brand: "unknown enum string value" } -export type t_Animal = t_Cat | t_Dog +export type t_Animal = + | t_Cat + | t_Dog + | { + type: UnknownEnumStringValue + } export type t_Cat = { lives?: number | undefined diff --git a/e2e/src/generated/client/fetch/schemas.ts b/e2e/src/generated/client/fetch/schemas.ts index 3b0d235f5..6dc34fe8a 100644 --- a/e2e/src/generated/client/fetch/schemas.ts +++ b/e2e/src/generated/client/fetch/schemas.ts @@ -49,7 +49,15 @@ export const s_RandomNumber = z.object({ .optional(), }) -export const s_Animal = z.discriminatedUnion("type", [s_Cat, s_Dog]) +export const s_Animal = z.discriminatedUnion("type", [ + s_Cat, + s_Dog, + z.object({ + type: z + .string() + .transform((it) => it as typeof it & UnknownEnumStringValue), + }), +]) export const s_GetHeadersUndeclared200Response = z.object({ rawHeaders: z.record(z.string(), z.unknown()).optional(), diff --git a/packages/documentation/src/lib/playground.tsx b/packages/documentation/src/lib/playground.tsx index cacf0b7c1..10efb8bb3 100644 --- a/packages/documentation/src/lib/playground.tsx +++ b/packages/documentation/src/lib/playground.tsx @@ -60,6 +60,7 @@ const defaultConfig = { tsIsEsmProject: false, tsServerImplementationMethod: "type", enumExtensibility: "", + unionExtensibility: "", } satisfies Config const EditorFileSelectorWrapper: React.FC = ({children}) => { diff --git a/packages/documentation/src/lib/playground/config-form.tsx b/packages/documentation/src/lib/playground/config-form.tsx index 7769d401d..66a622ec7 100644 --- a/packages/documentation/src/lib/playground/config-form.tsx +++ b/packages/documentation/src/lib/playground/config-form.tsx @@ -19,6 +19,7 @@ const schema = configSchema.pick({ tsServerImplementationMethod: true, tsIsEsmProject: true, enumExtensibility: true, + unionExtensibility: true, }) type Inputs = z.infer @@ -40,6 +41,7 @@ export const ConfigForm: React.FC<{ tsServerImplementationMethod: config.tsServerImplementationMethod, tsIsEsmProject: config.tsIsEsmProject, enumExtensibility: config.enumExtensibility, + unionExtensibility: config.unionExtensibility, } as const, }) @@ -108,6 +110,12 @@ export const ConfigForm: React.FC<{ label={"--enum-extensibility"} values={schema.shape.enumExtensibility.options} /> + ) } diff --git a/packages/openapi-code-generator/src/cli.ts b/packages/openapi-code-generator/src/cli.ts index afa7a8025..59a95c357 100644 --- a/packages/openapi-code-generator/src/cli.ts +++ b/packages/openapi-code-generator/src/cli.ts @@ -148,6 +148,16 @@ const program = new Command() .default("") .makeOptionMandatory(), ) + .addOption( + new Option( + "--union-extensibility ", + "Whether discriminated unions should be open to unknown values, or closed. Defaults to open for client templates, and closed for server templates.", + ) + .env("OPENAPI_UNION_EXTENSIBILITY") + .choices(["open", "closed"] as const) + .default("") + .makeOptionMandatory(), + ) .addOption( new Option( "--grouping-strategy ", diff --git a/packages/openapi-code-generator/src/config.ts b/packages/openapi-code-generator/src/config.ts index 1123b81dd..8c4217ad7 100644 --- a/packages/openapi-code-generator/src/config.ts +++ b/packages/openapi-code-generator/src/config.ts @@ -25,6 +25,7 @@ export type Config = { groupingStrategy: "none" | "first-slug" | "first-tag" filenameConvention: IdentifierConvention enumExtensibility: "" | "open" | "closed" + unionExtensibility: "" | "open" | "closed" tsAllowAny: boolean tsServerImplementationMethod: ServerImplementationMethod tsIsEsmProject: boolean @@ -63,6 +64,7 @@ export const configSchema = z.object({ "snake-case", ]), enumExtensibility: z.enum(["", "open", "closed"]), + unionExtensibility: z.enum(["", "open", "closed"]), tsAllowAny: z.boolean(), tsServerImplementationMethod: tsServerImplementationSchema, tsIsEsmProject: z.boolean(), diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index b7d767458..7144e2dee 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -35,6 +35,7 @@ export type OperationGroupStrategy = "none" | "first-tag" | "first-slug" export type InputConfig = { extractInlineSchemas: boolean enumExtensibility: "open" | "closed" + unionExtensibility: "open" | "closed" } export interface ISchemaProvider { 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 c7aed5d00..9d0c43258 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 @@ -26,6 +26,7 @@ describe("ParameterNormalizer", () => { { extractInlineSchemas: true, enumExtensibility: "open", + unionExtensibility: "open", }, fakeSchemaProvider, ) 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 230474461..b287643f1 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 @@ -14,6 +14,7 @@ describe("core/input - SchemaNormalizer", () => { { extractInlineSchemas: true, enumExtensibility: "open", + unionExtensibility: "open", }, schemaProvider, ) @@ -455,6 +456,7 @@ describe("core/input - SchemaNormalizer", () => { ir.union({ nullable: true, schemas: [ir.number(), {$ref: "#/components/schemas/RefModel"}], + "x-union-extensibility": "open", }), ) }) @@ -473,6 +475,7 @@ describe("core/input - SchemaNormalizer", () => { ir.union({ nullable: true, schemas: [ir.number(), {$ref: "#/components/schemas/Another"}], + "x-union-extensibility": "open", }), ) }) @@ -557,6 +560,7 @@ describe("core/input - SchemaNormalizer", () => { }, }), ], + "x-union-extensibility": "open", }), ], }), @@ -598,6 +602,7 @@ describe("core/input - SchemaNormalizer", () => { }, }), ], + "x-union-extensibility": "open", }), ], }), @@ -635,6 +640,7 @@ describe("core/input - SchemaNormalizer", () => { ir.object({properties: {foo: ir.string()}, required: ["foo"]}), ir.object({properties: {bar: ir.string()}, required: ["bar"]}), ], + "x-union-extensibility": "open", }), ], }), @@ -688,6 +694,7 @@ describe("core/input - SchemaNormalizer", () => { ir.ref("/components/schemas/Foo"), ir.ref("/components/schemas/Bar"), ], + "x-union-extensibility": "open", }), ) }) @@ -736,6 +743,7 @@ describe("core/input - SchemaNormalizer", () => { ir.ref("/components/schemas/Foo", "/absolute/path.yaml"), ir.ref("/components/schemas/Bar", "/absolute/path.yaml"), ], + "x-union-extensibility": "open", }), ) }) @@ -823,6 +831,7 @@ describe("core/input - SchemaNormalizer", () => { ir.ref("/components/schemas/Foo"), ir.ref("/components/schemas/Bar"), ], + "x-union-extensibility": "open", }), ) }) @@ -940,6 +949,7 @@ describe("core/input - SchemaNormalizer", () => { ir.object({properties: {type: ir.string()}}), ir.ref("/components/schemas/Bar"), ], + "x-union-extensibility": "open", }), ) }) @@ -977,6 +987,7 @@ describe("core/input - SchemaNormalizer", () => { ir.ref("/components/schemas/Foo"), ir.ref("/components/schemas/Bar"), ], + "x-union-extensibility": "open", }), ) }) @@ -1008,6 +1019,7 @@ describe("core/input - SchemaNormalizer", () => { ir.ref("/components/schemas/Foo"), ir.ref("/components/schemas/Bar"), ], + "x-union-extensibility": "open", }), ) }) @@ -1039,6 +1051,7 @@ describe("core/input - SchemaNormalizer", () => { ir.ref("/components/schemas/Foo"), ir.ref("/components/schemas/Bar"), ], + "x-union-extensibility": "open", }), ) }) @@ -1198,6 +1211,7 @@ describe("core/input - SchemaNormalizer", () => { expect(actual).toStrictEqual( ir.union({ schemas: [ir.string({nullable: true}), ir.number({nullable: true})], + "x-union-extensibility": "open", }), ) }) 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 5b0a2c221..af862e36f 100644 --- a/packages/openapi-code-generator/src/core/normalization/schema-normalizer.ts +++ b/packages/openapi-code-generator/src/core/normalization/schema-normalizer.ts @@ -47,6 +47,16 @@ export class SchemaNormalizer { ) } + private getUnionExtensibility( + schemaObject: {"x-union-extensibility"?: "open" | "closed" | undefined}, + enumValues: unknown[], + ) { + return ( + schemaObject["x-union-extensibility"] ?? + (enumValues.length === 1 ? "closed" : this.config.unionExtensibility) + ) + } + private hasPropertiesOrComposition(schema: SchemaObject): boolean { return Boolean( schema.allOf?.length || @@ -209,6 +219,7 @@ export class SchemaNormalizer { {...base, nullable}, [...oneOf, ...anyOf], schemaObject.discriminator, + this.getUnionExtensibility(schemaObject, [...oneOf, ...anyOf]), ) if (maybeIntersection && maybeUnion) { @@ -657,6 +668,7 @@ export class SchemaNormalizer { base: IRModelBase, schemas: MaybeIRModel[], discriminator: Discriminator | undefined, + extensibility: "open" | "closed" | undefined, ): MaybeIRModel | IRModelUnion | undefined { // (A|B)|(C|D) is the same as (A|B|C|D) // todo: merge repeated in-line schemas @@ -668,7 +680,12 @@ export class SchemaNormalizer { if (isNonEmptyArray(schemas)) { if (schemas.length === 1) { if (base.nullable) { - return {...base, type: "union", schemas} + return { + ...base, + type: "union", + schemas, + "x-union-extensibility": extensibility, + } } return schemas[0] } @@ -678,6 +695,7 @@ export class SchemaNormalizer { type: "union", discriminator: this.normalizeDiscriminator(discriminator, schemas), schemas, + "x-union-extensibility": extensibility, } } 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 4bf2891d3..21f16d118 100644 --- a/packages/openapi-code-generator/src/core/openapi-types-normalized.ts +++ b/packages/openapi-code-generator/src/core/openapi-types-normalized.ts @@ -90,6 +90,7 @@ export interface IRModelUnion extends IRModelBase { //todo: support defaultMapping } | undefined + "x-union-extensibility"?: "open" | "closed" | 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 d97e97b91..1bfb6649b 100644 --- a/packages/openapi-code-generator/src/core/openapi-types.ts +++ b/packages/openapi-code-generator/src/core/openapi-types.ts @@ -274,6 +274,7 @@ export interface SchemaBase { */ "x-internal-preprocess"?: xInternalPreproccess | Reference | undefined "x-enum-extensibility"?: "open" | "closed" | undefined + "x-union-extensibility"?: "open" | "closed" | undefined } export interface SchemaNumber extends SchemaBase { diff --git a/packages/openapi-code-generator/src/generate.ts b/packages/openapi-code-generator/src/generate.ts index 9cf1db502..331240891 100644 --- a/packages/openapi-code-generator/src/generate.ts +++ b/packages/openapi-code-generator/src/generate.ts @@ -30,6 +30,25 @@ function enumExtensibility( throw new Error(`Unsupported generator type '${generator.type}'`) } +function unionExtensibility( + config: Config, + generator: OpenapiGenerator, +): "open" | "closed" { + if (config.unionExtensibility) { + return config.unionExtensibility + } + + if (generator.type === "client") { + return "open" + } + + if (generator.type === "server") { + return "closed" + } + + throw new Error(`Unsupported generator type '${generator.type}'`) +} + export async function generate( config: Config, fsAdaptor: IFsAdaptor, @@ -63,6 +82,7 @@ export async function generate( { extractInlineSchemas: config.extractInlineSchemas, enumExtensibility: enumExtensibility(config, generator), + unionExtensibility: unionExtensibility(config, generator), }, generator.syntheticNameGenerator, ) diff --git a/packages/openapi-code-generator/src/test/input.test-utils.ts b/packages/openapi-code-generator/src/test/input.test-utils.ts index 5efddfb5a..ede14bf1a 100644 --- a/packages/openapi-code-generator/src/test/input.test-utils.ts +++ b/packages/openapi-code-generator/src/test/input.test-utils.ts @@ -54,6 +54,7 @@ export async function unitTestInput( const config = { extractInlineSchemas: true, enumExtensibility: "closed", + unionExtensibility: "closed", } satisfies InputConfig return { @@ -87,5 +88,6 @@ export async function createTestInputFromYamlString( return new Input(loader, { extractInlineSchemas: true, enumExtensibility: "closed", + unionExtensibility: "closed", }) } 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 651298e0b..54a3b5233 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 @@ -116,6 +116,7 @@ const extension = { nullable: false, default: undefined, discriminator: undefined, + "x-union-extensibility": undefined, "x-internal-preprocess": undefined, } satisfies IRModelUnion, null: { 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 2ff0451f1..4a6b5acc7 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 @@ -252,6 +252,7 @@ export abstract class AbstractSchemaBuilder< ([value, ref]) => [value, this.fromModel(ref, true)], ), ), + model["x-union-extensibility"], ) } else { result = this.union( @@ -370,6 +371,7 @@ export abstract class AbstractSchemaBuilder< protected abstract discriminatedUnion( propertyName: string, mapping: Record, + extensibility: "open" | "closed" | undefined, ): string protected abstract preprocess( 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 7ba8ed4dd..4b82e9600 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 @@ -160,6 +160,7 @@ export class JoiBuilder extends AbstractSchemaBuilder< protected discriminatedUnion( _propertyName: string, mapping: Record, + _extensibility: "open" | "closed" | undefined, ): string { return this.union(Object.values(mapping)) } 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 23c7e47c2..8d947bf23 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 @@ -168,9 +168,25 @@ export class ZodV3Builder extends AbstractSchemaBuilder< protected discriminatedUnion( propertyName: string, mapping: Record, + extensibility: "open" | "closed" | undefined, ): string { const schemas = Object.values(mapping) + if (extensibility === "open") { + schemas.push( + this.object({ + // todo: number discriminators + [propertyName]: this.string({ + type: "string", + enum: [], + "x-enum-extensibility": "open", + isIRModel: true, + nullable: false, + }), + }), + ) + } + return [ zod, `discriminatedUnion("${propertyName}", [\n${schemas @@ -340,10 +356,15 @@ export class ZodV3Builder extends AbstractSchemaBuilder< this.typeBuilder.filename, true, ) - return this.union([ - this.stringEnum(model), - "z.string().transform(it => it as (typeof it & UnknownEnumStringValue))", - ]) + + const unknownValue = + "z.string().transform(it => it as (typeof it & UnknownEnumStringValue))" + + if (model.enum.length === 0) { + return unknownValue + } + + return this.union([this.stringEnum(model), unknownValue]) } if (model["x-enum-extensibility"] === "closed") { diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.unit.spec.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.unit.spec.ts index e198c5cc8..2eaae4a7e 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.unit.spec.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.unit.spec.ts @@ -1146,6 +1146,45 @@ describe("typescript/common/schema-builders/zod-v3-schema-builder - unit tests", await expect(execute("some string")).resolves.toEqual("some string") }) + + it("can generate an open-ended discriminated union", async () => { + const {code} = await getActual( + ir.union({ + schemas: [ir.ref("/components/schemas/A")], + discriminator: { + propertyName: "kind", + mapping: { + a: ir.ref("/components/schemas/A"), + }, + }, + "x-union-extensibility": "open", + }), + { + schemas: { + "/components/schemas/A": ir.object({ + properties: { + kind: ir.string({enum: ["a"]}), + foo: ir.string(), + }, + required: ["kind", "foo"], + }), + }, + }, + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_A } from "./unit-test.schemas" + + const x = z.discriminatedUnion("kind", [ + s_A, + z.object({ + kind: z + .string() + .transform((it) => it as typeof it & UnknownEnumStringValue), + }), + ])" + `) + }) }) describe("intersections", () => { @@ -1378,11 +1417,17 @@ describe("typescript/common/schema-builders/zod-v3-schema-builder - unit tests", { config = {allowAny: false}, compilerOptions = {exactOptionalPropertyTypes: false}, + schemas = {}, }: { config?: SchemaBuilderConfig compilerOptions?: CompilerOptions + schemas?: Record } = {}, ) { + for (const [ref, model] of Object.entries(schemas)) { + schemaProvider.registerTestRef(ir.ref(ref), model) + } + return testHarness.getActual(schema, schemaProvider, { config, compilerOptions, 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 eed7f5f30..6a0eabdba 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 @@ -193,9 +193,25 @@ export class ZodV4Builder extends AbstractSchemaBuilder< protected discriminatedUnion( propertyName: string, mapping: Record, + extensibility: "open" | "closed" | undefined, ): string { const schemas = Object.values(mapping) + if (extensibility === "open") { + schemas.push( + this.object({ + // todo: number discriminators + [propertyName]: this.string({ + type: "string", + enum: [], + "x-enum-extensibility": "open", + isIRModel: true, + nullable: false, + }), + }), + ) + } + return [ zod, `discriminatedUnion("${propertyName}", [\n${schemas @@ -364,10 +380,14 @@ export class ZodV4Builder extends AbstractSchemaBuilder< this.typeBuilder.filename, true, ) - return this.union([ - this.stringEnum(model), - "z.string().transform(it => it as (typeof it & UnknownEnumStringValue))", - ]) + const unknownValue = + "z.string().transform(it => it as (typeof it & UnknownEnumStringValue))" + + if (model.enum.length === 0) { + return unknownValue + } + + return this.union([this.stringEnum(model), unknownValue]) } if (model["x-enum-extensibility"] === "closed") { 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 7c06054c0..0b0d5d7a6 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 @@ -1315,6 +1315,45 @@ describe("typescript/common/schema-builders/zod-v4-schema-builder - unit tests", const x = z.discriminatedUnion("kind", [s_A, s_B])" `) }) + + it("can generate an open-ended discriminated union", async () => { + const {code} = await getActual( + ir.union({ + schemas: [ir.ref("/components/schemas/A")], + discriminator: { + propertyName: "kind", + mapping: { + a: ir.ref("/components/schemas/A"), + }, + }, + "x-union-extensibility": "open", + }), + { + schemas: { + "/components/schemas/A": ir.object({ + properties: { + kind: ir.string({enum: ["a"]}), + foo: ir.string(), + }, + required: ["kind", "foo"], + }), + }, + }, + ) + + expect(code).toMatchInlineSnapshot(` + "import { s_A } from "./unit-test.schemas" + + const x = z.discriminatedUnion("kind", [ + s_A, + z.object({ + kind: z + .string() + .transform((it) => it as typeof it & UnknownEnumStringValue), + }), + ])" + `) + }) }) describe("intersections", () => { diff --git a/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts index e9380dd77..85247b810 100644 --- a/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.ts @@ -225,9 +225,29 @@ export class TypeBuilder implements ICompilable { } case "union": { + const types = schemaObject.schemas.flatMap(this.schemaObjectToTypes) + + if ( + schemaObject.discriminator && + schemaObject["x-union-extensibility"] === "open" + ) { + types.push( + intersect( + object([ + objectProperty({ + name: schemaObject.discriminator.propertyName, + type: this.addStaticType("UnknownEnumStringValue"), + isReadonly: false, + isRequired: true, + }), + ]), + ), + ) + } + result.push({ type: "type-union", - types: schemaObject.schemas.flatMap(this.schemaObjectToTypes), + types, }) break } diff --git a/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.unit.spec.ts b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.unit.spec.ts index 9111cab15..1b3ebfc49 100644 --- a/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.unit.spec.ts +++ b/packages/openapi-code-generator/src/typescript/common/type-builder/type-builder.unit.spec.ts @@ -582,6 +582,42 @@ describe("typescript/common/type-builder - unit tests", () => { )" `) }) + + it("can handle open-ended discriminated unions", async () => { + const {code} = await getActual( + ir.union({ + schemas: [ + ir.object({ + properties: { + kind: ir.string({enum: ["a"]}), + a: ir.number(), + }, + required: ["kind", "a"], + }), + ], + discriminator: { + propertyName: "kind", + mapping: { + a: ir.ref("/components/schemas/A"), + }, + }, + "x-union-extensibility": "open", + }), + ) + + expect(code).toMatchInlineSnapshot(` + "import type { UnknownEnumStringValue } from "./unit-test.types" + + declare const x: + | { + a: number + kind: "a" + } + | { + kind: UnknownEnumStringValue + }" + `) + }) }) it("throws if accidentally passed a 'null' type", async () => {