Skip to content

Commit f55fb72

Browse files
authored
feat(openAPI-union) Add type support for unions with additionalProperties to provide ts intellisense (#2415)
* Add type support for enums with additionalProperties to provide ts intellisense * satisfy linter * CR: add loose-enum-autocomplete.md to changeset * format
1 parent e605eaf commit f55fb72

4 files changed

Lines changed: 140 additions & 99 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"openapi-typescript": patch
3+
---
4+
5+
Support `additionalProperties: true` on string enums by generating a loose autocomplete union (`(enum literals) | (string & {})`), preserving editor suggestions while still accepting arbitrary string values.

packages/openapi-typescript/src/transform/schema-object.ts

Lines changed: 123 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -94,112 +94,119 @@ export function transformSchemaObjectWithComposition(
9494
if (
9595
Array.isArray(schemaObject.enum) &&
9696
(!("type" in schemaObject) || schemaObject.type !== "object") &&
97-
!("properties" in schemaObject) &&
98-
!("additionalProperties" in schemaObject)
97+
!("properties" in schemaObject)
9998
) {
100-
// hoist enum to top level if string/number enum and option is enabled
101-
if (shouldTransformToTsEnum(options, schemaObject)) {
102-
let enumName = parseRef(options.path ?? "").pointer.join("/");
103-
// allow #/components/schemas to have simpler names
104-
enumName = enumName.replace("components/schemas", "");
105-
const metadata = schemaObject.enum.map((_, i) => ({
106-
name: schemaObject["x-enum-varnames"]?.[i] ?? schemaObject["x-enumNames"]?.[i],
107-
description: schemaObject["x-enum-descriptions"]?.[i] ?? schemaObject["x-enumDescriptions"]?.[i],
108-
}));
109-
110-
// enums can contain null values, but dont want to output them
111-
let hasNull = false;
112-
const validSchemaEnums = schemaObject.enum.filter((enumValue) => {
113-
if (enumValue === null) {
114-
hasNull = true;
115-
return false;
99+
const hasAdditionalProperties = "additionalProperties" in schemaObject && !!schemaObject.additionalProperties;
100+
101+
if (!hasAdditionalProperties || (schemaObject.type === "string" && hasAdditionalProperties)) {
102+
// hoist enum to top level if string/number enum and option is enabled
103+
if (shouldTransformToTsEnum(options, schemaObject)) {
104+
let enumName = parseRef(options.path ?? "").pointer.join("/");
105+
// allow #/components/schemas to have simpler names
106+
enumName = enumName.replace("components/schemas", "");
107+
const metadata = schemaObject.enum.map((_, i) => ({
108+
name: schemaObject["x-enum-varnames"]?.[i] ?? schemaObject["x-enumNames"]?.[i],
109+
description: schemaObject["x-enum-descriptions"]?.[i] ?? schemaObject["x-enumDescriptions"]?.[i],
110+
}));
111+
112+
// enums can contain null values, but dont want to output them
113+
let hasNull = false;
114+
const validSchemaEnums = schemaObject.enum.filter((enumValue) => {
115+
if (enumValue === null) {
116+
hasNull = true;
117+
return false;
118+
}
119+
120+
return true;
121+
});
122+
const enumType = tsEnum(enumName, validSchemaEnums as (string | number)[], metadata, {
123+
shouldCache: options.ctx.dedupeEnums,
124+
export: true,
125+
// readonly: TS enum do not support the readonly modifier
126+
});
127+
if (!options.ctx.injectFooter.includes(enumType)) {
128+
options.ctx.injectFooter.push(enumType);
116129
}
130+
const ref = ts.factory.createTypeReferenceNode(enumType.name);
117131

118-
return true;
119-
});
120-
const enumType = tsEnum(enumName, validSchemaEnums as (string | number)[], metadata, {
121-
shouldCache: options.ctx.dedupeEnums,
122-
export: true,
123-
// readonly: TS enum do not support the readonly modifier
124-
});
125-
if (!options.ctx.injectFooter.includes(enumType)) {
126-
options.ctx.injectFooter.push(enumType);
132+
const finalType: ts.TypeNode = hasNull ? tsUnion([ref, NULL]) : ref;
133+
134+
return applyAdditionalPropertiesToEnum(hasAdditionalProperties, finalType, schemaObject);
135+
}
136+
137+
const enumType = schemaObject.enum.map(tsLiteral);
138+
if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) {
139+
enumType.push(NULL);
127140
}
128-
const ref = ts.factory.createTypeReferenceNode(enumType.name);
129-
return hasNull ? tsUnion([ref, NULL]) : ref;
130-
}
131-
const enumType = schemaObject.enum.map(tsLiteral);
132-
if ((Array.isArray(schemaObject.type) && schemaObject.type.includes("null")) || schemaObject.nullable) {
133-
enumType.push(NULL);
134-
}
135141

136-
const unionType = tsUnion(enumType);
137-
138-
// hoist array with valid enum values to top level if string/number enum and option is enabled
139-
if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) {
140-
const parsed = parseRef(options.path ?? "");
141-
let enumValuesVariableName = parsed.pointer.join("/");
142-
// allow #/components/schemas to have simpler names
143-
enumValuesVariableName = enumValuesVariableName.replace("components/schemas", "");
144-
enumValuesVariableName = `${enumValuesVariableName}Values`;
145-
146-
// build a ref path for the type that ignores union indices (anyOf/oneOf) so
147-
// type references remain stable even when names include union positions
148-
const cleanedPointer: string[] = [];
149-
// Track ALL properties after a oneOf/anyOf that need Extract<> narrowing.
150-
// We apply Extract<> before EVERY property access after a union index because:
151-
// - When the property exists on ALL variants, Extract<> is a no-op (returns same type)
152-
// - When the property only exists on SOME variants, it correctly narrows the union
153-
// - When both variants have same property name but different inner schemas,
154-
// we still narrow at each level to handle nested unions correctly
155-
// This robust approach handles both simple and complex union structures.
156-
const extractProperties: string[] = [];
157-
for (let i = 0; i < parsed.pointer.length; i++) {
158-
// Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message
159-
const segment = parsed.pointer[i];
160-
if ((segment === "anyOf" || segment === "oneOf") && i < parsed.pointer.length - 1) {
161-
const next = parsed.pointer[i + 1];
162-
if (/^\d+$/.test(next)) {
163-
// If we encounter something like "anyOf/0", we want to skip that part of the path
164-
i++;
165-
// Collect ALL remaining segments after the union index.
166-
// Each one will be wrapped with Extract<> to safely narrow the type
167-
// at each level, handling both top-level and nested union variants.
168-
const remainingSegments = parsed.pointer.slice(i + 1);
169-
for (const seg of remainingSegments) {
170-
// Skip union keywords and indices, only add actual property names
171-
if (seg !== "anyOf" && seg !== "oneOf" && !/^\d+$/.test(seg)) {
172-
extractProperties.push(seg);
142+
const unionType = applyAdditionalPropertiesToEnum(hasAdditionalProperties, tsUnion(enumType), schemaObject);
143+
144+
// hoist array with valid enum values to top level if string/number enum and option is enabled
145+
if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) {
146+
const parsed = parseRef(options.path ?? "");
147+
let enumValuesVariableName = parsed.pointer.join("/");
148+
// allow #/components/schemas to have simpler names
149+
enumValuesVariableName = enumValuesVariableName.replace("components/schemas", "");
150+
enumValuesVariableName = `${enumValuesVariableName}Values`;
151+
152+
// build a ref path for the type that ignores union indices (anyOf/oneOf) so
153+
// type references remain stable even when names include union positions
154+
const cleanedPointer: string[] = [];
155+
// Track ALL properties after a oneOf/anyOf that need Extract<> narrowing.
156+
// We apply Extract<> before EVERY property access after a union index because:
157+
// - When the property exists on ALL variants, Extract<> is a no-op (returns same type)
158+
// - When the property only exists on SOME variants, it correctly narrows the union
159+
// - When both variants have same property name but different inner schemas,
160+
// we still narrow at each level to handle nested unions correctly
161+
// This robust approach handles both simple and complex union structures.
162+
const extractProperties: string[] = [];
163+
for (let i = 0; i < parsed.pointer.length; i++) {
164+
// Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message
165+
const segment = parsed.pointer[i];
166+
if ((segment === "anyOf" || segment === "oneOf") && i < parsed.pointer.length - 1) {
167+
const next = parsed.pointer[i + 1];
168+
if (/^\d+$/.test(next)) {
169+
// If we encounter something like "anyOf/0", we want to skip that part of the path
170+
i++;
171+
// Collect ALL remaining segments after the union index.
172+
// Each one will be wrapped with Extract<> to safely narrow the type
173+
// at each level, handling both top-level and nested union variants.
174+
const remainingSegments = parsed.pointer.slice(i + 1);
175+
for (const seg of remainingSegments) {
176+
// Skip union keywords and indices, only add actual property names
177+
if (seg !== "anyOf" && seg !== "oneOf" && !/^\d+$/.test(seg)) {
178+
extractProperties.push(seg);
179+
}
173180
}
181+
continue;
174182
}
175-
continue;
176183
}
184+
cleanedPointer.push(segment);
177185
}
178-
cleanedPointer.push(segment);
186+
const cleanedRefPath = createRef(cleanedPointer);
187+
188+
const enumValuesArray = tsArrayLiteralExpression(
189+
enumValuesVariableName,
190+
// If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type
191+
fromAdditionalProperties
192+
? ts.factory.createIndexedAccessTypeNode(
193+
oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }),
194+
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")),
195+
)
196+
: oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }),
197+
schemaObject.enum as (string | number)[],
198+
{
199+
export: true,
200+
readonly: true,
201+
injectFooter: options.ctx.injectFooter,
202+
},
203+
);
204+
205+
options.ctx.injectFooter.push(enumValuesArray);
179206
}
180-
const cleanedRefPath = createRef(cleanedPointer);
181-
182-
const enumValuesArray = tsArrayLiteralExpression(
183-
enumValuesVariableName,
184-
// If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type
185-
fromAdditionalProperties
186-
? ts.factory.createIndexedAccessTypeNode(
187-
oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }),
188-
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")),
189-
)
190-
: oapiRef(cleanedRefPath, undefined, { deep: true, extractProperties }),
191-
schemaObject.enum as (string | number)[],
192-
{
193-
export: true,
194-
readonly: true,
195-
injectFooter: options.ctx.injectFooter,
196-
},
197-
);
198207

199-
options.ctx.injectFooter.push(enumValuesArray);
208+
return unionType;
200209
}
201-
202-
return unionType;
203210
}
204211

205212
/**
@@ -525,7 +532,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
525532
("$defs" in schemaObject && schemaObject.$defs)
526533
) {
527534
// properties
528-
if (Object.keys(schemaObject.properties ?? {}).length) {
535+
if ("properties" in schemaObject && schemaObject.properties && Object.keys(schemaObject?.properties).length) {
529536
for (const [k, v] of getEntries(schemaObject.properties ?? {}, options.ctx)) {
530537
if ((typeof v !== "object" && typeof v !== "boolean") || Array.isArray(v)) {
531538
throw new Error(
@@ -609,7 +616,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
609616
}
610617

611618
// $defs
612-
if (schemaObject.$defs && typeof schemaObject.$defs === "object" && Object.keys(schemaObject.$defs).length) {
619+
if ("$defs" in schemaObject && typeof schemaObject.$defs === "object" && Object.keys(schemaObject.$defs).length) {
613620
const defKeys: ts.TypeElement[] = [];
614621
for (const [k, v] of Object.entries(schemaObject.$defs)) {
615622
const defReadOnly = "readOnly" in v && !!v.readOnly;
@@ -661,17 +668,21 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
661668
schemaObject.additionalProperties === true ||
662669
(typeof schemaObject.additionalProperties === "object" &&
663670
Object.keys(schemaObject.additionalProperties).length === 0);
671+
const patternProperties = hasKey(schemaObject, "patternProperties") ? schemaObject.patternProperties : undefined;
664672
const hasExplicitPatternProperties =
665-
typeof schemaObject.patternProperties === "object" && Object.keys(schemaObject.patternProperties).length;
673+
typeof patternProperties === "object" && patternProperties !== null && Object.keys(patternProperties).length > 0;
666674
const stringIndexTypes = [];
667675
if (hasExplicitAdditionalProperties) {
668676
stringIndexTypes.push(transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options, true));
669677
}
670678
if (hasImplicitAdditionalProperties || (!schemaObject.additionalProperties && options.ctx.additionalProperties)) {
671679
stringIndexTypes.push(UNKNOWN);
672680
}
673-
if (hasExplicitPatternProperties) {
674-
for (const [_, v] of getEntries(schemaObject.patternProperties ?? {}, options.ctx)) {
681+
if (hasExplicitPatternProperties && patternProperties && typeof patternProperties === "object") {
682+
for (const [_, v] of getEntries(
683+
patternProperties as Record<string, SchemaObject | ReferenceObject>,
684+
options.ctx,
685+
)) {
675686
stringIndexTypes.push(transformSchemaObject(v, options));
676687
}
677688
}
@@ -717,6 +728,19 @@ function hasKey<K extends string>(possibleObject: unknown, key: K): possibleObje
717728
return typeof possibleObject === "object" && possibleObject !== null && key in possibleObject;
718729
}
719730

731+
function applyAdditionalPropertiesToEnum(
732+
hasAdditionalProperties: boolean,
733+
unionType: ts.TypeNode,
734+
schemaObject: SchemaObject,
735+
) {
736+
// If additionalProperties is true, add (string & {}) to the union
737+
if (hasAdditionalProperties && schemaObject.type === "string") {
738+
const stringAndEmptyObject = tsIntersection([STRING, ts.factory.createTypeLiteralNode([])]);
739+
return tsUnion([unionType, stringAndEmptyObject]);
740+
}
741+
return unionType;
742+
}
743+
720744
/** Wrap type with $Read or $Write marker when readWriteMarkers flag is enabled */
721745
function wrapWithReadWriteMarker(
722746
type: ts.TypeNode,

packages/openapi-typescript/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ export type SchemaObject = {
436436
const?: unknown;
437437
default?: unknown;
438438
format?: string;
439+
additionalProperties?: boolean | Record<string, never> | SchemaObject | ReferenceObject;
439440
/** @deprecated in 3.1 (still valid for 3.0) */
440441
nullable?: boolean;
441442
oneOf?: (SchemaObject | ReferenceObject)[];

packages/openapi-typescript/test/transform/schema-object/string.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,17 @@ describe("transformSchemaObject > string", () => {
119119
want: "string | null",
120120
},
121121
],
122+
[
123+
"enum + additionalProperties",
124+
{
125+
given: {
126+
type: "string",
127+
enum: ["A", "B", "C"],
128+
additionalProperties: true,
129+
},
130+
want: `("A" | "B" | "C") | (string & {})`,
131+
},
132+
],
122133
];
123134

124135
for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) {

0 commit comments

Comments
 (0)