From 93f02d76d1d9760c2b13d5b8d670d3315c41eb1b Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Tue, 9 Dec 2025 13:58:56 +0100 Subject: [PATCH 1/2] chore: add eslint rule `no-all-string-literal-unions` to disallow all string literal unions (#5653) * chore: add eslint rule `no-all-string-literal-unions` to disallow all string literal unions * chore: update README to include `no-all-string-literal-unions` rule description --- specification/eslint.config.js | 2 + validator/README.md | 21 ++-- validator/eslint-plugin-es-spec.js | 2 + .../rules/no-all-string-literal-unions.js | 65 +++++++++++++ .../test/no-all-string-literal-unions.js | 95 +++++++++++++++++++ 5 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 validator/rules/no-all-string-literal-unions.js create mode 100644 validator/test/no-all-string-literal-unions.js diff --git a/specification/eslint.config.js b/specification/eslint.config.js index 46ced2e9b8..4f936c266b 100644 --- a/specification/eslint.config.js +++ b/specification/eslint.config.js @@ -95,6 +95,8 @@ export default defineConfig({ } } ], + 'es-spec-validator/no-all-string-literal-unions': 'error' + ], 'es-spec-validator/jsdoc-endpoint-check': [ 'error', { diff --git a/validator/README.md b/validator/README.md index e59076fe94..cd7bed5213 100644 --- a/validator/README.md +++ b/validator/README.md @@ -5,19 +5,20 @@ It is configured [in the specification directory](../specification/eslint.config ## Rules -| Name | Description | -|---------------------------------------| - | -| `single-key-dictionary-key-is-string` | `SingleKeyDictionary` keys must be strings. | -| `dictionary-key-is-string` | `Dictionary` keys must be strings. | -| `no-native-types` | TypeScript native utility types (`Record`, `Partial`, etc.) and collection types (`Map`, `Set`, etc.) are not allowed. Use spec-defined aliases like `Dictionary` instead. | -| `invalid-node-types` | The spec uses a subset of TypeScript, so some types, clauses and expressions are not allowed. | -| `no-generic-number` | Generic `number` type is not allowed outside of `_types/Numeric.ts`. Use concrete numeric types like `integer`, `long`, `float`, `double`, etc. | -| `request-must-have-urls` | All Request interfaces extending `RequestBase` must have a `urls` property defining their endpoint paths and HTTP methods. | -| `no-variants-on-responses` | `@variants` is only supported on Interface types, not on Request or Response classes. Use value_body pattern with `@codegen_name` instead. | -| `no-inline-unions` | Inline union types (e.g., `field: A \| B`) are not allowed in properties/fields. Define a named type alias instead to improve code generation for statically-typed languages. | +| Name | Description | +|---------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `single-key-dictionary-key-is-string` | `SingleKeyDictionary` keys must be strings. | +| `dictionary-key-is-string` | `Dictionary` keys must be strings. | +| `no-native-types` | TypeScript native utility types (`Record`, `Partial`, etc.) and collection types (`Map`, `Set`, etc.) are not allowed. Use spec-defined aliases like `Dictionary` instead. | +| `invalid-node-types` | The spec uses a subset of TypeScript, so some types, clauses and expressions are not allowed. | +| `no-generic-number` | Generic `number` type is not allowed outside of `_types/Numeric.ts`. Use concrete numeric types like `integer`, `long`, `float`, `double`, etc. | +| `request-must-have-urls` | All Request interfaces extending `RequestBase` must have a `urls` property defining their endpoint paths and HTTP methods. | +| `no-variants-on-responses` | `@variants` is only supported on Interface types, not on Request or Response classes. Use value_body pattern with `@codegen_name` instead. | +| `no-inline-unions` | Inline union types (e.g., `field: A \| B`) are not allowed in properties/fields. Define a named type alias instead to improve code generation for statically-typed languages. | | `prefer-tagged-variants` | Union of class types should use tagged variants (`@variants internal` or `@variants container`) instead of inline unions for better deserialization support in statically-typed languages. | | `no-duplicate-type-names` | All types must be unique across class and enum definitions. | | `jsdoc-endpoint-check` | Validates JSDoc on endpoints in the specification. Ensuring consistent formatting. Some errors can be fixed with `--fix`. | +| `no-all-string-literal-unions | Unions consisting entirely of string literals (e.g., `"green" \| "yellow" \| "red"`) are not allowed, use enums instead. | | ## Usage diff --git a/validator/eslint-plugin-es-spec.js b/validator/eslint-plugin-es-spec.js index 1552943128..64f3a58f7d 100644 --- a/validator/eslint-plugin-es-spec.js +++ b/validator/eslint-plugin-es-spec.js @@ -26,6 +26,7 @@ import noVariantsOnResponses from './rules/no-variants-on-responses.js' import noInlineUnions from './rules/no-inline-unions.js' import preferTaggedVariants from './rules/prefer-tagged-variants.js' import noDuplicateTypeNames from './rules/no-duplicate-type-names.js' +import noAllStringLiteralUnions from './rules/no-all-string-literal-unions.js' import jsdocEndpointCheck from './rules/jsdoc-endpoint-check.js' export default { @@ -39,6 +40,7 @@ export default { 'no-variants-on-responses': noVariantsOnResponses, 'no-inline-unions': noInlineUnions, 'prefer-tagged-variants': preferTaggedVariants, + 'no-all-string-literal-unions': noAllStringLiteralUnions 'no-duplicate-type-names': noDuplicateTypeNames, 'jsdoc-endpoint-check': jsdocEndpointCheck } diff --git a/validator/rules/no-all-string-literal-unions.js b/validator/rules/no-all-string-literal-unions.js new file mode 100644 index 0000000000..2581ac7266 --- /dev/null +++ b/validator/rules/no-all-string-literal-unions.js @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { ESLintUtils } from '@typescript-eslint/utils' +import ts from 'typescript' + +export const noAllStringLiteralUnions = ESLintUtils.RuleCreator.withoutDocs({ + name: 'no-all-string-literal-unions', + meta: { + type: 'problem', + docs: { + description: 'Disallow all string literal unions', + recommended: 'error' + }, + messages: { + noAllStringLiteralUnions: + 'All string literal unions are not allowed. Use an enum of string literals instead (e.g., export enum MyEnum { A = "a", B = "b" })' + }, + schema: [] + }, + defaultOptions: [], + create(context) { + const services = ESLintUtils.getParserServices(context) + + function isStringLiteralType(type) { + if (type.type !== 'TSLiteralType') { + return false + } + + const tsNode = services.esTreeNodeToTSNodeMap.get(type) + return tsNode.literal.kind === ts.SyntaxKind.StringLiteral + } + + return { + TSUnionType(node) { + const allMembersAreStringLiterals = + node.types.every(isStringLiteralType) + + if (allMembersAreStringLiterals) { + context.report({ + node, + messageId: 'noAllStringLiteralUnions' + }) + } + } + } + } +}) + +export default noAllStringLiteralUnions diff --git a/validator/test/no-all-string-literal-unions.js b/validator/test/no-all-string-literal-unions.js new file mode 100644 index 0000000000..8d585055d7 --- /dev/null +++ b/validator/test/no-all-string-literal-unions.js @@ -0,0 +1,95 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { RuleTester } from '@typescript-eslint/rule-tester' +import noAllStringLiteralUnions from '../rules/no-all-string-literal-unions.js' + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: ['*.ts*'] + }, + tsconfigRootDir: import.meta.dirname + } + } +}) + +const rule = noAllStringLiteralUnions + +ruleTester.run('no-all-string-literal-unions', rule, { + valid: [ + { + name: 'enum', + code: `enum MyEnum { foo, bar, baz } + type MyDict = Dictionary` + }, + { + name: 'type', + code: `type MyType = "foo" | int` + }, + { + name: 'single string literal (not a union)', + code: `type SingleValue = "foo"` + }, + { + name: 'union with null/undefined', + code: `type MaybeString = "active" | null` + }, + { + name: 'union with number', + code: `type StringOrNumber = "default" | number` + }, + { + name: 'number literal unions (should only catch string literals)', + code: `type NumericUnion = 1 | 2 | 3` + }, + { + name: 'union with type reference', + code: `type MyType = string; type Mixed = "literal" | MyType` + } + ], + invalid: [ + { + name: 'all string literal union', + code: `type MyType = "foo" | "bar" | "baz"`, + errors: [{ messageId: 'noAllStringLiteralUnions' }] + }, + { + name: 'interface with string literal union', + code: `export interface MyInterface { + some?: "foo" | "bar" | "baz" + other?: 'foo' | 'bar' | 'baz' + }`, + errors: [ + { messageId: 'noAllStringLiteralUnions' }, + { messageId: 'noAllStringLiteralUnions' } + ] + }, + { + name: 'function with string literal union', + code: `function getStatus(): "pending" | "complete" { return "pending" }`, + errors: [{ messageId: 'noAllStringLiteralUnions' }] + }, + { + name: 'class with string literal union', + code: `class Config { status: "active" | "inactive" }`, + errors: [{ messageId: 'noAllStringLiteralUnions' }] + } + ] +}) From defca3a13fa5d2420523b509aa922d5cf81e1554 Mon Sep 17 00:00:00 2001 From: Laura Trotta Date: Tue, 9 Dec 2025 13:59:32 +0100 Subject: [PATCH 2/2] fix merge --- specification/eslint.config.js | 3 +-- validator/eslint-plugin-es-spec.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/specification/eslint.config.js b/specification/eslint.config.js index 4f936c266b..36aa810de6 100644 --- a/specification/eslint.config.js +++ b/specification/eslint.config.js @@ -95,8 +95,7 @@ export default defineConfig({ } } ], - 'es-spec-validator/no-all-string-literal-unions': 'error' - ], + 'es-spec-validator/no-all-string-literal-unions': 'error', 'es-spec-validator/jsdoc-endpoint-check': [ 'error', { diff --git a/validator/eslint-plugin-es-spec.js b/validator/eslint-plugin-es-spec.js index 64f3a58f7d..b89f617f80 100644 --- a/validator/eslint-plugin-es-spec.js +++ b/validator/eslint-plugin-es-spec.js @@ -40,7 +40,7 @@ export default { 'no-variants-on-responses': noVariantsOnResponses, 'no-inline-unions': noInlineUnions, 'prefer-tagged-variants': preferTaggedVariants, - 'no-all-string-literal-unions': noAllStringLiteralUnions + 'no-all-string-literal-unions': noAllStringLiteralUnions, 'no-duplicate-type-names': noDuplicateTypeNames, 'jsdoc-endpoint-check': jsdocEndpointCheck }