Skip to content
42 changes: 42 additions & 0 deletions .chronus/changes/openapi3-enum-style-annotated-2026-6-4-13-30-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
changeKind: feature
packages:
- "@typespec/openapi3"
---

Add opt-in `enum-strategy` emitter option to emit TypeSpec enums as [annotated enumerations](https://spec.openapis.org/oas/v3.1.1.html#annotated-enumerations) (a `oneOf` of `const` subschemas with per-member `title`/`description`). Supported for OpenAPI 3.1.0 and above; emitting with OpenAPI 3.0.0 falls back to the default form and reports a warning.

```yaml
options:
"@typespec/openapi3":
enum-strategy: annotated
```

For example, the following TypeSpec:

```typespec
/** Type of pet. */
enum PetType {
/** A loyal canine companion. */
@summary("Dog")
Dog: "dog",

/** A self-sufficient feline. */
@summary("Cat")
Cat: "cat",
}
```

emits:

```yaml
PetType:
description: Type of pet.
oneOf:
- const: dog
title: Dog
description: A loyal canine companion.
- const: cat
title: Cat
description: A self-sufficient feline.
```
13 changes: 13 additions & 0 deletions packages/openapi3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@ See https://github.com/OAI/OpenAPI-Specification/discussions/4622 for discussion
| `kind` | `"parent-container" \| "fqn" \| "explicit-only"` | `"parent-container"` | Determines how to generate operation IDs when `@operationId` is not used.<br />Avaliable options are:<br /> - `parent-container`: Uses the parent namespace and operation name to generate the ID.<br /> - `fqn`: Uses the fully qualified name of the operation to generate the ID.<br /> - `explicit-only`: Only use explicitly defined operation IDs. |
| `separator` | `string` | | Separator used to join segment in the operation name. |

### `enum-strategy`

**Type:** `"default" | "annotated"`

**Default:** `"default"`

How to emit TypeSpec enums. Options are:

- `default`: Emit as a single schema using the `enum` keyword.
- `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description`
from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.
Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported.

## Decorators

### TypeSpec.OpenAPI
Expand Down
35 changes: 35 additions & 0 deletions packages/openapi3/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createTypeSpecLibrary, JSONSchemaType, paramMessage } from "@typespec/c
export type FileType = "yaml" | "json";
export type OpenAPIVersion = "3.0.0" | "3.1.0" | "3.2.0";
export type ExperimentalParameterExamplesStrategy = "data" | "serialized";
export type EnumStrategy = "default" | "annotated";
export interface OpenAPI3EmitterOptions {
/**
* If the content should be serialized as YAML or JSON. Can be a single value or an array to emit multiple file types.
Expand Down Expand Up @@ -108,6 +109,20 @@ export interface OpenAPI3EmitterOptions {
/** Separator used to join segment in the operation name. */
separator?: string;
};

/**
* How to emit TypeSpec enums.
*
* - `default`: Emit as a single schema using the `enum` keyword.
* - `annotated`: Emit as a `oneOf` of `const` subschemas, each annotated with `title` and `description`
* when the corresponding enum member has `@summary` or `@doc`. This follows the OpenAPI 3.1.1
* [annotated enumerations](https://spec.openapis.org/oas/v3.1.1.html#annotated-enumerations) pattern.
* Only supported by OpenAPI 3.1.0 and above. When emitting OpenAPI 3.0.0, a warning will be reported
* and the `default` style will be used instead.
*
* @default "default"
*/
"enum-strategy"?: EnumStrategy;
}

export type OperationIdStrategy = "parent-container" | "fqn" | "explicit-only";
Expand Down Expand Up @@ -268,6 +283,19 @@ const EmitterOptionsSchema: JSONSchemaType<OpenAPI3EmitterOptions> = {
},
],
} as any,
"enum-strategy": {
type: "string",
enum: ["default", "annotated"],
nullable: true,
default: "default",
description: [
"How to emit TypeSpec enums. Options are:",
" - `default`: Emit as a single schema using the `enum` keyword.",
" - `annotated`: Emit as a `oneOf` of `const` subschemas annotated with `title` and `description`",
" from each member's `@summary` and `@doc`. Follows the OpenAPI 3.1.1 annotated enumerations pattern.",
" Only supported by OpenAPI 3.1.0 and above; on 3.0.0 the `default` style is used and a warning is reported.",
].join("\n"),
},
},
required: [],
};
Expand Down Expand Up @@ -428,6 +456,13 @@ export const $lib = createTypeSpecLibrary({
default: paramMessage`Default value is not supported in OpenAPI 3.0 ${"message"}`,
},
},
"enum-strategy-not-supported": {
severity: "warning",
messages: {
default:
"`enum-strategy: annotated` is only supported for OpenAPI 3.1.0 and above. The default enum strategy will be used for OpenAPI 3.0.0.",
},
},
},
emitter: {
options: EmitterOptionsSchema as JSONSchemaType<OpenAPI3EmitterOptions>,
Expand Down
9 changes: 9 additions & 0 deletions packages/openapi3/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
Namespace,
navigateTypesInNamespace,
NewLine,
NoTarget,
Program,
resolvePath,
Service,
Expand Down Expand Up @@ -86,6 +87,7 @@ import { getExampleOrExamples, OperationExamples, resolveOperationExamples } fro
import { JsonSchemaModule, resolveJsonSchemaModule } from "./json-schema.js";
import {
createDiagnostic,
EnumStrategy,
FileType,
OpenAPI3EmitterOptions,
OpenAPIVersion,
Expand Down Expand Up @@ -219,6 +221,11 @@ export function resolveOptions(

const openapiVersions = resolvedOptions["openapi-versions"] ?? ["3.0.0"];

const enumStrategy: EnumStrategy = resolvedOptions["enum-strategy"] ?? "default";
if (enumStrategy === "annotated" && openapiVersions.includes("3.0.0")) {
reportDiagnostic(context.program, { code: "enum-strategy-not-supported", target: NoTarget });
}

const specDir = openapiVersions.length > 1 ? "{openapi-version}" : "";
return {
fileTypes,
Expand All @@ -231,6 +238,7 @@ export function resolveOptions(
sealObjectSchemas: resolvedOptions["seal-object-schemas"],
parameterExamplesStrategy: resolvedOptions["experimental-parameter-examples"],
operationIdStrategy: resolveOperationIdStrategy(resolvedOptions["operation-id-strategy"]),
enumStrategy,
};
}

Expand Down Expand Up @@ -272,6 +280,7 @@ export interface ResolvedOpenAPI3EmitterOptions {
sealObjectSchemas: boolean;
parameterExamplesStrategy?: "data" | "serialized";
operationIdStrategy: { kind: OperationIdStrategy; separator: string };
enumStrategy: EnumStrategy;
}

function createOAPIEmitter(
Expand Down
26 changes: 26 additions & 0 deletions packages/openapi3/src/schema-emitter-3-1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import {
compilerAssert,
Enum,
getDiscriminatedUnion,
getDoc,
getExamples,
getMaxValueExclusive,
getMinValueExclusive,
getSummary,
IntrinsicScalarName,
IntrinsicType,
Model,
Expand Down Expand Up @@ -183,6 +185,10 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPISch
return {};
}

if (this._options.enumStrategy === "annotated") {
return this.#annotatedEnumSchema(en);
}

const enumTypes = new Set<JsonType>();
const enumValues = new Set<string | number>();
for (const member of en.members.values()) {
Expand All @@ -200,6 +206,26 @@ export class OpenAPI31SchemaEmitter extends OpenAPI3SchemaEmitterBase<OpenAPISch
return this.applyConstraints(en, schema);
}

#annotatedEnumSchema(en: Enum): OpenAPISchema3_1 {
const program = this.emitter.getProgram();
const oneOf: OpenAPISchema3_1[] = [];
for (const member of en.members.values()) {
const value = member.value ?? member.name;
const subschema: OpenAPISchema3_1 = { const: value };
const title = getSummary(program, member);
if (title !== undefined) {
subschema.title = title;
}
const description = getDoc(program, member);
if (description !== undefined) {
subschema.description = description;
}
oneOf.push(subschema);
}

return this.applyConstraints(en, { oneOf });
}

unionSchema(union: Union): ObjectBuilder<OpenAPISchema3_1> {
const program = this.emitter.getProgram();
const [discriminated] = getDiscriminatedUnion(program, union);
Expand Down
Loading
Loading