Skip to content

Commit 3fabebe

Browse files
committed
Enum with Root Types Filtering Recursive Reference
1 parent 3f76da4 commit 3fabebe

File tree

7 files changed

+191
-20
lines changed

7 files changed

+191
-20
lines changed

.changeset/mighty-zebras-move.md

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+
Fix behavior when using enum and export-type flags
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* This file was auto-generated by openapi-typescript.
3+
* Do not make direct changes to the file.
4+
*/
5+
6+
export type paths = Record<string, never>;
7+
export type webhooks = Record<string, never>;
8+
export interface components {
9+
schemas: {
10+
/** @enum {string} */
11+
Status: Status;
12+
/** @enum {number} */
13+
Priority: Priority;
14+
Item: {
15+
name?: string;
16+
status?: components["schemas"]["Status"];
17+
priority?: components["schemas"]["Priority"];
18+
};
19+
};
20+
responses: never;
21+
parameters: never;
22+
requestBodies: never;
23+
headers: never;
24+
pathItems: never;
25+
}
26+
export type Item = components['schemas']['Item'];
27+
export type $defs = Record<string, never>;
28+
export enum Status {
29+
active = "active",
30+
inactive = "inactive",
31+
pending = "pending"
32+
}
33+
export enum Priority {
34+
Value1 = 1,
35+
Value2 = 2,
36+
Value3 = 3
37+
}
38+
export type operations = Record<string, never>;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Enum Root Types Test
4+
version: 1.0.0
5+
components:
6+
schemas:
7+
Status:
8+
type: string
9+
enum: [active, inactive, pending]
10+
Priority:
11+
type: number
12+
enum: [1, 2, 3]
13+
Item:
14+
type: object
15+
properties:
16+
name:
17+
type: string
18+
status:
19+
$ref: '#/components/schemas/Status'
20+
priority:
21+
$ref: '#/components/schemas/Priority'

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

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,34 @@ import transformRequestBodyObject from "./request-body-object.js";
1111
import transformResponseObject from "./response-object.js";
1212
import transformSchemaObject from "./schema-object.js";
1313

14+
/**
15+
* Determines if a schema object represents an enum type to prevent duplicate exports
16+
* when using --root-types and --enum flags together.
17+
*
18+
* When both flags are enabled:
19+
* - --enum flag generates TypeScript enums at the bottom of the file
20+
* - --root-types flag would normally also export these as root type aliases
21+
* - This results in duplicate exports (both enum and type alias for the same schema)
22+
*
23+
* This function identifies enum schemas so they can be excluded from root type generation,
24+
* allowing only the TypeScript enum to be generated.
25+
*
26+
* @param schema The schema object to check
27+
* @returns true if the schema represents an enum type
28+
*/
29+
export function isEnumSchema(schema: unknown): boolean {
30+
return (
31+
typeof schema === "object" &&
32+
schema !== null &&
33+
!Array.isArray(schema) &&
34+
"enum" in schema &&
35+
Array.isArray((schema as any).enum) &&
36+
(!("type" in schema) || (schema as any).type !== "object") &&
37+
!("properties" in schema) &&
38+
!("additionalProperties" in schema)
39+
);
40+
}
41+
1442
type ComponentTransforms = keyof Omit<ComponentsObject, "examples" | "securitySchemes" | "links" | "callbacks">;
1543

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

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

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

77-
while (rootTypeAliases[aliasName] !== undefined) {
78-
conflictCounter++;
79-
aliasName = `${componentKey}${changeCase.pascalCase(name)}_${conflictCounter}`;
80-
}
81-
const ref = ts.factory.createTypeReferenceNode(`components['${key}']['${name}']`);
82-
if (ctx.rootTypesNoSchemaPrefix && key === "schemas") {
83-
aliasName = aliasName.replace(componentKey, "");
106+
// Add counter suffix (e.g. "_2") if conflict in name
107+
let conflictCounter = 1;
108+
109+
while (rootTypeAliases[aliasName] !== undefined) {
110+
conflictCounter++;
111+
aliasName = `${componentKey}${changeCase.pascalCase(name)}_${conflictCounter}`;
112+
}
113+
const ref = ts.factory.createTypeReferenceNode(`components['${key}']['${name}']`);
114+
if (ctx.rootTypesNoSchemaPrefix && key === "schemas") {
115+
aliasName = aliasName.replace(componentKey, "");
116+
}
117+
const typeAlias = ts.factory.createTypeAliasDeclaration(
118+
/* modifiers */ tsModifiers({ export: true }),
119+
/* name */ aliasName,
120+
/* typeParameters */ undefined,
121+
/* type */ ref,
122+
);
123+
rootTypeAliases[aliasName] = typeAlias;
84124
}
85-
const typeAlias = ts.factory.createTypeAliasDeclaration(
86-
/* modifiers */ tsModifiers({ export: true }),
87-
/* name */ aliasName,
88-
/* typeParameters */ undefined,
89-
/* type */ ref,
90-
);
91-
rootTypeAliases[aliasName] = typeAlias;
92125
}
93126
}
94127
}

packages/openapi-typescript/test/cli.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ describe("CLI", () => {
7676
ci: { timeout: TIMEOUT },
7777
},
7878
],
79+
[
80+
"snapshot > enum root types filtering",
81+
{
82+
given: ["./examples/enum-root-types.yaml", "--root-types", "--root-types-no-schema-prefix", "--enum"],
83+
want: new URL("./examples/enum-root-types.ts", root),
84+
ci: { timeout: TIMEOUT },
85+
},
86+
],
7987
];
8088

8189
for (const [testName, { given, want, ci }] of tests) {

packages/openapi-typescript/test/transform/components-object.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { fileURLToPath } from "node:url";
22
import ts from "typescript";
33
import { NULL, astToString } from "../../src/lib/ts.js";
4-
import transformComponentsObject from "../../src/transform/components-object.js";
4+
import transformComponentsObject, { isEnumSchema } from "../../src/transform/components-object.js";
55
import type { GlobalContext } from "../../src/types.js";
66
import { DEFAULT_CTX, type TestCase } from "../test-helpers.js";
77

@@ -871,3 +871,69 @@ export type Error = components['schemas']['Error'];
871871
);
872872
}
873873
});
874+
875+
describe("isEnumSchema", () => {
876+
test("returns true for string enum schema", () => {
877+
const schema = {
878+
type: "string",
879+
enum: ["active", "inactive", "pending"],
880+
};
881+
expect(isEnumSchema(schema)).toBe(true);
882+
});
883+
884+
test("returns true for number enum schema", () => {
885+
const schema = {
886+
type: "number",
887+
enum: [1, 2, 3],
888+
};
889+
expect(isEnumSchema(schema)).toBe(true);
890+
});
891+
892+
test("returns true for mixed enum schema without explicit type", () => {
893+
const schema = {
894+
enum: ["high", 0, null],
895+
};
896+
expect(isEnumSchema(schema)).toBe(true);
897+
});
898+
899+
test("returns false for object schema with properties", () => {
900+
const schema = {
901+
type: "object",
902+
properties: {
903+
name: { type: "string" },
904+
},
905+
};
906+
expect(isEnumSchema(schema)).toBe(false);
907+
});
908+
909+
test("returns false for object schema with enum (object enums not supported)", () => {
910+
const schema = {
911+
type: "object",
912+
enum: [{ value: "test" }],
913+
};
914+
expect(isEnumSchema(schema)).toBe(false);
915+
});
916+
917+
test("returns false for schema with additionalProperties", () => {
918+
const schema = {
919+
enum: ["test"],
920+
additionalProperties: true,
921+
};
922+
expect(isEnumSchema(schema)).toBe(false);
923+
});
924+
925+
test("returns false for schema without enum", () => {
926+
const schema = {
927+
type: "string",
928+
};
929+
expect(isEnumSchema(schema)).toBe(false);
930+
});
931+
932+
test("returns false for null, undefined, or non-object inputs", () => {
933+
expect(isEnumSchema(null)).toBe(false);
934+
expect(isEnumSchema(undefined)).toBe(false);
935+
expect(isEnumSchema("string")).toBe(false);
936+
expect(isEnumSchema(123)).toBe(false);
937+
expect(isEnumSchema([])).toBe(false);
938+
});
939+
});
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"extends": "./tsconfig.json",
33
"include": ["examples"],
4-
"exclude": ["examples/digital-ocean-api.ts"]
4+
"exclude": ["examples/digital-ocean-api.ts", "examples/enum-root-types.ts"]
55
}

0 commit comments

Comments
 (0)