Skip to content

Commit be03e22

Browse files
committed
union rules
1 parent cbedec9 commit be03e22

File tree

7 files changed

+368
-2
lines changed

7 files changed

+368
-2
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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ It is configured [in the specification directory](../specification/eslint.config
99
|---------------------------------------| - |
1010
| `single-key-dictionary-key-is-string` | `SingleKeyDictionary` keys must be strings. |
1111
| `dictionary-key-is-string` | `Dictionary` keys must be strings. |
12-
| `no-native-types` | `Typescript native types not allowed, use aliases. |
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. |
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: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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+
* Check if a type node appears to reference a class type
25+
* This is a heuristic - we check if the type name starts with uppercase
26+
* (common convention for classes) and is not a known primitive/utility type
27+
*/
28+
function isLikelyClassType(typeNode) {
29+
if (typeNode.type === 'TSTypeReference' && typeNode.typeName.type === 'Identifier') {
30+
const name = typeNode.typeName.name;
31+
32+
// Known non-class types
33+
const knownNonClassTypes = [
34+
// Primitives and spec aliases
35+
'string', 'number', 'boolean', 'null', 'undefined',
36+
'integer', 'long', 'short', 'byte', 'float', 'double', 'uint', 'ulong',
37+
// Utility types
38+
'Array', 'Dictionary', 'SingleKeyDictionary', 'UserDefinedValue',
39+
// Common aliases
40+
'Field', 'Id', 'IndexName', 'Name'
41+
];
42+
43+
if (knownNonClassTypes.includes(name)) {
44+
return false;
45+
}
46+
47+
// Heuristic: Class types typically start with uppercase and contain at least one lowercase
48+
// This catches: QueryContainer, BoolQuery, etc.
49+
// But not: URL, ID, HTTP, etc. (all caps - likely aliases)
50+
const hasUpperStart = /^[A-Z]/.test(name);
51+
const hasLowercase = /[a-z]/.test(name);
52+
53+
return hasUpperStart && hasLowercase;
54+
}
55+
56+
return false;
57+
}
58+
59+
export default createRule({
60+
name: 'prefer-tagged-variants',
61+
create(context) {
62+
return {
63+
TSUnionType(node) {
64+
const parent = node.parent;
65+
66+
const isPropertyAnnotation =
67+
parent?.type === 'TSTypeAnnotation' &&
68+
parent.parent?.type === 'PropertyDefinition';
69+
70+
const isInterfaceProperty =
71+
parent?.type === 'TSTypeAnnotation' &&
72+
parent.parent?.type === 'TSPropertySignature';
73+
74+
if (!isPropertyAnnotation && !isInterfaceProperty) {
75+
return;
76+
}
77+
78+
const allMembersAreClasses = node.types.every(typeNode => {
79+
if (typeNode.type === 'TSArrayType') {
80+
return isLikelyClassType(typeNode.elementType);
81+
}
82+
return isLikelyClassType(typeNode);
83+
});
84+
85+
if (allMembersAreClasses && node.types.length >= 2) {
86+
context.report({
87+
node,
88+
messageId: 'preferTaggedVariants',
89+
data: {
90+
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'
91+
}
92+
})
93+
}
94+
},
95+
}
96+
},
97+
meta: {
98+
docs: {
99+
description: 'Union of class types should use tagged variants (internal or external) instead of inline unions for better code generation in statically-typed languages.',
100+
},
101+
messages: {
102+
preferTaggedVariants: 'Union of class types is not allowed. {{suggestion}}.'
103+
},
104+
type: 'problem',
105+
schema: []
106+
},
107+
defaultOptions: []
108+
})
109+
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+

0 commit comments

Comments
 (0)