Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/loose-enum-autocomplete.md
Original file line number Diff line number Diff line change
@@ -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.
222 changes: 123 additions & 99 deletions packages/openapi-typescript/src/transform/schema-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -525,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(
Expand Down Expand Up @@ -609,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;
Expand Down Expand Up @@ -661,17 +668,21 @@ 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));
}
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<string, SchemaObject | ReferenceObject>,
options.ctx,
)) {
stringIndexTypes.push(transformSchemaObject(v, options));
}
}
Expand Down Expand Up @@ -717,6 +728,19 @@ function hasKey<K extends string>(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,
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ export type SchemaObject = {
const?: unknown;
default?: unknown;
format?: string;
additionalProperties?: boolean | Record<string, never> | SchemaObject | ReferenceObject;
/** @deprecated in 3.1 (still valid for 3.0) */
nullable?: boolean;
oneOf?: (SchemaObject | ReferenceObject)[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down