From ad21cb21d1c6618bd04f87dcb657f615fad61607 Mon Sep 17 00:00:00 2001 From: cyphercodes Date: Sun, 17 May 2026 06:51:56 +0300 Subject: [PATCH] Fix empty string TypeScript enum members --- .changeset/fix-empty-string-enum-members.md | 5 + packages/openapi-typescript/src/lib/ts.ts | 30 +++--- .../openapi-typescript/test/lib/ts.test.ts | 8 ++ .../test/transform/schema-object/enum.test.ts | 92 +++++++++++++++++++ 4 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-empty-string-enum-members.md diff --git a/.changeset/fix-empty-string-enum-members.md b/.changeset/fix-empty-string-enum-members.md new file mode 100644 index 000000000..33912c224 --- /dev/null +++ b/.changeset/fix-empty-string-enum-members.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": patch +--- + +Generate valid TypeScript enum members for empty string enum values when using `--enum`. \ No newline at end of file diff --git a/packages/openapi-typescript/src/lib/ts.ts b/packages/openapi-typescript/src/lib/ts.ts index d1f41eb88..1bf9345d5 100644 --- a/packages/openapi-typescript/src/lib/ts.ts +++ b/packages/openapi-typescript/src/lib/ts.ts @@ -408,20 +408,24 @@ function sanitizeMemberName(name: string) { export function tsEnumMember(value: string | number, metadata: { name?: string; description?: string | null } = {}) { let name = metadata.name ?? String(value); if (!JS_PROPERTY_INDEX_RE.test(name)) { - if (Number(name[0]) >= 0) { - name = `Value${name}`.replace(".", "_"); // don't forged decimals; - } else if (name[0] === "-") { - name = `ValueMinus${name.slice(1)}`; - } + if (name === "") { + name = `"${name}"`; + } else { + if (Number(name[0]) >= 0) { + name = `Value${name}`.replace(".", "_"); // don't forged decimals; + } else if (name[0] === "-") { + name = `ValueMinus${name.slice(1)}`; + } - const invalidCharMatch = name.match(JS_PROPERTY_INDEX_INVALID_CHARS_RE); - if (invalidCharMatch) { - if (invalidCharMatch[0] === name) { - name = `"${name}"`; - } else { - name = name.replace(JS_PROPERTY_INDEX_INVALID_CHARS_RE, (s) => { - return s in SPECIAL_CHARACTER_MAP ? SPECIAL_CHARACTER_MAP[s] : "_"; - }); + const invalidCharMatch = name.match(JS_PROPERTY_INDEX_INVALID_CHARS_RE); + if (invalidCharMatch) { + if (invalidCharMatch[0] === name) { + name = `"${name}"`; + } else { + name = name.replace(JS_PROPERTY_INDEX_INVALID_CHARS_RE, (s) => { + return s in SPECIAL_CHARACTER_MAP ? SPECIAL_CHARACTER_MAP[s] : "_"; + }); + } } } } diff --git a/packages/openapi-typescript/test/lib/ts.test.ts b/packages/openapi-typescript/test/lib/ts.test.ts index 79e5b4571..dbb1b2fdd 100644 --- a/packages/openapi-typescript/test/lib/ts.test.ts +++ b/packages/openapi-typescript/test/lib/ts.test.ts @@ -218,6 +218,14 @@ describe("tsEnum", () => { }`); }); + test("empty string member", () => { + expect(astToString(tsEnum("type", ["", "foo", "bar"])).trim()).toBe(`enum Type { + "" = "", + foo = "foo", + bar = "bar" +}`); + }); + test("number members", () => { expect(astToString(tsEnum(".Error.code.", [100, 101, 102, -100])).trim()).toBe(`enum ErrorCode { Value100 = 100, diff --git a/packages/openapi-typescript/test/transform/schema-object/enum.test.ts b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts index 9de603479..1e3bd3369 100644 --- a/packages/openapi-typescript/test/transform/schema-object/enum.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts @@ -237,6 +237,62 @@ export type operations = Record;`, options: { ctx: createTestContext({ enum: true, conditionalEnums: true }) }, }, ], + [ + "options > enum: true with empty string enum member", + { + given: mockEmptyStringEnumSchema(), + want: `export interface paths { + "/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Test */ + get: operations["test"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export type components = Record; +export type $defs = Record; +export interface operations { + test: { + parameters: { + query?: { + type?: PathsTestGetParametersQueryType; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} +export enum PathsTestGetParametersQueryType { + "" = "", + foo = "foo", + bar = "bar" +}`, + options: { ctx: createTestContext({ enum: true }) }, + }, + ], ]; describe("transformComponentsObject", () => { @@ -324,6 +380,42 @@ function mockSchema() { }; } +function mockEmptyStringEnumSchema() { + return { + openapi: "3.1.0", + info: { + title: "Test", + version: "0.1.0", + }, + paths: { + "/test": { + get: { + summary: "Test", + operationId: "test", + parameters: [ + { + name: "type", + in: "query", + required: false, + schema: { + enum: ["", "foo", "bar"], + type: "string", + default: "", + title: "Type", + }, + }, + ], + responses: { + 200: { + description: "OK", + }, + }, + }, + }, + }, + }; +} + function createTestContext(overrides: Partial = {}) { return { ...DEFAULT_CTX,