Skip to content

Commit 0c9fc32

Browse files
committed
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
1 parent df8c7d1 commit 0c9fc32

File tree

5 files changed

+175
-10
lines changed

5 files changed

+175
-10
lines changed

specification/eslint.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ export default defineConfig({
9595
}
9696
}
9797
],
98+
'es-spec-validator/no-all-string-literal-unions': 'error'
99+
],
98100
'es-spec-validator/jsdoc-endpoint-check': [
99101
'error',
100102
{

validator/README.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@ It is configured [in the specification directory](../specification/eslint.config
55

66
## Rules
77

8-
| Name | Description |
9-
|---------------------------------------| - |
10-
| `single-key-dictionary-key-is-string` | `SingleKeyDictionary` keys must be strings. |
11-
| `dictionary-key-is-string` | `Dictionary` keys must be strings. |
12-
| `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. |
13-
| `invalid-node-types` | The spec uses a subset of TypeScript, so some types, clauses and expressions are not allowed. |
14-
| `no-generic-number` | Generic `number` type is not allowed outside of `_types/Numeric.ts`. Use concrete numeric types like `integer`, `long`, `float`, `double`, etc. |
15-
| `request-must-have-urls` | All Request interfaces extending `RequestBase` must have a `urls` property defining their endpoint paths and HTTP methods. |
16-
| `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. |
17-
| `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. |
8+
| Name | Description |
9+
|---------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
10+
| `single-key-dictionary-key-is-string` | `SingleKeyDictionary` keys must be strings. |
11+
| `dictionary-key-is-string` | `Dictionary` keys must be strings. |
12+
| `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. |
13+
| `invalid-node-types` | The spec uses a subset of TypeScript, so some types, clauses and expressions are not allowed. |
14+
| `no-generic-number` | Generic `number` type is not allowed outside of `_types/Numeric.ts`. Use concrete numeric types like `integer`, `long`, `float`, `double`, etc. |
15+
| `request-must-have-urls` | All Request interfaces extending `RequestBase` must have a `urls` property defining their endpoint paths and HTTP methods. |
16+
| `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. |
17+
| `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. |
1818
| `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. |
1919
| `no-duplicate-type-names` | All types must be unique across class and enum definitions. |
2020
| `jsdoc-endpoint-check` | Validates JSDoc on endpoints in the specification. Ensuring consistent formatting. Some errors can be fixed with `--fix`. |
21+
| `no-all-string-literal-unions | Unions consisting entirely of string literals (e.g., `"green" \| "yellow" \| "red"`) are not allowed, use enums instead. | |
2122

2223
## Usage
2324

validator/eslint-plugin-es-spec.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import noVariantsOnResponses from './rules/no-variants-on-responses.js'
2626
import noInlineUnions from './rules/no-inline-unions.js'
2727
import preferTaggedVariants from './rules/prefer-tagged-variants.js'
2828
import noDuplicateTypeNames from './rules/no-duplicate-type-names.js'
29+
import noAllStringLiteralUnions from './rules/no-all-string-literal-unions.js'
2930
import jsdocEndpointCheck from './rules/jsdoc-endpoint-check.js'
3031

3132
export default {
@@ -39,6 +40,7 @@ export default {
3940
'no-variants-on-responses': noVariantsOnResponses,
4041
'no-inline-unions': noInlineUnions,
4142
'prefer-tagged-variants': preferTaggedVariants,
43+
'no-all-string-literal-unions': noAllStringLiteralUnions
4244
'no-duplicate-type-names': noDuplicateTypeNames,
4345
'jsdoc-endpoint-check': jsdocEndpointCheck
4446
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import { ESLintUtils } from '@typescript-eslint/utils'
20+
import ts from 'typescript'
21+
22+
export const noAllStringLiteralUnions = ESLintUtils.RuleCreator.withoutDocs({
23+
name: 'no-all-string-literal-unions',
24+
meta: {
25+
type: 'problem',
26+
docs: {
27+
description: 'Disallow all string literal unions',
28+
recommended: 'error'
29+
},
30+
messages: {
31+
noAllStringLiteralUnions:
32+
'All string literal unions are not allowed. Use an enum of string literals instead (e.g., export enum MyEnum { A = "a", B = "b" })'
33+
},
34+
schema: []
35+
},
36+
defaultOptions: [],
37+
create(context) {
38+
const services = ESLintUtils.getParserServices(context)
39+
40+
function isStringLiteralType(type) {
41+
if (type.type !== 'TSLiteralType') {
42+
return false
43+
}
44+
45+
const tsNode = services.esTreeNodeToTSNodeMap.get(type)
46+
return tsNode.literal.kind === ts.SyntaxKind.StringLiteral
47+
}
48+
49+
return {
50+
TSUnionType(node) {
51+
const allMembersAreStringLiterals =
52+
node.types.every(isStringLiteralType)
53+
54+
if (allMembersAreStringLiterals) {
55+
context.report({
56+
node,
57+
messageId: 'noAllStringLiteralUnions'
58+
})
59+
}
60+
}
61+
}
62+
}
63+
})
64+
65+
export default noAllStringLiteralUnions
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import { RuleTester } from '@typescript-eslint/rule-tester'
20+
import noAllStringLiteralUnions from '../rules/no-all-string-literal-unions.js'
21+
22+
const ruleTester = new RuleTester({
23+
languageOptions: {
24+
parserOptions: {
25+
projectService: {
26+
allowDefaultProject: ['*.ts*']
27+
},
28+
tsconfigRootDir: import.meta.dirname
29+
}
30+
}
31+
})
32+
33+
const rule = noAllStringLiteralUnions
34+
35+
ruleTester.run('no-all-string-literal-unions', rule, {
36+
valid: [
37+
{
38+
name: 'enum',
39+
code: `enum MyEnum { foo, bar, baz }
40+
type MyDict = Dictionary<MyEnum, object>`
41+
},
42+
{
43+
name: 'type',
44+
code: `type MyType = "foo" | int`
45+
},
46+
{
47+
name: 'single string literal (not a union)',
48+
code: `type SingleValue = "foo"`
49+
},
50+
{
51+
name: 'union with null/undefined',
52+
code: `type MaybeString = "active" | null`
53+
},
54+
{
55+
name: 'union with number',
56+
code: `type StringOrNumber = "default" | number`
57+
},
58+
{
59+
name: 'number literal unions (should only catch string literals)',
60+
code: `type NumericUnion = 1 | 2 | 3`
61+
},
62+
{
63+
name: 'union with type reference',
64+
code: `type MyType = string; type Mixed = "literal" | MyType`
65+
}
66+
],
67+
invalid: [
68+
{
69+
name: 'all string literal union',
70+
code: `type MyType = "foo" | "bar" | "baz"`,
71+
errors: [{ messageId: 'noAllStringLiteralUnions' }]
72+
},
73+
{
74+
name: 'interface with string literal union',
75+
code: `export interface MyInterface {
76+
some?: "foo" | "bar" | "baz"
77+
other?: 'foo' | 'bar' | 'baz'
78+
}`,
79+
errors: [
80+
{ messageId: 'noAllStringLiteralUnions' },
81+
{ messageId: 'noAllStringLiteralUnions' }
82+
]
83+
},
84+
{
85+
name: 'function with string literal union',
86+
code: `function getStatus(): "pending" | "complete" { return "pending" }`,
87+
errors: [{ messageId: 'noAllStringLiteralUnions' }]
88+
},
89+
{
90+
name: 'class with string literal union',
91+
code: `class Config { status: "active" | "inactive" }`,
92+
errors: [{ messageId: 'noAllStringLiteralUnions' }]
93+
}
94+
]
95+
})

0 commit comments

Comments
 (0)