1
1
import type { TSESTree } from '@typescript-eslint/utils' ;
2
+ import * as tsutils from 'tsutils' ;
2
3
import * as ts from 'typescript' ;
3
4
4
5
import * as util from '../util' ;
5
6
6
7
type Options = [
7
8
{
8
- checkCompoundAssignments ?: boolean ;
9
9
allowAny ?: boolean ;
10
+ allowBoolean ?: boolean ;
11
+ allowNullish ?: boolean ;
12
+ allowNumberAndString ?: boolean ;
13
+ allowRegExp ?: boolean ;
14
+ checkCompoundAssignments ?: boolean ;
10
15
} ,
11
16
] ;
12
- type MessageIds =
13
- | 'notNumbers'
14
- | 'notStrings'
15
- | 'notBigInts'
16
- | 'notValidAnys'
17
- | 'notValidTypes' ;
17
+
18
+ type MessageIds = 'bigintAndNumber' | 'invalid' | 'mismatched' ;
18
19
19
20
export default util . createRule < Options , MessageIds > ( {
20
21
name : 'restrict-plus-operands' ,
@@ -27,166 +28,204 @@ export default util.createRule<Options, MessageIds>({
27
28
requiresTypeChecking : true ,
28
29
} ,
29
30
messages : {
30
- notNumbers :
31
- "Operands of '+' operation must either be both strings or both numbers." ,
32
- notStrings :
33
- "Operands of '+' operation must either be both strings or both numbers. Consider using a template literal." ,
34
- notBigInts : "Operands of '+' operation must be both bigints." ,
35
- notValidAnys :
36
- "Operands of '+' operation with any is possible only with string, number, bigint or any" ,
37
- notValidTypes :
38
- "Operands of '+' operation must either be one of string, number, bigint or any (if allowed by option)" ,
31
+ bigintAndNumber :
32
+ "Numeric '+' operations must either be both bigints or both numbers. Got `{{left}}` + `{{right}}`." ,
33
+ invalid :
34
+ "Invalid operand for a '+' operation. Operands must each be a number or {{stringLike}}. Got `{{type}}`." ,
35
+ mismatched :
36
+ "Operands of '+' operations must be a number or {{stringLike}}. Got `{{left}}` + `{{right}}`." ,
39
37
} ,
40
38
schema : [
41
39
{
42
40
type : 'object' ,
43
41
additionalProperties : false ,
44
42
properties : {
45
- checkCompoundAssignments : {
46
- description : 'Whether to check compound assignments such as `+=`.' ,
47
- type : 'boolean' ,
48
- } ,
49
43
allowAny : {
50
44
description : 'Whether to allow `any` typed values.' ,
51
45
type : 'boolean' ,
52
46
} ,
47
+ allowBoolean : {
48
+ description : 'Whether to allow `boolean` typed values.' ,
49
+ type : 'boolean' ,
50
+ } ,
51
+ allowNullish : {
52
+ description :
53
+ 'Whether to allow potentially `null` or `undefined` typed values.' ,
54
+ type : 'boolean' ,
55
+ } ,
56
+ allowNumberAndString : {
57
+ description :
58
+ 'Whether to allow `bigint`/`number` typed values and `string` typed values to be added together.' ,
59
+ type : 'boolean' ,
60
+ } ,
61
+ allowRegExp : {
62
+ description : 'Whether to allow `regexp` typed values.' ,
63
+ type : 'boolean' ,
64
+ } ,
65
+ checkCompoundAssignments : {
66
+ description : 'Whether to check compound assignments such as `+=`.' ,
67
+ type : 'boolean' ,
68
+ } ,
53
69
} ,
54
70
} ,
55
71
] ,
56
72
} ,
57
73
defaultOptions : [
58
74
{
59
75
checkCompoundAssignments : false ,
60
- allowAny : false ,
61
76
} ,
62
77
] ,
63
- create ( context , [ { checkCompoundAssignments, allowAny } ] ) {
78
+ create (
79
+ context ,
80
+ [
81
+ {
82
+ checkCompoundAssignments,
83
+ allowAny,
84
+ allowBoolean,
85
+ allowNullish,
86
+ allowNumberAndString,
87
+ allowRegExp,
88
+ } ,
89
+ ] ,
90
+ ) {
64
91
const service = util . getParserServices ( context ) ;
65
92
const typeChecker = service . program . getTypeChecker ( ) ;
66
93
67
- type BaseLiteral = 'string' | 'number' | 'bigint' | 'invalid' | 'any' ;
68
-
69
- /**
70
- * Helper function to get base type of node
71
- */
72
- function getBaseTypeOfLiteralType ( type : ts . Type ) : BaseLiteral {
73
- if ( type . isNumberLiteral ( ) ) {
74
- return 'number' ;
75
- }
76
- if (
77
- type . isStringLiteral ( ) ||
78
- util . isTypeFlagSet ( type , ts . TypeFlags . TemplateLiteral )
79
- ) {
80
- return 'string' ;
81
- }
82
- // is BigIntLiteral
83
- if ( type . flags & ts . TypeFlags . BigIntLiteral ) {
84
- return 'bigint' ;
85
- }
86
- if ( type . isUnion ( ) ) {
87
- const types = type . types . map ( getBaseTypeOfLiteralType ) ;
88
-
89
- return types . every ( value => value === types [ 0 ] ) ? types [ 0 ] : 'invalid' ;
90
- }
91
-
92
- if ( type . isIntersection ( ) ) {
93
- const types = type . types . map ( getBaseTypeOfLiteralType ) ;
94
-
95
- if ( types . some ( value => value === 'string' ) ) {
96
- return 'string' ;
97
- }
98
-
99
- if ( types . some ( value => value === 'number' ) ) {
100
- return 'number' ;
101
- }
102
-
103
- if ( types . some ( value => value === 'bigint' ) ) {
104
- return 'bigint' ;
105
- }
106
-
107
- return 'invalid' ;
108
- }
94
+ const stringLikes = [
95
+ allowAny && '`any`' ,
96
+ allowBoolean && '`boolean`' ,
97
+ allowNullish && '`null`' ,
98
+ allowRegExp && '`RegExp`' ,
99
+ allowNullish && '`undefined`' ,
100
+ ] . filter ( ( value ) : value is string => typeof value === 'string' ) ;
101
+ const stringLike = stringLikes . length
102
+ ? stringLikes . length === 1
103
+ ? `string, allowing a string + ${ stringLikes [ 0 ] } `
104
+ : `string, allowing a string + any of: ${ stringLikes . join ( ', ' ) } `
105
+ : 'string' ;
106
+
107
+ function getTypeConstrained ( node : TSESTree . Node ) : ts . Type {
108
+ return typeChecker . getBaseTypeOfLiteralType (
109
+ util . getConstrainedTypeAtLocation (
110
+ typeChecker ,
111
+ service . esTreeNodeToTSNodeMap . get ( node ) ,
112
+ ) ,
113
+ ) ;
114
+ }
109
115
110
- const stringType = typeChecker . typeToString ( type ) ;
116
+ function checkPlusOperands (
117
+ node : TSESTree . AssignmentExpression | TSESTree . BinaryExpression ,
118
+ ) : void {
119
+ const leftType = getTypeConstrained ( node . left ) ;
120
+ const rightType = getTypeConstrained ( node . right ) ;
111
121
112
122
if (
113
- stringType === 'number' ||
114
- stringType === 'string' ||
115
- stringType === 'bigint' ||
116
- stringType === 'any'
123
+ leftType === rightType &&
124
+ tsutils . isTypeFlagSet (
125
+ leftType ,
126
+ ts . TypeFlags . BigIntLike |
127
+ ts . TypeFlags . NumberLike |
128
+ ts . TypeFlags . StringLike ,
129
+ )
117
130
) {
118
- return stringType ;
131
+ return ;
119
132
}
120
- return 'invalid' ;
121
- }
122
-
123
- /**
124
- * Helper function to get base type of node
125
- * @param node the node to be evaluated.
126
- */
127
- function getNodeType (
128
- node : TSESTree . Expression | TSESTree . PrivateIdentifier ,
129
- ) : BaseLiteral {
130
- const tsNode = service . esTreeNodeToTSNodeMap . get ( node ) ;
131
- const type = util . getConstrainedTypeAtLocation ( typeChecker , tsNode ) ;
132
-
133
- return getBaseTypeOfLiteralType ( type ) ;
134
- }
135
133
136
- function checkPlusOperands (
137
- node : TSESTree . BinaryExpression | TSESTree . AssignmentExpression ,
138
- ) : void {
139
- const leftType = getNodeType ( node . left ) ;
140
- const rightType = getNodeType ( node . right ) ;
141
-
142
- if ( leftType === rightType ) {
143
- if ( leftType === 'invalid' ) {
134
+ let hadIndividualComplaint = false ;
135
+
136
+ for ( const [ baseNode , baseType , otherType ] of [
137
+ [ node . left , leftType , rightType ] ,
138
+ [ node . right , rightType , leftType ] ,
139
+ ] as const ) {
140
+ if (
141
+ isTypeFlagSetInUnion (
142
+ baseType ,
143
+ ts . TypeFlags . ESSymbolLike |
144
+ ts . TypeFlags . Never |
145
+ ts . TypeFlags . Unknown ,
146
+ ) ||
147
+ ( ! allowAny && isTypeFlagSetInUnion ( baseType , ts . TypeFlags . Any ) ) ||
148
+ ( ! allowBoolean &&
149
+ isTypeFlagSetInUnion ( baseType , ts . TypeFlags . BooleanLike ) ) ||
150
+ ( ! allowNullish &&
151
+ util . isTypeFlagSet (
152
+ baseType ,
153
+ ts . TypeFlags . Null | ts . TypeFlags . Undefined ,
154
+ ) )
155
+ ) {
144
156
context . report ( {
145
- node,
146
- messageId : 'notValidTypes' ,
157
+ data : {
158
+ stringLike,
159
+ type : typeChecker . typeToString ( baseType ) ,
160
+ } ,
161
+ messageId : 'invalid' ,
162
+ node : baseNode ,
147
163
} ) ;
164
+ hadIndividualComplaint = true ;
165
+ continue ;
148
166
}
149
167
150
- if ( ! allowAny && leftType === 'any' ) {
151
- context . report ( {
152
- node,
153
- messageId : 'notValidAnys' ,
154
- } ) ;
168
+ // RegExps also contain ts.TypeFlags.Any & ts.TypeFlags.Object
169
+ for ( const subBaseType of tsutils . unionTypeParts ( baseType ) ) {
170
+ const typeName = util . getTypeName ( typeChecker , subBaseType ) ;
171
+ if (
172
+ typeName === 'RegExp'
173
+ ? ! allowRegExp ||
174
+ tsutils . isTypeFlagSet ( otherType , ts . TypeFlags . NumberLike )
175
+ : ( ! allowAny && util . isTypeAnyType ( subBaseType ) ) ||
176
+ isDeeplyObjectType ( subBaseType )
177
+ ) {
178
+ context . report ( {
179
+ data : {
180
+ stringLike,
181
+ type : typeChecker . typeToString ( subBaseType ) ,
182
+ } ,
183
+ messageId : 'invalid' ,
184
+ node : baseNode ,
185
+ } ) ;
186
+ hadIndividualComplaint = true ;
187
+ continue ;
188
+ }
155
189
}
190
+ }
156
191
192
+ if ( hadIndividualComplaint ) {
157
193
return ;
158
194
}
159
195
160
- if ( leftType === 'any' || rightType === 'any' ) {
161
- if ( ! allowAny || leftType === 'invalid' || rightType === 'invalid' ) {
162
- context . report ( {
196
+ for ( const [ baseType , otherType ] of [
197
+ [ leftType , rightType ] ,
198
+ [ rightType , leftType ] ,
199
+ ] as const ) {
200
+ if (
201
+ ! allowNumberAndString &&
202
+ isTypeFlagSetInUnion ( baseType , ts . TypeFlags . StringLike ) &&
203
+ isTypeFlagSetInUnion ( otherType , ts . TypeFlags . NumberLike )
204
+ ) {
205
+ return context . report ( {
206
+ data : {
207
+ stringLike,
208
+ left : typeChecker . typeToString ( leftType ) ,
209
+ right : typeChecker . typeToString ( rightType ) ,
210
+ } ,
211
+ messageId : 'mismatched' ,
163
212
node,
164
- messageId : 'notValidAnys' ,
165
213
} ) ;
166
214
}
167
215
168
- return ;
169
- }
170
-
171
- if ( leftType === 'string' || rightType === 'string' ) {
172
- return context . report ( {
173
- node,
174
- messageId : 'notStrings' ,
175
- } ) ;
176
- }
177
-
178
- if ( leftType === 'bigint' || rightType === 'bigint' ) {
179
- return context . report ( {
180
- node,
181
- messageId : 'notBigInts' ,
182
- } ) ;
183
- }
184
-
185
- if ( leftType === 'number' || rightType === 'number' ) {
186
- return context . report ( {
187
- node,
188
- messageId : 'notNumbers' ,
189
- } ) ;
216
+ if (
217
+ isTypeFlagSetInUnion ( baseType , ts . TypeFlags . NumberLike ) &&
218
+ isTypeFlagSetInUnion ( otherType , ts . TypeFlags . BigIntLike )
219
+ ) {
220
+ return context . report ( {
221
+ data : {
222
+ left : typeChecker . typeToString ( leftType ) ,
223
+ right : typeChecker . typeToString ( rightType ) ,
224
+ } ,
225
+ messageId : 'bigintAndNumber' ,
226
+ node,
227
+ } ) ;
228
+ }
190
229
}
191
230
}
192
231
@@ -200,3 +239,15 @@ export default util.createRule<Options, MessageIds>({
200
239
} ;
201
240
} ,
202
241
} ) ;
242
+
243
+ function isDeeplyObjectType ( type : ts . Type ) : boolean {
244
+ return type . isIntersection ( )
245
+ ? tsutils . intersectionTypeParts ( type ) . every ( tsutils . isObjectType )
246
+ : tsutils . unionTypeParts ( type ) . every ( tsutils . isObjectType ) ;
247
+ }
248
+
249
+ function isTypeFlagSetInUnion ( type : ts . Type , flag : ts . TypeFlags ) : boolean {
250
+ return tsutils
251
+ . unionTypeParts ( type )
252
+ . some ( subType => tsutils . isTypeFlagSet ( subType , flag ) ) ;
253
+ }
0 commit comments