Skip to content

Enum with Root Types Filtering Recursive Reference #2375

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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/mighty-zebras-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-typescript": patch
---

Fix behavior when using enum and export-type flags
38 changes: 38 additions & 0 deletions packages/openapi-typescript/examples/enum-root-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/

export type paths = Record<string, never>;
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** @enum {string} */
Status: Status;
/** @enum {number} */
Priority: Priority;
Item: {
name?: string;
status?: components["schemas"]["Status"];
priority?: components["schemas"]["Priority"];
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type Item = components['schemas']['Item'];
export type $defs = Record<string, never>;
export enum Status {
active = "active",
inactive = "inactive",
pending = "pending"
}
export enum Priority {
Value1 = 1,
Value2 = 2,
Value3 = 3
}
export type operations = Record<string, never>;
21 changes: 21 additions & 0 deletions packages/openapi-typescript/examples/enum-root-types.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
openapi: 3.0.0
info:
title: Enum Root Types Test
version: 1.0.0
components:
schemas:
Status:
type: string
enum: [active, inactive, pending]
Priority:
type: number
enum: [1, 2, 3]
Item:
type: object
properties:
name:
type: string
status:
$ref: '#/components/schemas/Status'
priority:
$ref: '#/components/schemas/Priority'
69 changes: 51 additions & 18 deletions packages/openapi-typescript/src/transform/components-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,34 @@ import transformRequestBodyObject from "./request-body-object.js";
import transformResponseObject from "./response-object.js";
import transformSchemaObject from "./schema-object.js";

/**
* Determines if a schema object represents an enum type to prevent duplicate exports
* when using --root-types and --enum flags together.
*
* When both flags are enabled:
* - --enum flag generates TypeScript enums at the bottom of the file
* - --root-types flag would normally also export these as root type aliases
* - This results in duplicate exports (both enum and type alias for the same schema)
*
* This function identifies enum schemas so they can be excluded from root type generation,
* allowing only the TypeScript enum to be generated.
*
* @param schema The schema object to check
* @returns true if the schema represents an enum type
*/
export function isEnumSchema(schema: unknown): boolean {
return (
typeof schema === "object" &&
schema !== null &&
!Array.isArray(schema) &&
"enum" in schema &&
Array.isArray((schema as any).enum) &&
(!("type" in schema) || (schema as any).type !== "object") &&
!("properties" in schema) &&
!("additionalProperties" in schema)
);
}

type ComponentTransforms = keyof Omit<ComponentsObject, "examples" | "securitySchemes" | "links" | "callbacks">;

const transformers: Record<ComponentTransforms, (node: any, options: TransformNodeOptions) => ts.TypeNode> = {
Expand Down Expand Up @@ -68,27 +96,32 @@ export default function transformComponentsObject(componentsObject: ComponentsOb
items.push(property);

if (ctx.rootTypes) {
const componentKey = changeCase.pascalCase(singularizeComponentKey(key));
let aliasName = `${componentKey}${changeCase.pascalCase(name)}`;
// Skip enum schemas when generating root types to prevent duplication (only when --enum flag is enabled)
const shouldSkipEnumSchema = ctx.enum && key === "schemas" && isEnumSchema(item);

// Add counter suffix (e.g. "_2") if conflict in name
let conflictCounter = 1;
if (!shouldSkipEnumSchema) {
const componentKey = changeCase.pascalCase(singularizeComponentKey(key));
let aliasName = `${componentKey}${changeCase.pascalCase(name)}`;

while (rootTypeAliases[aliasName] !== undefined) {
conflictCounter++;
aliasName = `${componentKey}${changeCase.pascalCase(name)}_${conflictCounter}`;
}
const ref = ts.factory.createTypeReferenceNode(`components['${key}']['${name}']`);
if (ctx.rootTypesNoSchemaPrefix && key === "schemas") {
aliasName = aliasName.replace(componentKey, "");
// Add counter suffix (e.g. "_2") if conflict in name
let conflictCounter = 1;

while (rootTypeAliases[aliasName] !== undefined) {
conflictCounter++;
aliasName = `${componentKey}${changeCase.pascalCase(name)}_${conflictCounter}`;
}
const ref = ts.factory.createTypeReferenceNode(`components['${key}']['${name}']`);
if (ctx.rootTypesNoSchemaPrefix && key === "schemas") {
aliasName = aliasName.replace(componentKey, "");
}
const typeAlias = ts.factory.createTypeAliasDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ aliasName,
/* typeParameters */ undefined,
/* type */ ref,
);
rootTypeAliases[aliasName] = typeAlias;
}
const typeAlias = ts.factory.createTypeAliasDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ aliasName,
/* typeParameters */ undefined,
/* type */ ref,
);
rootTypeAliases[aliasName] = typeAlias;
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions packages/openapi-typescript/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ describe("CLI", () => {
ci: { timeout: TIMEOUT },
},
],
[
"snapshot > enum root types filtering",
{
given: ["./examples/enum-root-types.yaml", "--root-types", "--root-types-no-schema-prefix", "--enum"],
want: new URL("./examples/enum-root-types.ts", root),
ci: { timeout: TIMEOUT },
},
],
];

for (const [testName, { given, want, ci }] of tests) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { fileURLToPath } from "node:url";
import ts from "typescript";
import { NULL, astToString } from "../../src/lib/ts.js";
import transformComponentsObject from "../../src/transform/components-object.js";
import transformComponentsObject, { isEnumSchema } from "../../src/transform/components-object.js";
import type { GlobalContext } from "../../src/types.js";
import { DEFAULT_CTX, type TestCase } from "../test-helpers.js";

Expand Down Expand Up @@ -871,3 +871,69 @@ export type Error = components['schemas']['Error'];
);
}
});

describe("isEnumSchema", () => {
test("returns true for string enum schema", () => {
const schema = {
type: "string",
enum: ["active", "inactive", "pending"],
};
expect(isEnumSchema(schema)).toBe(true);
});

test("returns true for number enum schema", () => {
const schema = {
type: "number",
enum: [1, 2, 3],
};
expect(isEnumSchema(schema)).toBe(true);
});

test("returns true for mixed enum schema without explicit type", () => {
const schema = {
enum: ["high", 0, null],
};
expect(isEnumSchema(schema)).toBe(true);
});

test("returns false for object schema with properties", () => {
const schema = {
type: "object",
properties: {
name: { type: "string" },
},
};
expect(isEnumSchema(schema)).toBe(false);
});

test("returns false for object schema with enum (object enums not supported)", () => {
const schema = {
type: "object",
enum: [{ value: "test" }],
};
expect(isEnumSchema(schema)).toBe(false);
});

test("returns false for schema with additionalProperties", () => {
const schema = {
enum: ["test"],
additionalProperties: true,
};
expect(isEnumSchema(schema)).toBe(false);
});

test("returns false for schema without enum", () => {
const schema = {
type: "string",
};
expect(isEnumSchema(schema)).toBe(false);
});

test("returns false for null, undefined, or non-object inputs", () => {
expect(isEnumSchema(null)).toBe(false);
expect(isEnumSchema(undefined)).toBe(false);
expect(isEnumSchema("string")).toBe(false);
expect(isEnumSchema(123)).toBe(false);
expect(isEnumSchema([])).toBe(false);
});
});
2 changes: 1 addition & 1 deletion packages/openapi-typescript/tsconfig.examples.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["examples"],
"exclude": ["examples/digital-ocean-api.ts"]
"exclude": ["examples/digital-ocean-api.ts", "examples/enum-root-types.ts"]
}
Loading