From 8edc7c6536e067406c9d8ab6e91035e12e708a36 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 8 Sep 2025 14:25:48 +1000 Subject: [PATCH 1/6] support conditional ts enums --- docs/cli.md | 1 + packages/openapi-typescript/bin/cli.js | 3 + packages/openapi-typescript/src/index.ts | 1 + .../src/transform/schema-object.ts | 34 ++- packages/openapi-typescript/src/types.ts | 3 + .../openapi-typescript/test/test-helpers.ts | 1 + .../test/transform/schema-object/enum.test.ts | 276 ++++++++++++++++++ 7 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 packages/openapi-typescript/test/transform/schema-object/enum.test.ts diff --git a/docs/cli.md b/docs/cli.md index fb41bd941..f3236f86b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -113,6 +113,7 @@ The following flags are supported in the CLI: | `--empty-objects-unknown` | | `false` | Allow arbitrary properties for schema objects with no specified properties, and no specified `additionalProperties` | | `--enum` | | `false` | Generate true [TS enums](https://www.typescriptlang.org/docs/handbook/enums.html) rather than string unions. | | `--enum-values` | | `false` | Export enum values as arrays. | +| `--conditional-enums` | | `false` | Only generate true TS enums when the `x-enum-*` metadata is available. Requires `--enum=true` to be enabled. | | `--dedupe-enums` | | `false` | Dedupe enum types when `--enum=true` is set | | `--check` | | `false` | Check that the generated types are up-to-date. | | `--exclude-deprecated` | | `false` | Exclude deprecated fields from types | diff --git a/packages/openapi-typescript/bin/cli.js b/packages/openapi-typescript/bin/cli.js index fea433124..f1fd46a60 100755 --- a/packages/openapi-typescript/bin/cli.js +++ b/packages/openapi-typescript/bin/cli.js @@ -17,6 +17,7 @@ Options --output, -o Specify output file (if not specified in redocly.yaml) --enum Export true TS enums instead of unions --enum-values Export enum values as arrays + --conditional-enums Only generate true TS enums when enum metadata is available (default: false) --dedupe-enums Dedupe enum types when \`--enum=true\` is set --check Check that the generated types are up-to-date. (default: false) --export-type, -t Export top-level \`type\` instead of \`interface\` @@ -74,6 +75,7 @@ const flags = parser(args, { "emptyObjectsUnknown", "enum", "enumValues", + "conditionalEnums", "dedupeEnums", "check", "excludeDeprecated", @@ -139,6 +141,7 @@ async function generateSchema(schema, { redocly, silent = false }) { emptyObjectsUnknown: flags.emptyObjectsUnknown, enum: flags.enum, enumValues: flags.enumValues, + conditionalEnums: flags.conditionalEnums, dedupeEnums: flags.dedupeEnums, excludeDeprecated: flags.excludeDeprecated, exportType: flags.exportType, diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index 8c36fd0ad..c85b355e1 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -75,6 +75,7 @@ export default async function openapiTS( emptyObjectsUnknown: options.emptyObjectsUnknown ?? false, enum: options.enum ?? false, enumValues: options.enumValues ?? false, + conditionalEnums: options.conditionalEnums ?? false, dedupeEnums: options.dedupeEnums ?? false, excludeDeprecated: options.excludeDeprecated ?? false, exportType: options.exportType ?? false, diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 9c5725126..eb8695841 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -96,10 +96,7 @@ export function transformSchemaObjectWithComposition( !("additionalProperties" in schemaObject) ) { // hoist enum to top level if string/number enum and option is enabled - if ( - options.ctx.enum && - schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number" || v === null) - ) { + if (shouldTransformToTsEnum(options, schemaObject)) { let enumName = parseRef(options.path ?? "").pointer.join("/"); // allow #/components/schemas to have simpler names enumName = enumName.replace("components/schemas", ""); @@ -270,6 +267,35 @@ export function transformSchemaObjectWithComposition( return finalType; } +/** + * Check if the given OAPI enum should be transformed to a TypeScript enum + */ +function shouldTransformToTsEnum(options: TransformNodeOptions, schemaObject: SchemaObject): boolean { + // Enum conversion not enabled + if (!options.ctx.enum) { + return false; + } + + // Enum must have string, number or null values + if (!schemaObject.enum?.every((v) => typeof v === "string" || typeof v === "number" || v === null)) { + return false; + } + + // If conditionalEnums is enabled, only convert if x-enum-* metadata is present + if (options.ctx.conditionalEnums) { + const hasEnumMetadata = + Array.isArray(schemaObject["x-enum-varnames"]) || + Array.isArray(schemaObject["x-enumNames"]) || + Array.isArray(schemaObject["x-enum-descriptions"]) || + Array.isArray(schemaObject["x-enumDescriptions"]); + if (!hasEnumMetadata) { + return false; + } + } + + return true; +} + /** * Handle SchemaObject minus composition (anyOf/allOf/oneOf) */ diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index 75d8f8c07..29e11dc33 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -651,6 +651,8 @@ export interface OpenAPITSOptions { enum?: boolean; /** Export union values as arrays */ enumValues?: boolean; + /** Only generate TS Enums when `x-enum-*` metadata is available */ + conditionalEnums?: boolean; /** Dedupe enum values */ dedupeEnums?: boolean; /** (optional) Substitute path parameter names with their respective types */ @@ -688,6 +690,7 @@ export interface GlobalContext { emptyObjectsUnknown: boolean; enum: boolean; enumValues: boolean; + conditionalEnums: boolean; dedupeEnums: boolean; excludeDeprecated: boolean; exportType: boolean; diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index 72e44fccc..14e9abb26 100644 --- a/packages/openapi-typescript/test/test-helpers.ts +++ b/packages/openapi-typescript/test/test-helpers.ts @@ -15,6 +15,7 @@ export const DEFAULT_CTX: GlobalContext = { emptyObjectsUnknown: false, enum: false, enumValues: false, + conditionalEnums: false, dedupeEnums: false, excludeDeprecated: false, exportType: false, diff --git a/packages/openapi-typescript/test/transform/schema-object/enum.test.ts b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts new file mode 100644 index 000000000..d6a3ec982 --- /dev/null +++ b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts @@ -0,0 +1,276 @@ +import { fileURLToPath } from "node:url"; +import { transformSchema } from "../../../src/index.js"; +import { astToString } from "../../../src/lib/ts.js"; +import type { GlobalContext } from "../../../src/types.js"; +import { DEFAULT_CTX, type TestCase } from "../../test-helpers.js"; + +const DEFAULT_OPTIONS = DEFAULT_CTX; + +const schema = { + openapi: "3.0.0", + info: { + title: "Status API", + version: "1.0.0", + }, + paths: { + "/status": { + get: { + summary: "Get current status", + responses: { + "200": { + description: "Status response", + content: { + "application/json": { + schema: { + type: "object", + properties: { + required: { + "status": true, + "statusEnum": true, + }, + status: { + $ref: "#/components/schemas/StatusResponse", + }, + statusEnum: { + $ref: "#/components/schemas/StatusEnumResponse", + } + } + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + StatusResponse: { + type: "object", + properties: { + status: { + $ref: "#/components/schemas/Status", + }, + }, + }, + Status: { + type: "string", + enum: ["pending", "active", "done"], + }, + StatusEnumResponse: { + type: "object", + properties: { + status: { + $ref: "#/components/schemas/StatusEnum", + }, + }, + }, + StatusEnum: { + type: "string", + enum: ["pending", "active", "done"], + "x-enum-varnames": ["Pending", "Active", "Done"], + "x-enum-descriptions": [ + "The task is pending", + "The task is active", + "The task is done", + ], + }, + }, + }, +}; + +describe("transformComponentsObject", () => { + const tests: TestCase[] = [ + [ + "options > enum: true and conditionalEnums: false", + { + given: schema, + want: `export interface paths { + "/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current status */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Status response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + required?: unknown; + status?: components["schemas"]["StatusResponse"]; + statusEnum?: components["schemas"]["StatusEnumResponse"]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + StatusResponse: { + status?: components["schemas"]["Status"]; + }; + /** @enum {string} */ + Status: Status; + StatusEnumResponse: { + status?: components["schemas"]["StatusEnum"]; + }; + /** @enum {string} */ + StatusEnum: StatusEnum; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export enum Status { + pending = "pending", + active = "active", + done = "done" +} +export enum StatusEnum { + // The task is pending + Pending = "pending", + // The task is active + Active = "active", + // The task is done + Done = "done" +} +export type operations = Record;`, + options: { ...DEFAULT_OPTIONS, enum: true, conditionalEnums: false }, + }, + ], + [ + "options > enum: true and conditionalEnums: true", + { + given: schema, + want: `export interface paths { + "/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current status */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Status response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + required?: unknown; + status?: components["schemas"]["StatusResponse"]; + statusEnum?: components["schemas"]["StatusEnumResponse"]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + StatusResponse: { + status?: components["schemas"]["Status"]; + }; + /** @enum {string} */ + Status: "pending" | "active" | "done"; + StatusEnumResponse: { + status?: components["schemas"]["StatusEnum"]; + }; + /** @enum {string} */ + StatusEnum: StatusEnum; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export enum Status { + pending = "pending", + active = "active", + done = "done" +} +export enum StatusEnum { + // The task is pending + Pending = "pending", + // The task is active + Active = "active", + // The task is done + Done = "done" +} +export enum StatusEnum { + // The task is pending + Pending = "pending", + // The task is active + Active = "active", + // The task is done + Done = "done" +} +export type operations = Record;`, + options: { ...DEFAULT_OPTIONS, enum: true, conditionalEnums: true }, + }, + ], + ]; + + for (const [testName, { given, want, options, ci }] of tests) { + test.skipIf(ci?.skipIf)( + testName, + async () => { + const result = astToString(transformSchema(given, options ?? DEFAULT_OPTIONS)); + if (want instanceof URL) { + await expect(result).toMatchFileSnapshot(fileURLToPath(want)); + } else { + expect(result.trim()).toBe(want.trim()); + } + }, + ci?.timeout, + ); + } +}); From dd2075e31e4a4d9a573dec59d1db7dea17ee8da0 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 8 Sep 2025 15:49:33 +1000 Subject: [PATCH 2/6] simplify enum test --- packages/openapi-typescript/src/transform/schema-object.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index eb8695841..a51c9e6d4 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -271,13 +271,13 @@ export function transformSchemaObjectWithComposition( * Check if the given OAPI enum should be transformed to a TypeScript enum */ function shouldTransformToTsEnum(options: TransformNodeOptions, schemaObject: SchemaObject): boolean { - // Enum conversion not enabled - if (!options.ctx.enum) { + // Enum conversion not enabled or no enum present + if (!options.ctx.enum || !schemaObject.enum) { return false; } // Enum must have string, number or null values - if (!schemaObject.enum?.every((v) => typeof v === "string" || typeof v === "number" || v === null)) { + if (!schemaObject.enum.every((v) => ['string', 'number', null].includes(typeof v))) { return false; } From e4c7d80b870df456ce62cbfd996b88741bc3266c Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 8 Sep 2025 15:49:41 +1000 Subject: [PATCH 3/6] fix test context pollution --- .../test/transform/schema-object/enum.test.ts | 227 +++++++++--------- 1 file changed, 109 insertions(+), 118 deletions(-) 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 d6a3ec982..1e86e11ca 100644 --- a/packages/openapi-typescript/test/transform/schema-object/enum.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts @@ -1,91 +1,13 @@ -import { fileURLToPath } from "node:url"; import { transformSchema } from "../../../src/index.js"; import { astToString } from "../../../src/lib/ts.js"; -import type { GlobalContext } from "../../../src/types.js"; import { DEFAULT_CTX, type TestCase } from "../../test-helpers.js"; -const DEFAULT_OPTIONS = DEFAULT_CTX; - -const schema = { - openapi: "3.0.0", - info: { - title: "Status API", - version: "1.0.0", - }, - paths: { - "/status": { - get: { - summary: "Get current status", - responses: { - "200": { - description: "Status response", - content: { - "application/json": { - schema: { - type: "object", - properties: { - required: { - "status": true, - "statusEnum": true, - }, - status: { - $ref: "#/components/schemas/StatusResponse", - }, - statusEnum: { - $ref: "#/components/schemas/StatusEnumResponse", - } - } - }, - }, - }, - }, - }, - }, - }, - }, - components: { - schemas: { - StatusResponse: { - type: "object", - properties: { - status: { - $ref: "#/components/schemas/Status", - }, - }, - }, - Status: { - type: "string", - enum: ["pending", "active", "done"], - }, - StatusEnumResponse: { - type: "object", - properties: { - status: { - $ref: "#/components/schemas/StatusEnum", - }, - }, - }, - StatusEnum: { - type: "string", - enum: ["pending", "active", "done"], - "x-enum-varnames": ["Pending", "Active", "Done"], - "x-enum-descriptions": [ - "The task is pending", - "The task is active", - "The task is done", - ], - }, - }, - }, -}; - -describe("transformComponentsObject", () => { - const tests: TestCase[] = [ - [ - "options > enum: true and conditionalEnums: false", - { - given: schema, - want: `export interface paths { +const tests: TestCase[] = [ + [ + "options > enum: true and conditionalEnums: false", + { + given: mockSchema(), + want: `export interface paths { "/status": { parameters: { query?: never; @@ -162,14 +84,14 @@ export enum StatusEnum { Done = "done" } export type operations = Record;`, - options: { ...DEFAULT_OPTIONS, enum: true, conditionalEnums: false }, - }, - ], - [ - "options > enum: true and conditionalEnums: true", - { - given: schema, - want: `export interface paths { + options: { ctx: createTestContext({ enum: true, conditionalEnums: false }) }, + }, + ], + [ + "options > enum: true and conditionalEnums: true", + { + given: mockSchema(), + want: `export interface paths { "/status": { parameters: { query?: never; @@ -232,19 +154,6 @@ export interface components { pathItems: never; } export type $defs = Record; -export enum Status { - pending = "pending", - active = "active", - done = "done" -} -export enum StatusEnum { - // The task is pending - Pending = "pending", - // The task is active - Active = "active", - // The task is done - Done = "done" -} export enum StatusEnum { // The task is pending Pending = "pending", @@ -254,23 +163,105 @@ export enum StatusEnum { Done = "done" } export type operations = Record;`, - options: { ...DEFAULT_OPTIONS, enum: true, conditionalEnums: true }, - }, - ], - ]; + options: { ctx: createTestContext({ enum: true, conditionalEnums: true }) }, + }, + ], +]; - for (const [testName, { given, want, options, ci }] of tests) { +describe("transformComponentsObject", () => { + describe.each(tests)("Case: %s", (name, { given, want, options, ci }) => { test.skipIf(ci?.skipIf)( - testName, + "it matches the snapshot", async () => { - const result = astToString(transformSchema(given, options ?? DEFAULT_OPTIONS)); - if (want instanceof URL) { - await expect(result).toMatchFileSnapshot(fileURLToPath(want)); - } else { - expect(result.trim()).toBe(want.trim()); - } + assert(typeof want === "string"); + const result = astToString(transformSchema(given, options?.ctx ?? DEFAULT_CTX), { fileName: name }); + expect(result.trim()).toBe(want.trim()); }, ci?.timeout, ); - } + }); }); + +function mockSchema() { + return { + openapi: "3.0.0", + info: { + title: "Status API", + version: "1.0.0", + }, + paths: { + "/status": { + get: { + summary: "Get current status", + responses: { + "200": { + description: "Status response", + content: { + "application/json": { + schema: { + type: "object", + properties: { + required: { + status: true, + statusEnum: true, + }, + status: { + $ref: "#/components/schemas/StatusResponse", + }, + statusEnum: { + $ref: "#/components/schemas/StatusEnumResponse", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + StatusResponse: { + type: "object", + properties: { + status: { + $ref: "#/components/schemas/Status", + }, + }, + }, + Status: { + type: "string", + enum: ["pending", "active", "done"], + }, + StatusEnumResponse: { + type: "object", + properties: { + status: { + $ref: "#/components/schemas/StatusEnum", + }, + }, + }, + StatusEnum: { + type: "string", + enum: ["pending", "active", "done"], + "x-enum-varnames": ["Pending", "Active", "Done"], + "x-enum-descriptions": ["The task is pending", "The task is active", "The task is done"], + }, + }, + }, + }; +} + +function createTestContext(overrides: Partial = {}) { + return { + ...DEFAULT_CTX, + ...overrides, + // Deep copy mutable properties to avoid scope pollution + discriminators: { + objects: {}, + refsHandled: [], + }, + injectFooter: [], + }; +} \ No newline at end of file From a3aeb97d956ad895c6398403a8c8428cb7756b9a Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 8 Sep 2025 15:54:31 +1000 Subject: [PATCH 4/6] add changeset --- .changeset/every-roses-tickle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/every-roses-tickle.md diff --git a/.changeset/every-roses-tickle.md b/.changeset/every-roses-tickle.md new file mode 100644 index 000000000..f90faf590 --- /dev/null +++ b/.changeset/every-roses-tickle.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": minor +--- + +Conditionally generate TS enums From 968b6934884ac6b9e4ce2258fe377c9c8b1d2e89 Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 8 Sep 2025 15:57:24 +1000 Subject: [PATCH 5/6] add baseline test for disabled enums flag --- .../test/transform/schema-object/enum.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) 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 1e86e11ca..5dae6ab04 100644 --- a/packages/openapi-typescript/test/transform/schema-object/enum.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts @@ -3,6 +3,77 @@ import { astToString } from "../../../src/lib/ts.js"; import { DEFAULT_CTX, type TestCase } from "../../test-helpers.js"; const tests: TestCase[] = [ + [ + "options > enum: false and conditionalEnums: false", + { + given: mockSchema(), + want: `export interface paths { + "/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current status */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Status response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + required?: unknown; + status?: components["schemas"]["StatusResponse"]; + statusEnum?: components["schemas"]["StatusEnumResponse"]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + StatusResponse: { + status?: components["schemas"]["Status"]; + }; + /** @enum {string} */ + Status: "pending" | "active" | "done"; + StatusEnumResponse: { + status?: components["schemas"]["StatusEnum"]; + }; + /** @enum {string} */ + StatusEnum: "pending" | "active" | "done"; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record;`, + options: { ctx: createTestContext({ enum: false, conditionalEnums: false }) }, + }, + ], [ "options > enum: true and conditionalEnums: false", { From ad43b6ad4a145af641e8bfc3cfb845d7a13cf0cf Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 8 Sep 2025 16:00:41 +1000 Subject: [PATCH 6/6] lint fix --- packages/openapi-typescript/src/transform/schema-object.ts | 2 +- .../test/transform/schema-object/enum.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index a51c9e6d4..ea1e21d89 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -277,7 +277,7 @@ function shouldTransformToTsEnum(options: TransformNodeOptions, schemaObject: Sc } // Enum must have string, number or null values - if (!schemaObject.enum.every((v) => ['string', 'number', null].includes(typeof v))) { + if (!schemaObject.enum.every((v) => ["string", "number", null].includes(typeof v))) { return false; } 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 5dae6ab04..9de603479 100644 --- a/packages/openapi-typescript/test/transform/schema-object/enum.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/enum.test.ts @@ -335,4 +335,4 @@ function createTestContext(overrides: Partial = {}) { }, injectFooter: [], }; -} \ No newline at end of file +}