From 4267bd14daf42729c7b9ea553b201662e23c3e7f Mon Sep 17 00:00:00 2001 From: remy90 Date: Fri, 15 Aug 2025 14:43:13 +0100 Subject: [PATCH 1/4] Add type support for enums with additionalProperties to provide ts intellisense --- .../src/transform/schema-object.ts | 208 ++++++++++-------- packages/openapi-typescript/src/types.ts | 1 + .../transform/schema-object/string.test.ts | 11 + 3 files changed, 126 insertions(+), 94 deletions(-) diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 58745d072..26a60918a 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -94,112 +94,119 @@ 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 (shouldTransformToTsEnum(options, schemaObject)) { - 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 (shouldTransformToTsEnum(options, schemaObject)) { + 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); - 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 finalType: ts.TypeNode = hasNull ? tsUnion([ref, NULL]) : ref; + + return applyAdditionalPropertiesToEnum(hasAdditionalProperties, finalType, schemaObject); + } + + const enumType = schemaObject.enum.map(tsLiteral); + if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) { + enumType.push(NULL); } - 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); - - // 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")) { - const parsed = parseRef(options.path ?? ""); - let enumValuesVariableName = parsed.pointer.join("/"); - // allow #/components/schemas to have simpler names - enumValuesVariableName = enumValuesVariableName.replace("components/schemas", ""); - enumValuesVariableName = `${enumValuesVariableName}Values`; - - // build a ref path for the type that ignores union indices (anyOf/oneOf) so - // type references remain stable even when names include union positions - const cleanedPointer: string[] = []; - // Track ALL properties after a oneOf/anyOf that need Extract<> narrowing. - // We apply Extract<> before EVERY property access after a union index because: - // - When the property exists on ALL variants, Extract<> is a no-op (returns same type) - // - When the property only exists on SOME variants, it correctly narrows the union - // - When both variants have same property name but different inner schemas, - // we still narrow at each level to handle nested unions correctly - // This robust approach handles both simple and complex union structures. - const extractProperties: string[] = []; - for (let i = 0; i < parsed.pointer.length; i++) { - // Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message - const segment = parsed.pointer[i]; - if ((segment === "anyOf" || segment === "oneOf") && i < parsed.pointer.length - 1) { - const next = parsed.pointer[i + 1]; - if (/^\d+$/.test(next)) { - // If we encounter something like "anyOf/0", we want to skip that part of the path - i++; - // Collect ALL remaining segments after the union index. - // Each one will be wrapped with Extract<> to safely narrow the type - // at each level, handling both top-level and nested union variants. - const remainingSegments = parsed.pointer.slice(i + 1); - for (const seg of remainingSegments) { - // Skip union keywords and indices, only add actual property names - if (seg !== "anyOf" && seg !== "oneOf" && !/^\d+$/.test(seg)) { - extractProperties.push(seg); + 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")) { + const parsed = parseRef(options.path ?? ""); + let enumValuesVariableName = parsed.pointer.join("/"); + // allow #/components/schemas to have simpler names + enumValuesVariableName = enumValuesVariableName.replace("components/schemas", ""); + enumValuesVariableName = `${enumValuesVariableName}Values`; + + // build a ref path for the type that ignores union indices (anyOf/oneOf) so + // type references remain stable even when names include union positions + const cleanedPointer: string[] = []; + // Track ALL properties after a oneOf/anyOf that need Extract<> narrowing. + // We apply Extract<> before EVERY property access after a union index because: + // - When the property exists on ALL variants, Extract<> is a no-op (returns same type) + // - When the property only exists on SOME variants, it correctly narrows the union + // - When both variants have same property name but different inner schemas, + // we still narrow at each level to handle nested unions correctly + // This robust approach handles both simple and complex union structures. + const extractProperties: string[] = []; + for (let i = 0; i < parsed.pointer.length; i++) { + // Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message + const segment = parsed.pointer[i]; + if ((segment === "anyOf" || segment === "oneOf") && i < parsed.pointer.length - 1) { + const next = parsed.pointer[i + 1]; + if (/^\d+$/.test(next)) { + // If we encounter something like "anyOf/0", we want to skip that part of the path + i++; + // Collect ALL remaining segments after the union index. + // Each one will be wrapped with Extract<> to safely narrow the type + // at each level, handling both top-level and nested union variants. + const remainingSegments = parsed.pointer.slice(i + 1); + for (const seg of remainingSegments) { + // Skip union keywords and indices, only add actual property names + if (seg !== "anyOf" && seg !== "oneOf" && !/^\d+$/.test(seg)) { + extractProperties.push(seg); + } } + continue; } - continue; } + cleanedPointer.push(segment); } - cleanedPointer.push(segment); + const cleanedRefPath = createRef(cleanedPointer); + + const enumValuesArray = tsArrayLiteralExpression( + enumValuesVariableName, + // If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type + fromAdditionalProperties + ? ts.factory.createIndexedAccessTypeNode( + oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }), + ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")), + ) + : oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }), + schemaObject.enum as (string | number)[], + { + export: true, + readonly: true, + injectFooter: options.ctx.injectFooter, + }, + ); + + options.ctx.injectFooter.push(enumValuesArray); } - const cleanedRefPath = createRef(cleanedPointer); - - const enumValuesArray = tsArrayLiteralExpression( - enumValuesVariableName, - // If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type - fromAdditionalProperties - ? ts.factory.createIndexedAccessTypeNode( - oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }), - ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")), - ) - : oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }), - schemaObject.enum as (string | number)[], - { - export: true, - readonly: true, - injectFooter: options.ctx.injectFooter, - }, - ); - options.ctx.injectFooter.push(enumValuesArray); + return unionType; } - - return unionType; } /** @@ -717,6 +724,19 @@ function hasKey(possibleObject: unknown, key: K): possibleObje 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; +} + /** Wrap type with $Read or $Write marker when readWriteMarkers flag is enabled */ function wrapWithReadWriteMarker( type: ts.TypeNode, diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index 8a511e1e8..d19185cc6 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 1c3224e181a7d859d6ca4966a3331c6e11e9983b Mon Sep 17 00:00:00 2001 From: remy90 Date: Fri, 15 Aug 2025 15:20:49 +0100 Subject: [PATCH 2/4] 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 26a60918a..12d5380a6 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -532,7 +532,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( @@ -616,7 +616,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 defReadOnly = "readOnly" in v && !!v.readOnly; From 101da06c7d4865bf0038cad622513d3b9b68d4b1 Mon Sep 17 00:00:00 2001 From: Shaun Date: Thu, 12 Feb 2026 12:21:48 +0000 Subject: [PATCH 3/4] CR: add loose-enum-autocomplete.md to changeset --- .changeset/loose-enum-autocomplete.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/loose-enum-autocomplete.md diff --git a/.changeset/loose-enum-autocomplete.md b/.changeset/loose-enum-autocomplete.md new file mode 100644 index 000000000..184697c57 --- /dev/null +++ b/.changeset/loose-enum-autocomplete.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": patch +--- + +Support `additionalProperties: true` on string enums by generating a loose autocomplete union (`(enum literals) | (string & {})`), preserving editor suggestions while still accepting arbitrary string values. From 404db56034d9fa96f39d78f6b8e8abc67fa4bcdd Mon Sep 17 00:00:00 2001 From: Shaun Date: Thu, 12 Feb 2026 12:40:44 +0000 Subject: [PATCH 4/4] format --- .../openapi-typescript/src/transform/schema-object.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 12d5380a6..caab5e10f 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -668,8 +668,9 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor schemaObject.additionalProperties === true || (typeof schemaObject.additionalProperties === "object" && Object.keys(schemaObject.additionalProperties).length === 0); + const patternProperties = hasKey(schemaObject, "patternProperties") ? schemaObject.patternProperties : undefined; const hasExplicitPatternProperties = - typeof schemaObject.patternProperties === "object" && Object.keys(schemaObject.patternProperties).length; + typeof patternProperties === "object" && patternProperties !== null && Object.keys(patternProperties).length > 0; const stringIndexTypes = []; if (hasExplicitAdditionalProperties) { stringIndexTypes.push(transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options, true)); @@ -677,8 +678,11 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor if (hasImplicitAdditionalProperties || (!schemaObject.additionalProperties && options.ctx.additionalProperties)) { stringIndexTypes.push(UNKNOWN); } - if (hasExplicitPatternProperties) { - for (const [_, v] of getEntries(schemaObject.patternProperties ?? {}, options.ctx)) { + if (hasExplicitPatternProperties && patternProperties && typeof patternProperties === "object") { + for (const [_, v] of getEntries( + patternProperties as Record, + options.ctx, + )) { stringIndexTypes.push(transformSchemaObject(v, options)); } }