Skip to content

Commit 4267bd1

Browse files
committed
Add type support for enums with additionalProperties to provide ts intellisense
1 parent 8664cb6 commit 4267bd1

3 files changed

Lines changed: 126 additions & 94 deletions

File tree

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

Lines changed: 114 additions & 94 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
/**
@@ -717,6 +724,19 @@ function hasKey<K extends string>(possibleObject: unknown, key: K): possibleObje
717724
return typeof possibleObject === "object" && possibleObject !== null && key in possibleObject;
718725
}
719726

727+
function applyAdditionalPropertiesToEnum(
728+
hasAdditionalProperties: boolean,
729+
unionType: ts.TypeNode,
730+
schemaObject: SchemaObject,
731+
) {
732+
// If additionalProperties is true, add (string & {}) to the union
733+
if (hasAdditionalProperties && schemaObject.type === "string") {
734+
const stringAndEmptyObject = tsIntersection([STRING, ts.factory.createTypeLiteralNode([])]);
735+
return tsUnion([unionType, stringAndEmptyObject]);
736+
}
737+
return unionType;
738+
}
739+
720740
/** Wrap type with $Read or $Write marker when readWriteMarkers flag is enabled */
721741
function wrapWithReadWriteMarker(
722742
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)