diff --git a/.chronus/changes/add-enum-instead-of-boolean-rule-2026-08-20-13-13-00.md b/.chronus/changes/add-enum-instead-of-boolean-rule-2026-08-20-13-13-00.md new file mode 100644 index 0000000000..defe449c75 --- /dev/null +++ b/.chronus/changes/add-enum-instead-of-boolean-rule-2026-08-20-13-13-00.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azure-core" + - "@azure-tools/typespec-azure-rulesets" +--- + +Add the `enum-instead-of-boolean` lint rule that recommends descriptive extensible enums instead of boolean API shapes when semantic values matter. diff --git a/packages/typespec-azure-core/README.md b/packages/typespec-azure-core/README.md index 5e5a3a7f18..32074a6325 100644 --- a/packages/typespec-azure-core/README.md +++ b/packages/typespec-azure-core/README.md @@ -35,6 +35,7 @@ Available ruleSets: | [`@azure-tools/typespec-azure-core/byos`](https://azure.github.io/typespec-azure/docs/libraries/azure-core/rules/byos) | Use the BYOS pattern recommended for Azure Services. | | [`@azure-tools/typespec-azure-core/casing-style`](https://azure.github.io/typespec-azure/docs/libraries/azure-core/rules/casing-style) | Ensure proper casing style. | | [`@azure-tools/typespec-azure-core/composition-over-inheritance`](https://azure.github.io/typespec-azure/docs/libraries/azure-core/rules/composition-over-inheritance) | Check that if a model is used in an operation and has derived models that it has a discriminator or recommend to use composition via spread or `is`. | +| [`@azure-tools/typespec-azure-core/use-enum-instead-of-boolean`](https://azure.github.io/typespec-azure/docs/libraries/azure-core/rules/use-enum-instead-of-boolean) | Boolean properties should use descriptive extensible enums when semantic values matter. | | [`@azure-tools/typespec-azure-core/known-encoding`](https://azure.github.io/typespec-azure/docs/libraries/azure-core/rules/known-encoding) | Check for supported encodings. | | [`@azure-tools/typespec-azure-core/long-running-polling-operation-required`](https://azure.github.io/typespec-azure/docs/libraries/azure-core/rules/long-running-polling-operation-required) | Long-running operations should have a linked polling operation. | | [`@azure-tools/typespec-azure-core/no-case-mismatch`](https://azure.github.io/typespec-azure/docs/libraries/azure-core/rules/no-case-mismatch) | Validate that no two types have the same name with different casing. | diff --git a/packages/typespec-azure-core/src/linter.ts b/packages/typespec-azure-core/src/linter.ts index e13abd9932..be33debd16 100644 --- a/packages/typespec-azure-core/src/linter.ts +++ b/packages/typespec-azure-core/src/linter.ts @@ -39,6 +39,7 @@ import { requireVersionedRule } from "./rules/require-versioned.js"; import { responseSchemaMultiStatusCodeRule } from "./rules/response-schema-multi-status-code.js"; import { rpcOperationRequestBodyRule } from "./rules/rpc-operation-request-body.js"; import { spreadDiscriminatedModelRule } from "./rules/spread-discriminated-model.js"; +import { useEnumInsteadOfBooleanRule } from "./rules/use-enum-instead-of-boolean.js"; import { useStandardNames } from "./rules/use-standard-names.js"; import { useStandardOperations } from "./rules/use-standard-operations.js"; @@ -49,6 +50,7 @@ const rules = [ byosRule, casingRule, compositionOverInheritanceRule, + useEnumInsteadOfBooleanRule, knownEncodingRule, longRunningOperationsRequirePollingOperation, noCaseMismatchRule, diff --git a/packages/typespec-azure-core/src/rules/use-enum-instead-of-boolean.md b/packages/typespec-azure-core/src/rules/use-enum-instead-of-boolean.md new file mode 100644 index 0000000000..24c719fc66 --- /dev/null +++ b/packages/typespec-azure-core/src/rules/use-enum-instead-of-boolean.md @@ -0,0 +1,60 @@ +--- +title: "use-enum-instead-of-boolean" +--- + +```text title="Full name" +@azure-tools/typespec-azure-core/use-enum-instead-of-boolean +``` + +Boolean values can be hard for API users to understand when the property or payload represents a +domain state, option, or mode. Prefer a descriptive extensible enum modeled as a union so future +values can be added without a breaking change. + +## Impact + +- **Area:** SDK, API + +Boolean shapes can make generated clients less readable and can force future breaking changes if the +API later needs more than two values. + +## LintDiff Equivalent + +This rule corresponds to the LintDiff rule +[EnumInsteadOfBoolean](https://github.com/Azure/azure-openapi-validator/blob/main/docs/enum-instead-of-boolean.md). + +#### Incorrect + +```tsp +model Widget { + enabled: boolean; +} +``` + +```tsp +@get +op isWidgetEnabled(): boolean; +``` + +#### Correct + +```tsp +union WidgetState { + Enabled: "Enabled", + Disabled: "Disabled", + string, +} + +model Widget { + state: WidgetState; +} +``` + +```tsp +@get +op getWidgetState(): WidgetState; +``` + +## Suppression + +Suppress this rule only when the value is inherently boolean and is unlikely to grow additional +states, such as a simple yes/no capability. diff --git a/packages/typespec-azure-core/src/rules/use-enum-instead-of-boolean.ts b/packages/typespec-azure-core/src/rules/use-enum-instead-of-boolean.ts new file mode 100644 index 0000000000..16d0696ccd --- /dev/null +++ b/packages/typespec-azure-core/src/rules/use-enum-instead-of-boolean.ts @@ -0,0 +1,57 @@ +import type { Type } from "@typespec/compiler"; +import { createRule, fileRef } from "@typespec/compiler"; +import { getHttpOperation } from "@typespec/http"; + +export const useEnumInsteadOfBooleanRule = createRule({ + name: "use-enum-instead-of-boolean", + docs: fileRef.fromPackageRoot("src/rules/use-enum-instead-of-boolean.md"), + description: + "Boolean properties should use descriptive extensible enums when semantic values matter.", + severity: "warning", + url: "https://azure.github.io/typespec-azure/docs/libraries/azure-core/rules/use-enum-instead-of-boolean", + messages: { + default: + "Consider using an extensible enum instead of a boolean property so the API shape is more descriptive.", + }, + create(context) { + return { + modelProperty: (property) => { + if (!isBooleanScalar(property)) { + return; + } + + context.reportDiagnostic({ + target: property, + }); + }, + operation: (operation) => { + const [httpOperation] = getHttpOperation(context.program, operation); + + for (const response of httpOperation.responses) { + if (isBooleanScalar(response.type)) { + context.reportDiagnostic({ + target: operation, + }); + continue; + } + + for (const content of response.responses) { + if (content.body === undefined || !isBooleanScalar(content.body.type)) { + continue; + } + + context.reportDiagnostic({ + target: content.body.property ?? operation, + }); + } + } + }, + }; + }, +}); + +function isBooleanScalar(type: Type): boolean { + return type.kind === "ModelProperty" + ? isBooleanScalar(type.type) + : type.kind === "Scalar" && type.name === "boolean"; +} diff --git a/packages/typespec-azure-core/test/rules/use-enum-instead-of-boolean.test.ts b/packages/typespec-azure-core/test/rules/use-enum-instead-of-boolean.test.ts new file mode 100644 index 0000000000..212eb8a287 --- /dev/null +++ b/packages/typespec-azure-core/test/rules/use-enum-instead-of-boolean.test.ts @@ -0,0 +1,94 @@ +import { Tester } from "#test/test-host.js"; +import { type LinterRuleTester, createLinterRuleTester } from "@typespec/compiler/testing"; +import { beforeEach, describe, it } from "vitest"; +import { useEnumInsteadOfBooleanRule } from "../../src/rules/use-enum-instead-of-boolean.js"; + +let tester: LinterRuleTester; + +beforeEach(async () => { + const runner = await Tester.createInstance(); + tester = createLinterRuleTester( + runner, + useEnumInsteadOfBooleanRule, + "@azure-tools/typespec-azure-core", + ); +}); + +describe("boolean shapes should use descriptive extensible enums", () => { + it("emits warning for boolean model properties", async () => { + await tester + .expect( + ` + model Widget { + enabled: boolean; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-core/use-enum-instead-of-boolean", + message: + "Consider using an extensible enum instead of a boolean property so the API shape is more descriptive.", + }); + }); + + it("emits warning for boolean path parameters", async () => { + await tester + .expect( + ` + @route("/widgets/{enabled}") + @get + op getWidget(@path enabled: boolean): string; + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-core/use-enum-instead-of-boolean", + }); + }); + + it("emits warning for boolean request bodies", async () => { + await tester + .expect( + ` + @post + op checkWidget(@body body: boolean): string; + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-core/use-enum-instead-of-boolean", + }); + }); + + it("emits warning for boolean response bodies", async () => { + await tester + .expect( + ` + @get + op isWidgetEnabled(): boolean; + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-core/use-enum-instead-of-boolean", + }); + }); + + it("allows comparable non-boolean shapes", async () => { + await tester + .expect( + ` + union WidgetState { + Enabled: "Enabled", + Disabled: "Disabled", + string, + } + + model Widget { + state: WidgetState; + } + + @post + op checkWidget(@body body: WidgetState): WidgetState; + `, + ) + .toBeValid(); + }); +}); diff --git a/packages/typespec-azure-rulesets/src/rulesets/data-plane.ts b/packages/typespec-azure-rulesets/src/rulesets/data-plane.ts index 9f9314e74c..bf679e7645 100644 --- a/packages/typespec-azure-rulesets/src/rulesets/data-plane.ts +++ b/packages/typespec-azure-rulesets/src/rulesets/data-plane.ts @@ -8,6 +8,7 @@ export default { "@azure-tools/typespec-azure-core/byos": true, "@azure-tools/typespec-azure-core/casing-style": true, "@azure-tools/typespec-azure-core/composition-over-inheritance": true, + "@azure-tools/typespec-azure-core/use-enum-instead-of-boolean": true, "@azure-tools/typespec-azure-core/use-extensible-enum": true, "@azure-tools/typespec-azure-core/known-encoding": true, "@azure-tools/typespec-azure-core/long-running-polling-operation-required": true, diff --git a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts index 1167301d2f..b39f322956 100644 --- a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts +++ b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts @@ -9,6 +9,7 @@ export default { "@azure-tools/typespec-azure-core/byos": true, "@azure-tools/typespec-azure-core/casing-style": true, "@azure-tools/typespec-azure-core/composition-over-inheritance": true, + "@azure-tools/typespec-azure-core/use-enum-instead-of-boolean": true, "@azure-tools/typespec-azure-core/use-extensible-enum": true, "@azure-tools/typespec-azure-core/known-encoding": true, "@azure-tools/typespec-azure-core/long-running-polling-operation-required": true, diff --git a/website/src/content/docs/docs/libraries/azure-core/reference/linter.md b/website/src/content/docs/docs/libraries/azure-core/reference/linter.md index 1b286c74a4..c44db610ac 100644 --- a/website/src/content/docs/docs/libraries/azure-core/reference/linter.md +++ b/website/src/content/docs/docs/libraries/azure-core/reference/linter.md @@ -29,6 +29,7 @@ Available ruleSets: | [`@azure-tools/typespec-azure-core/byos`](../rules/byos.md) | Use the BYOS pattern recommended for Azure Services. | | [`@azure-tools/typespec-azure-core/casing-style`](../rules/casing-style.md) | Ensure proper casing style. | | [`@azure-tools/typespec-azure-core/composition-over-inheritance`](../rules/composition-over-inheritance.md) | Check that if a model is used in an operation and has derived models that it has a discriminator or recommend to use composition via spread or `is`. | +| [`@azure-tools/typespec-azure-core/use-enum-instead-of-boolean`](../rules/use-enum-instead-of-boolean.md) | Boolean properties should use descriptive extensible enums when semantic values matter. | | [`@azure-tools/typespec-azure-core/known-encoding`](../rules/known-encoding.md) | Check for supported encodings. | | [`@azure-tools/typespec-azure-core/long-running-polling-operation-required`](../rules/long-running-polling-operation-required.md) | Long-running operations should have a linked polling operation. | | [`@azure-tools/typespec-azure-core/no-case-mismatch`](../rules/no-case-mismatch.md) | Validate that no two types have the same name with different casing. |