Skip to content

Commit 5c04070

Browse files
committed
union rules
1 parent cbedec9 commit 5c04070

File tree

7 files changed

+359
-1
lines changed

7 files changed

+359
-1
lines changed

specification/eslint.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export default defineConfig({
3434
'es-spec-validator/single-key-dictionary-key-is-string': 'error',
3535
'es-spec-validator/dictionary-key-is-string': 'error',
3636
'es-spec-validator/no-native-types': 'error',
37-
'es-spec-validator/invalid-node-types': 'error'
37+
'es-spec-validator/invalid-node-types': 'error',
38+
'es-spec-validator/no-inline-unions': 'error',
39+
'es-spec-validator/prefer-tagged-variants': 'error'
3840
}
3941
})

validator/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ It is configured [in the specification directory](../specification/eslint.config
1111
| `dictionary-key-is-string` | `Dictionary` keys must be strings. |
1212
| `no-native-types` | `Typescript native types not allowed, use aliases. |
1313
| `invalid-node-types` | The spec uses a subset of TypeScript, so some types, clauses and expressions are not allowed. |
14+
| `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. |
15+
| `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. |
1416

1517
## Usage
1618

validator/eslint-plugin-es-spec.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ import singleKeyDict from './rules/single-key-dictionary-key-is-string.js'
2020
import dict from './rules/dictionary-key-is-string.js'
2121
import noNativeTypes from './rules/no-native-types.js'
2222
import invalidNodeTypes from './rules/invalid-node-types.js'
23+
import noInlineUnions from './rules/no-inline-unions.js'
24+
import preferTaggedVariants from './rules/prefer-tagged-variants.js'
2325

2426
export default {
2527
rules: {
2628
'single-key-dictionary-key-is-string': singleKeyDict,
2729
'dictionary-key-is-string': dict,
2830
'no-native-types': noNativeTypes,
2931
'invalid-node-types': invalidNodeTypes,
32+
'no-inline-unions': noInlineUnions,
33+
'prefer-tagged-variants': preferTaggedVariants,
3034
}
3135
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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+
21+
const createRule = ESLintUtils.RuleCreator(name => `https://example.com/rule/${name}`)
22+
23+
export default createRule({
24+
name: 'no-inline-unions',
25+
create(context) {
26+
return {
27+
TSUnionType(node) {
28+
const parent = node.parent;
29+
30+
const isPropertyAnnotation =
31+
parent?.type === 'TSTypeAnnotation' &&
32+
parent.parent?.type === 'PropertyDefinition';
33+
34+
const isInterfaceProperty =
35+
parent?.type === 'TSTypeAnnotation' &&
36+
parent.parent?.type === 'TSPropertySignature';
37+
38+
if (isPropertyAnnotation || isInterfaceProperty) {
39+
context.report({
40+
node,
41+
messageId: 'noInlineUnion',
42+
data: {
43+
suggestion: 'Define a named type alias (e.g., "export type MyUnion = A | B") and use that type instead'
44+
}
45+
})
46+
}
47+
},
48+
}
49+
},
50+
meta: {
51+
docs: {
52+
description: 'Inline union types are not allowed in properties/fields. Use a named type alias instead to improve code generation for statically-typed languages.',
53+
},
54+
messages: {
55+
noInlineUnion: 'Inline union types are not allowed. {{suggestion}}.'
56+
},
57+
type: 'problem',
58+
schema: []
59+
},
60+
defaultOptions: []
61+
})
62+
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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, TSESTree } from '@typescript-eslint/utils';
20+
21+
const createRule = ESLintUtils.RuleCreator(name => `https://example.com/rule/${name}`)
22+
23+
/**
24+
* Heuristic to identify class types by checking if the type name is PascalCase
25+
* and not a known primitive or utility type.
26+
*/
27+
function isLikelyClassType(typeNode) {
28+
if (typeNode.type === 'TSTypeReference' && typeNode.typeName.type === 'Identifier') {
29+
const name = typeNode.typeName.name;
30+
31+
const knownNonClassTypes = [
32+
'string', 'number', 'boolean', 'null', 'undefined',
33+
'integer', 'long', 'short', 'byte', 'float', 'double', 'uint', 'ulong',
34+
'Array', 'Dictionary', 'SingleKeyDictionary', 'UserDefinedValue',
35+
'Field', 'Id', 'IndexName', 'Name'
36+
];
37+
38+
if (knownNonClassTypes.includes(name)) {
39+
return false;
40+
}
41+
42+
const hasUpperStart = /^[A-Z]/.test(name);
43+
const hasLowercase = /[a-z]/.test(name);
44+
45+
return hasUpperStart && hasLowercase;
46+
}
47+
48+
return false;
49+
}
50+
51+
export default createRule({
52+
name: 'prefer-tagged-variants',
53+
create(context) {
54+
return {
55+
TSUnionType(node) {
56+
const parent = node.parent;
57+
58+
const isPropertyAnnotation =
59+
parent?.type === 'TSTypeAnnotation' &&
60+
parent.parent?.type === 'PropertyDefinition';
61+
62+
const isInterfaceProperty =
63+
parent?.type === 'TSTypeAnnotation' &&
64+
parent.parent?.type === 'TSPropertySignature';
65+
66+
if (!isPropertyAnnotation && !isInterfaceProperty) {
67+
return;
68+
}
69+
70+
const allMembersAreClasses = node.types.every(typeNode => {
71+
if (typeNode.type === 'TSArrayType') {
72+
return isLikelyClassType(typeNode.elementType);
73+
}
74+
return isLikelyClassType(typeNode);
75+
});
76+
77+
if (allMembersAreClasses && node.types.length >= 2) {
78+
context.report({
79+
node,
80+
messageId: 'preferTaggedVariants',
81+
data: {
82+
suggestion: 'Use tagged variants with @variants internal or @variants container (external). See modeling guide: https://github.com/elastic/elasticsearch-specification/blob/main/docs/modeling-guide.md#variants'
83+
}
84+
})
85+
}
86+
},
87+
}
88+
},
89+
meta: {
90+
docs: {
91+
description: 'Union of class types should use tagged variants (internal or external) instead of inline unions for better code generation in statically-typed languages.',
92+
},
93+
messages: {
94+
preferTaggedVariants: 'Union of class types is not allowed. {{suggestion}}.'
95+
},
96+
type: 'problem',
97+
schema: []
98+
},
99+
defaultOptions: []
100+
})
101+
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 rule from '../rules/no-inline-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+
ruleTester.run('no-inline-unions', rule, {
34+
valid: [
35+
`export type MyUnion = string | number`,
36+
`export type Result = Success | Error`,
37+
`type Status = 'active' | 'inactive' | 'pending'`,
38+
`type InputType = string | string[]
39+
class MyClass {
40+
input: InputType
41+
}`,
42+
`type QueryOrArray = QueryContainer | QueryContainer[]
43+
interface MyInterface {
44+
filter?: QueryOrArray
45+
}`,
46+
`class MyClass {
47+
name: string
48+
count: integer
49+
items: string[]
50+
}`,
51+
`type Item = string | number
52+
class MyClass {
53+
items: Array<Item>
54+
}`,
55+
],
56+
invalid: [
57+
{
58+
code: `class MyClass {
59+
filter: QueryContainer | QueryContainer[]
60+
}`,
61+
errors: [{ messageId: 'noInlineUnion' }]
62+
},
63+
{
64+
code: `interface MyInterface {
65+
input: string | string[]
66+
}`,
67+
errors: [{ messageId: 'noInlineUnion' }]
68+
},
69+
{
70+
code: `export class Request {
71+
value: string | number | boolean
72+
}`,
73+
errors: [{ messageId: 'noInlineUnion' }]
74+
},
75+
{
76+
code: `class Container {
77+
content: Success | Error | Pending
78+
}`,
79+
errors: [{ messageId: 'noInlineUnion' }]
80+
},
81+
{
82+
code: `interface Config {
83+
seed?: long | string
84+
}`,
85+
errors: [{ messageId: 'noInlineUnion' }]
86+
},
87+
{
88+
code: `class MyClass {
89+
data: SamplingConfiguration | null
90+
}`,
91+
errors: [{ messageId: 'noInlineUnion' }]
92+
},
93+
],
94+
})
95+
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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 rule from '../rules/prefer-tagged-variants.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+
ruleTester.run('prefer-tagged-variants', rule, {
34+
valid: [
35+
`export type QueryType = BoolQuery | TermQuery | RangeQuery`,
36+
`class MyClass {
37+
seed: long | string
38+
}`,
39+
`interface Config {
40+
value: string | number
41+
}`,
42+
`class Container {
43+
query: BoolQuery
44+
}`,
45+
`class MyClass {
46+
items: QueryContainer[]
47+
}`,
48+
`type QueryOrArray = QueryContainer | QueryContainer[]
49+
class MyClass {
50+
filter: QueryOrArray
51+
}`,
52+
`class MyClass {
53+
config: SamplingConfiguration | null
54+
}`,
55+
`interface Status {
56+
state: 'active' | 'inactive' | 'pending'
57+
}`,
58+
],
59+
invalid: [
60+
{
61+
code: `class Container {
62+
query: BoolQuery | TermQuery | RangeQuery
63+
}`,
64+
errors: [{ messageId: 'preferTaggedVariants' }]
65+
},
66+
{
67+
code: `interface Response {
68+
result: SuccessResult | ErrorResult
69+
}`,
70+
errors: [{ messageId: 'preferTaggedVariants' }]
71+
},
72+
{
73+
code: `class MyClass {
74+
content: TextContent | ImageContent | VideoContent
75+
}`,
76+
errors: [{ messageId: 'preferTaggedVariants' }]
77+
},
78+
{
79+
code: `interface Config {
80+
processor?: SetProcessor | RemoveProcessor | AppendProcessor
81+
}`,
82+
errors: [{ messageId: 'preferTaggedVariants' }]
83+
},
84+
{
85+
code: `export class SearchRequest {
86+
query: MatchQuery | TermQuery | BoolQuery | RangeQuery
87+
}`,
88+
errors: [{ messageId: 'preferTaggedVariants' }]
89+
},
90+
],
91+
})
92+

0 commit comments

Comments
 (0)