From baeb4967eb88566edfed5b583e39424dbce55660 Mon Sep 17 00:00:00 2001 From: remy90 Date: Fri, 15 Aug 2025 14:43:13 +0100 Subject: [PATCH 1/2] Add type support for enums with additionalProperties to provide ts intellisense --- .../src/transform/schema-object.ts | 134 ++++++++++-------- packages/openapi-typescript/src/types.ts | 1 + .../transform/schema-object/string.test.ts | 11 ++ 3 files changed, 89 insertions(+), 57 deletions(-) diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 9c5725126..b960c71b2 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -92,72 +92,79 @@ export function transformSchemaObjectWithComposition( if ( Array.isArray(schemaObject.enum) && (!("type" in schemaObject) || schemaObject.type !== "object") && - !("properties" in schemaObject) && - !("additionalProperties" in schemaObject) + !("properties" in schemaObject) ) { - // hoist enum to top level if string/number enum and option is enabled - if ( - options.ctx.enum && - schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number" || v === null) - ) { - let enumName = parseRef(options.path ?? "").pointer.join("/"); - // allow #/components/schemas to have simpler names - enumName = enumName.replace("components/schemas", ""); - const metadata = schemaObject.enum.map((_, i) => ({ - name: schemaObject["x-enum-varnames"]?.[i] ?? schemaObject["x-enumNames"]?.[i], - description: schemaObject["x-enum-descriptions"]?.[i] ?? schemaObject["x-enumDescriptions"]?.[i], - })); - - // enums can contain null values, but dont want to output them - let hasNull = false; - const validSchemaEnums = schemaObject.enum.filter((enumValue) => { - if (enumValue === null) { - hasNull = true; - return false; + const hasAdditionalProperties = "additionalProperties" in schemaObject && !!schemaObject.additionalProperties; + + if (!hasAdditionalProperties || (schemaObject.type === "string" && hasAdditionalProperties)) { + // hoist enum to top level if string/number enum and option is enabled + if ( + options.ctx.enum && + schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number" || v === null) + ) { + let enumName = parseRef(options.path ?? "").pointer.join("/"); + // allow #/components/schemas to have simpler names + enumName = enumName.replace("components/schemas", ""); + const metadata = schemaObject.enum.map((_, i) => ({ + name: schemaObject["x-enum-varnames"]?.[i] ?? schemaObject["x-enumNames"]?.[i], + description: schemaObject["x-enum-descriptions"]?.[i] ?? schemaObject["x-enumDescriptions"]?.[i], + })); + + // enums can contain null values, but dont want to output them + let hasNull = false; + const validSchemaEnums = schemaObject.enum.filter((enumValue) => { + if (enumValue === null) { + hasNull = true; + return false; + } + + return true; + }); + const enumType = tsEnum(enumName, validSchemaEnums as (string | number)[], metadata, { + shouldCache: options.ctx.dedupeEnums, + export: true, + // readonly: TS enum do not support the readonly modifier + }); + if (!options.ctx.injectFooter.includes(enumType)) { + options.ctx.injectFooter.push(enumType); } + const ref = ts.factory.createTypeReferenceNode(enumType.name); + + const finalType: ts.TypeNode = hasNull ? tsUnion([ref, NULL]) : ref; - return true; - }); - const enumType = tsEnum(enumName, validSchemaEnums as (string | number)[], metadata, { - shouldCache: options.ctx.dedupeEnums, - export: true, - // readonly: TS enum do not support the readonly modifier - }); - if (!options.ctx.injectFooter.includes(enumType)) { - options.ctx.injectFooter.push(enumType); + return applyAdditionalPropertiesToEnum(hasAdditionalProperties, finalType, schemaObject); } - const ref = ts.factory.createTypeReferenceNode(enumType.name); - return hasNull ? tsUnion([ref, NULL]) : ref; - } - const enumType = schemaObject.enum.map(tsLiteral); - if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) { - enumType.push(NULL); - } - const unionType = tsUnion(enumType); + const enumType = schemaObject.enum.map(tsLiteral); + if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) { + enumType.push(NULL); + } - // hoist array with valid enum values to top level if string/number enum and option is enabled - if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) { - let enumValuesVariableName = parseRef(options.path ?? "").pointer.join("/"); - // allow #/components/schemas to have simpler names - enumValuesVariableName = enumValuesVariableName.replace("components/schemas", ""); - enumValuesVariableName = `${enumValuesVariableName}Values`; + const unionType = applyAdditionalPropertiesToEnum(hasAdditionalProperties, tsUnion(enumType), schemaObject); + + // hoist array with valid enum values to top level if string/number enum and option is enabled + if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) { + let enumValuesVariableName = parseRef(options.path ?? "").pointer.join("/"); + // allow #/components/schemas to have simpler names + enumValuesVariableName = enumValuesVariableName.replace("components/schemas", ""); + enumValuesVariableName = `${enumValuesVariableName}Values`; + + const enumValuesArray = tsArrayLiteralExpression( + enumValuesVariableName, + oapiRef(options.path ?? ""), + schemaObject.enum as (string | number)[], + { + export: true, + readonly: true, + injectFooter: options.ctx.injectFooter, + }, + ); - const enumValuesArray = tsArrayLiteralExpression( - enumValuesVariableName, - oapiRef(options.path ?? ""), - schemaObject.enum as (string | number)[], - { - export: true, - readonly: true, - injectFooter: options.ctx.injectFooter, - }, - ); + options.ctx.injectFooter.push(enumValuesArray); + } - options.ctx.injectFooter.push(enumValuesArray); + return unionType; } - - return unionType; } /** @@ -584,3 +591,16 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor function hasKey(possibleObject: unknown, key: K): possibleObject is { [key in K]: unknown } { return typeof possibleObject === "object" && possibleObject !== null && key in possibleObject; } + +function applyAdditionalPropertiesToEnum( + hasAdditionalProperties: boolean, + unionType: ts.TypeNode, + schemaObject: SchemaObject, +) { + // If additionalProperties is true, add (string & {}) to the union + if (hasAdditionalProperties && schemaObject.type === "string") { + const stringAndEmptyObject = tsIntersection([STRING, ts.factory.createTypeLiteralNode([])]); + return tsUnion([unionType, stringAndEmptyObject]); + } + return unionType; +} diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index 75d8f8c07..a1c55beeb 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -436,6 +436,7 @@ export type SchemaObject = { const?: unknown; default?: unknown; format?: string; + additionalProperties?: boolean | Record | SchemaObject | ReferenceObject; /** @deprecated in 3.1 (still valid for 3.0) */ nullable?: boolean; oneOf?: (SchemaObject | ReferenceObject)[]; diff --git a/packages/openapi-typescript/test/transform/schema-object/string.test.ts b/packages/openapi-typescript/test/transform/schema-object/string.test.ts index 3ff497833..4e1c87257 100644 --- a/packages/openapi-typescript/test/transform/schema-object/string.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/string.test.ts @@ -119,6 +119,17 @@ describe("transformSchemaObject > string", () => { want: "string | null", }, ], + [ + "enum + additionalProperties", + { + given: { + type: "string", + enum: ["A", "B", "C"], + additionalProperties: true, + }, + want: `("A" | "B" | "C") | (string & {})`, + }, + ], ]; for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) { From 8426fa2f4066e593ee7bff680ed062d6dbdaad23 Mon Sep 17 00:00:00 2001 From: remy90 Date: Fri, 15 Aug 2025 15:20:49 +0100 Subject: [PATCH 2/2] satisfy linter --- packages/openapi-typescript/src/transform/schema-object.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index b960c71b2..d821dfea0 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -453,7 +453,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor ("$defs" in schemaObject && schemaObject.$defs) ) { // properties - if (Object.keys(schemaObject.properties ?? {}).length) { + if ("properties" in schemaObject && schemaObject.properties && Object.keys(schemaObject?.properties).length) { for (const [k, v] of getEntries(schemaObject.properties ?? {}, options.ctx)) { if ((typeof v !== "object" && typeof v !== "boolean") || Array.isArray(v)) { throw new Error( @@ -522,7 +522,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor } // $defs - if (schemaObject.$defs && typeof schemaObject.$defs === "object" && Object.keys(schemaObject.$defs).length) { + if ("$defs" in schemaObject && typeof schemaObject.$defs === "object" && Object.keys(schemaObject.$defs).length) { const defKeys: ts.TypeElement[] = []; for (const [k, v] of Object.entries(schemaObject.$defs)) { const property = ts.factory.createPropertySignature(