eslint-plugin-class-validator-type-match 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  declare const _default: {
2
2
  rules: {
3
- 'decorator-type-match': import("@typescript-eslint/utils/dist/ts-eslint").RuleModule<"mismatch" | "nestedArrayMismatch" | "missingValidateNested" | "enumMismatch" | "typeMismatch" | "missingEachOption" | "unnecessaryValidateNested", [], unknown, import("@typescript-eslint/utils/dist/ts-eslint").RuleListener>;
3
+ 'decorator-type-match': import("@typescript-eslint/utils/dist/ts-eslint").RuleModule<"mismatch" | "nestedArrayMismatch" | "missingValidateNested" | "enumMismatch" | "typeMismatch" | "missingEachOption" | "unnecessaryValidateNested" | "invalidEachOption" | "missingTypeDecorator" | "tupleValidationWarning", [], unknown, import("@typescript-eslint/utils/dist/ts-eslint").RuleListener>;
4
4
  'optional-decorator-match': import("@typescript-eslint/utils/dist/ts-eslint").RuleModule<"missingOptionalDecorator" | "missingOptionalSyntax" | "conflictingDefiniteAssignment" | "undefinedUnionWithoutDecorator" | "undefinedUnionWithoutOptional" | "nullUnionIncorrect" | "redundantUndefinedInType", [{
5
5
  strictNullChecks?: boolean;
6
6
  checkDefaultValues?: boolean;
@@ -1,5 +1,5 @@
1
1
  import { ESLintUtils } from '@typescript-eslint/utils';
2
- type MessageIds = 'mismatch' | 'nestedArrayMismatch' | 'missingValidateNested' | 'enumMismatch' | 'typeMismatch' | 'missingEachOption' | 'unnecessaryValidateNested';
2
+ type MessageIds = 'mismatch' | 'nestedArrayMismatch' | 'missingValidateNested' | 'enumMismatch' | 'typeMismatch' | 'missingEachOption' | 'unnecessaryValidateNested' | 'invalidEachOption' | 'missingTypeDecorator' | 'tupleValidationWarning';
3
3
  /**
4
4
  * ESLint rule to ensure class-validator decorators match TypeScript type annotations.
5
5
  *
@@ -10,167 +10,25 @@ type MessageIds = 'mismatch' | 'nestedArrayMismatch' | 'missingValidateNested' |
10
10
  * - Arrays of objects requiring @ValidateNested({ each: true })
11
11
  * - Nested objects requiring @ValidateNested()
12
12
  * - Nullable complex types (Address | null, Address | undefined)
13
- * - Arrays of nullable complex types ((Address | null)[])
13
+ * - Nested arrays (Address[][])
14
14
  * - Type literals
15
15
  * - class-transformer decorators
16
16
  * - Enum types (both TypeScript enums and union types)
17
17
  * - Literal types (e.g., "admin", 25)
18
18
  * - Intersection types (e.g., Profile & Settings)
19
+ * - Branded types (string & { __brand: 'UserId' })
19
20
  * - @Type(() => ClassName) decorator matching
20
21
  * - Readonly arrays (readonly T[])
21
22
  * - Tuple types ([T, U])
22
23
  * - Nullable unions (T | null | undefined)
23
24
  * - Unnecessary @ValidateNested on primitive arrays
24
- *
25
- * @example
26
- * // Bad - will trigger error
27
- * class User {
28
- * @IsString()
29
- * name!: number;
30
- * }
31
- *
32
- * @example
33
- * // ✅ Good - types match
34
- * class User {
35
- * @IsString()
36
- * name!: string;
37
- * }
38
- *
39
- * @example
40
- * // ❌ Bad - @Type doesn't match TypeScript type
41
- * class User {
42
- * @ValidateNested()
43
- * @Type(() => Address)
44
- * address: string;
45
- * }
46
- *
47
- * @example
48
- * // ✅ Good - @Type matches TypeScript type
49
- * class User {
50
- * @ValidateNested()
51
- * @Type(() => Address)
52
- * address: Address;
53
- * }
54
- *
55
- * @example
56
- * // ❌ Bad - array of objects without @ValidateNested
57
- * class User {
58
- * @IsArray()
59
- * addresses!: Address[];
60
- * }
61
- *
62
- * @example
63
- * // ✅ Good - array of objects with @ValidateNested
64
- * class User {
65
- * @IsArray()
66
- * @ValidateNested({ each: true })
67
- * @Type(() => Address)
68
- * addresses!: Address[];
69
- * }
70
- *
71
- * @example
72
- * // ✅ Good - array of primitives without @ValidateNested
73
- * class User {
74
- * @IsArray()
75
- * @IsString({ each: true })
76
- * tags!: string[];
77
- * }
78
- *
79
- * @example
80
- * // ❌ Bad - array of primitives with unnecessary @ValidateNested
81
- * class User {
82
- * @IsArray()
83
- * @ValidateNested({ each: true })
84
- * @IsString({ each: true })
85
- * tags!: string[];
86
- * }
87
- *
88
- * @example
89
- * // ❌ Bad - complex type without @ValidateNested
90
- * class User {
91
- * @IsDefined()
92
- * profile!: Profile;
93
- * }
94
- *
95
- * @example
96
- * // ✅ Good - complex type with @ValidateNested
97
- * class User {
98
- * @ValidateNested()
99
- * @Type(() => Profile)
100
- * profile!: Profile;
101
- * }
102
- *
103
- * @example
104
- * // ❌ Bad - intersection type without @ValidateNested
105
- * class User {
106
- * @IsDefined()
107
- * data!: Profile & Settings;
108
- * }
109
- *
110
- * @example
111
- * // ✅ Good - intersection type with @ValidateNested
112
- * class User {
113
- * @ValidateNested()
114
- * data!: Profile & Settings;
115
- * }
116
- *
117
- * @example
118
- * // ✅ Good - literal type
119
- * class User {
120
- * @IsString()
121
- * role!: "admin";
122
- * }
123
- *
124
- * @example
125
- * // ❌ Bad - @IsEnum with wrong enum type
126
- * class User {
127
- * @IsEnum(UserRole)
128
- * status!: UserStatus;
129
- * }
130
- *
131
- * @example
132
- * // ✅ Good - @IsEnum matches enum type
133
- * class User {
134
- * @IsEnum(UserStatus)
135
- * status!: UserStatus;
136
- * }
137
- *
138
- * @example
139
- * // ✅ Good - union type with @IsEnum
140
- * class User {
141
- * @IsEnum({ ACTIVE: 'active', INACTIVE: 'inactive' })
142
- * status!: 'active' | 'inactive';
143
- * }
144
- *
145
- * @example
146
- * // ❌ Bad - enum type used with wrong decorator
147
- * class User {
148
- * @IsString()
149
- * status!: UserStatus; // Should use @IsEnum, will report type mismatch
150
- * }
151
- *
152
- * @example
153
- * // ✅ Good - nullable union with @IsOptional
154
- * class User {
155
- * @IsOptional()
156
- * @IsString()
157
- * name!: string | null;
158
- * }
159
- *
160
- * @example
161
- * // ✅ Good - readonly array
162
- * class User {
163
- * @IsArray()
164
- * @ValidateNested({ each: true })
165
- * addresses!: readonly Address[];
166
- * }
167
- *
168
- * @example
169
- * // ✅ Good - tuple type
170
- * class User {
171
- * @IsArray()
172
- * coords!: [number, number];
173
- * }
25
+ * - Decorators with { each: true } option for array element validation
26
+ * - Template literal types (`user-${string}`)
27
+ * - Namespace/qualified type references (MyNamespace.MyEnum)
28
+ * - Invalid { each: true } on non-array types
29
+ * - Missing @Type decorator when @ValidateNested is present
30
+ * - Type aliases (via TypeScript type checker)
31
+ * - Record<K, V> utility types with primitive values
174
32
  */
175
33
  declare const _default: ESLintUtils.RuleModule<MessageIds, [], unknown, ESLintUtils.RuleListener>;
176
34
  export default _default;
@@ -27,43 +27,6 @@ const TYPE_AGNOSTIC_DECORATORS = new Set([
27
27
  /**
28
28
  * Mapping of class-validator decorators to their expected TypeScript types.
29
29
  * Only includes decorators that enforce specific types.
30
- *
31
- * @example
32
- * // ✅ Good - nullable complex type with @ValidateNested
33
- * class User {
34
- * @IsOptional()
35
- * @ValidateNested()
36
- * @Type(() => Address)
37
- * address?: Address | null;
38
- * }
39
- *
40
- * @example
41
- * // ❌ Bad - nullable complex type without @ValidateNested
42
- * class User {
43
- * @IsOptional()
44
- * @Type(() => Address)
45
- * address?: Address | null;
46
- * }
47
- *
48
- * @example
49
- * // ✅ Good - array of nullable complex types
50
- * class User {
51
- * @IsArray()
52
- * @ValidateNested({ each: true })
53
- * @Type(() => Address)
54
- * addresses!: (Address | null)[];
55
- * }
56
- *
57
- * @example
58
- * // ❌ Bad - array of nullable complex types without @ValidateNested
59
- * class User {
60
- * @IsArray()
61
- * @Type(() => Address)
62
- * addresses!: (Address | null)[];
63
- * }
64
- *
65
- * @example
66
- * IsString: ["string"] - expects string type
67
30
  */
68
31
  const decoratorTypeMap = {
69
32
  // String validators
@@ -216,12 +179,83 @@ function isNullableUnion(typeNode) {
216
179
  }
217
180
  return { isNullable: false, baseType: null };
218
181
  }
182
+ /**
183
+ * Extracts the name from a TSQualifiedName or Identifier
184
+ */
185
+ function getTypeReferenceName(typeName) {
186
+ if (typeName.type === 'Identifier') {
187
+ return typeName.name;
188
+ }
189
+ // Handle TSQualifiedName (e.g., MyNamespace.MyEnum)
190
+ if (typeName.type === 'TSQualifiedName') {
191
+ const parts = [];
192
+ let current = typeName;
193
+ while (current.type === 'TSQualifiedName') {
194
+ if (current.right.type === 'Identifier') {
195
+ parts.unshift(current.right.name);
196
+ }
197
+ current = current.left;
198
+ }
199
+ if (current.type === 'Identifier') {
200
+ parts.unshift(current.name);
201
+ }
202
+ return parts.join('.');
203
+ }
204
+ return '';
205
+ }
206
+ /**
207
+ * Uses TypeScript's type checker to resolve the actual type, handling type aliases.
208
+ * Returns the resolved type string, or null if it cannot be determined.
209
+ */
210
+ function resolveTypeWithChecker(typeNode, checker, esTreeNodeMap) {
211
+ if (!checker || !esTreeNodeMap) {
212
+ return null;
213
+ }
214
+ const tsNode = esTreeNodeMap.get(typeNode);
215
+ if (!tsNode) {
216
+ return null;
217
+ }
218
+ try {
219
+ const type = checker.getTypeAtLocation(tsNode);
220
+ // Check for primitive types using TypeScript's type flags
221
+ if (type.flags & (1 << 2))
222
+ return 'string'; // ts.TypeFlags.String
223
+ if (type.flags & (1 << 3))
224
+ return 'number'; // ts.TypeFlags.Number
225
+ if (type.flags & (1 << 4))
226
+ return 'boolean'; // ts.TypeFlags.Boolean
227
+ // Check if it's an array type
228
+ if (checker.isArrayType(type)) {
229
+ return 'array';
230
+ }
231
+ // Check if it's a tuple type
232
+ // TypeScript's ObjectType has objectFlags property, but it's not exposed in the public types
233
+ if ('objectFlags' in type && type.objectFlags & 8) {
234
+ // ts.ObjectFlags.Tuple = 8
235
+ return 'array';
236
+ }
237
+ // For other types, fall back to AST analysis
238
+ return null;
239
+ }
240
+ catch {
241
+ return null;
242
+ }
243
+ }
219
244
  /**
220
245
  * Converts a TypeScript type node to its string representation for validation purposes.
221
246
  * Handles primitives, arrays, tuples, type references, literals, unions, and intersections.
222
247
  * Returns null for types that cannot be validated.
248
+ *
249
+ * Can optionally use TypeScript's type checker to resolve type aliases.
223
250
  */
224
- function getTypeString(typeNode) {
251
+ function getTypeString(typeNode, checker = null, esTreeNodeMap = null) {
252
+ // Try to resolve with type checker first (handles type aliases)
253
+ if (checker && esTreeNodeMap) {
254
+ const resolved = resolveTypeWithChecker(typeNode, checker, esTreeNodeMap);
255
+ if (resolved) {
256
+ return resolved;
257
+ }
258
+ }
225
259
  const unwrapped = unwrapReadonlyOperator(typeNode);
226
260
  switch (unwrapped.type) {
227
261
  case 'TSStringKeyword':
@@ -234,11 +268,10 @@ function getTypeString(typeNode) {
234
268
  return 'array';
235
269
  case 'TSTupleType':
236
270
  return 'array';
271
+ case 'TSTemplateLiteralType':
272
+ return 'string';
237
273
  case 'TSTypeReference':
238
- if (unwrapped.typeName.type === 'Identifier') {
239
- return unwrapped.typeName.name;
240
- }
241
- break;
274
+ return getTypeReferenceName(unwrapped.typeName);
242
275
  case 'TSTypeLiteral':
243
276
  return 'object';
244
277
  case 'TSUnionType':
@@ -254,6 +287,13 @@ function getTypeString(typeNode) {
254
287
  }
255
288
  return 'literal';
256
289
  case 'TSIntersectionType':
290
+ // Check if intersection contains a primitive type (branded types)
291
+ for (const type of unwrapped.types) {
292
+ const typeStr = getTypeString(type, checker, esTreeNodeMap);
293
+ if (typeStr === 'string' || typeStr === 'number' || typeStr === 'boolean') {
294
+ return typeStr;
295
+ }
296
+ }
257
297
  return 'intersection';
258
298
  }
259
299
  return null;
@@ -261,7 +301,7 @@ function getTypeString(typeNode) {
261
301
  /**
262
302
  * Extracts the element type from an array type annotation.
263
303
  * Supports T[] and Array<T> syntax.
264
- * Returns null for tuple types since they don't have a single element type.
304
+ * For tuple types, returns null since they require per-element validation.
265
305
  */
266
306
  function getArrayElementTypeNode(typeAnnotation) {
267
307
  const unwrapped = unwrapReadonlyOperator(typeAnnotation);
@@ -276,12 +316,28 @@ function getArrayElementTypeNode(typeAnnotation) {
276
316
  unwrapped.typeArguments?.params[0]) {
277
317
  return unwrapped.typeArguments.params[0];
278
318
  }
279
- // Tuples don't have a single element type to validate
319
+ // Tuples require special handling
280
320
  if (unwrapped.type === 'TSTupleType') {
281
321
  return null;
282
322
  }
283
323
  return null;
284
324
  }
325
+ /**
326
+ * Gets all element types from a tuple type.
327
+ */
328
+ function getTupleElementTypes(typeAnnotation) {
329
+ const unwrapped = unwrapReadonlyOperator(typeAnnotation);
330
+ if (unwrapped.type === 'TSTupleType') {
331
+ return unwrapped.elementTypes.map((element) => {
332
+ // Handle named tuple elements: [name: Type]
333
+ if (element.type === 'TSNamedTupleMember') {
334
+ return element.elementType;
335
+ }
336
+ return element;
337
+ });
338
+ }
339
+ return [];
340
+ }
285
341
  /**
286
342
  * Checks if a type is a union of literal types (e.g., 'active' | 'inactive').
287
343
  * These are typically used for enum-like type definitions.
@@ -292,13 +348,38 @@ function isUnionOfLiterals(typeNode) {
292
348
  }
293
349
  return false;
294
350
  }
351
+ /**
352
+ * Checks if a type reference is a Record utility type with a primitive value type.
353
+ * Record<string, number> = not complex
354
+ * Record<string, Address> = complex
355
+ */
356
+ function isRecordWithPrimitiveValue(typeNode, checker, esTreeNodeMap) {
357
+ const unwrapped = unwrapReadonlyOperator(typeNode);
358
+ if (unwrapped.type !== 'TSTypeReference') {
359
+ return false;
360
+ }
361
+ const typeName = getTypeReferenceName(unwrapped.typeName);
362
+ if (typeName !== 'Record') {
363
+ return false;
364
+ }
365
+ // Check if it has type arguments
366
+ if (!unwrapped.typeArguments || unwrapped.typeArguments.params.length < 2) {
367
+ return false;
368
+ }
369
+ // Get the value type (second type argument)
370
+ const valueType = unwrapped.typeArguments.params[1];
371
+ const valueTypeStr = getTypeString(valueType, checker, esTreeNodeMap);
372
+ // If value type is a primitive, Record is not complex
373
+ return valueTypeStr === 'string' || valueTypeStr === 'number' || valueTypeStr === 'boolean';
374
+ }
295
375
  /**
296
376
  * Determines if a type requires @ValidateNested decorator for proper validation.
297
377
  * Complex types include objects, class instances, type literals, and intersections.
298
- * Built-in types with complex generic parameters are also considered complex.
378
+ * Arrays of complex types are also considered complex.
379
+ * Built-in types with complex generic parameters are checked selectively.
299
380
  * Union types are complex if any non-null/undefined member is complex.
300
381
  */
301
- function isComplexType(typeNode) {
382
+ function isComplexType(typeNode, checker = null, esTreeNodeMap = null) {
302
383
  const unwrapped = unwrapReadonlyOperator(typeNode);
303
384
  // Type literals are always complex
304
385
  if (unwrapped.type === 'TSTypeLiteral') {
@@ -309,7 +390,6 @@ function isComplexType(typeNode) {
309
390
  return false;
310
391
  }
311
392
  // Union types are complex if any non-null/undefined member is complex
312
- // This handles cases like: Address | null, Address | undefined, Address | Profile
313
393
  if (unwrapped.type === 'TSUnionType') {
314
394
  return unwrapped.types.some((type) => {
315
395
  // Skip null and undefined - they don't affect complexity
@@ -317,29 +397,53 @@ function isComplexType(typeNode) {
317
397
  return false;
318
398
  }
319
399
  // Recursively check if this union member is complex
320
- return isComplexType(type);
400
+ return isComplexType(type, checker, esTreeNodeMap);
321
401
  });
322
402
  }
323
- // Intersection types are complex
403
+ // Intersection types: check if any member is a primitive (branded types)
324
404
  if (unwrapped.type === 'TSIntersectionType') {
405
+ // If intersection contains a primitive, treat as primitive (branded type)
406
+ for (const type of unwrapped.types) {
407
+ const typeStr = getTypeString(type, checker, esTreeNodeMap);
408
+ if (typeStr === 'string' || typeStr === 'number' || typeStr === 'boolean') {
409
+ return false;
410
+ }
411
+ }
412
+ // Otherwise, it's a complex intersection
325
413
  return true;
326
414
  }
415
+ // Arrays are complex if their elements are complex
416
+ if (unwrapped.type === 'TSArrayType') {
417
+ return isComplexType(unwrapped.elementType, checker, esTreeNodeMap);
418
+ }
419
+ // Tuples are complex if any element is complex
420
+ if (unwrapped.type === 'TSTupleType') {
421
+ const elements = getTupleElementTypes(unwrapped);
422
+ return elements.some((element) => isComplexType(element, checker, esTreeNodeMap));
423
+ }
327
424
  // Check for type references (class names, interfaces, etc.)
328
- if (unwrapped.type === 'TSTypeReference' && unwrapped.typeName.type === 'Identifier') {
329
- const typeName = unwrapped.typeName.name;
425
+ if (unwrapped.type === 'TSTypeReference') {
426
+ const typeName = getTypeReferenceName(unwrapped.typeName);
330
427
  // Common built-in types that don't require @ValidateNested
331
- // This list focuses on types commonly used in validation contexts
332
- const builtInTypes = ['String', 'Number', 'Boolean', 'Date', 'Array', 'Promise', 'Map', 'Set'];
333
- // Built-in types with complex generic parameters are considered complex
334
- if (builtInTypes.includes(typeName) && unwrapped.typeArguments?.params) {
335
- for (const param of unwrapped.typeArguments.params) {
336
- if (isComplexType(param)) {
337
- return true;
338
- }
339
- }
428
+ const builtInTypes = ['String', 'Number', 'Boolean', 'Date'];
429
+ // Types that can't be validated even with generic parameters
430
+ const nonValidatableTypes = ['Promise', 'Map', 'Set'];
431
+ if (nonValidatableTypes.includes(typeName)) {
340
432
  return false;
341
433
  }
342
- return !builtInTypes.includes(typeName);
434
+ // Record<K, V> with primitive V is not complex
435
+ if (isRecordWithPrimitiveValue(unwrapped, checker, esTreeNodeMap)) {
436
+ return false;
437
+ }
438
+ // Array<T> needs to check the element type
439
+ if (typeName === 'Array' && unwrapped.typeArguments?.params[0]) {
440
+ return isComplexType(unwrapped.typeArguments.params[0], checker, esTreeNodeMap);
441
+ }
442
+ // Other built-in types are not complex
443
+ if (builtInTypes.includes(typeName)) {
444
+ return false;
445
+ }
446
+ return true;
343
447
  }
344
448
  return false;
345
449
  }
@@ -362,6 +466,13 @@ function isArrayType(typeAnnotation) {
362
466
  }
363
467
  return false;
364
468
  }
469
+ /**
470
+ * Checks if a type is a tuple type.
471
+ */
472
+ function isTupleType(typeAnnotation) {
473
+ const unwrapped = unwrapReadonlyOperator(typeAnnotation);
474
+ return unwrapped.type === 'TSTupleType';
475
+ }
365
476
  /**
366
477
  * Checks if a type is a union of literals, used for enum-like type definitions.
367
478
  */
@@ -422,35 +533,56 @@ function getTypeDecoratorClassName(decorator) {
422
533
  return null;
423
534
  }
424
535
  /**
425
- * Checks if @ValidateNested decorator includes the { each: true } option.
536
+ * Checks if a decorator includes the { each: true } option.
426
537
  * Returns false for decorators without parentheses or without the each option.
538
+ * This works for any decorator, not just @ValidateNested.
539
+ *
540
+ * Handles both single and two-parameter decorator signatures:
541
+ * - @IsString({ each: true })
542
+ * - @IsNumber({}, { each: true })
543
+ * - @Min(0, { each: true })
427
544
  */
428
- function hasValidateNestedEachOption(decorator) {
429
- // If decorator is used without parentheses (e.g., @ValidateNested), it has no options
545
+ function hasEachOption(decorator) {
546
+ // If decorator is used without parentheses (e.g., @IsString), it has no options
430
547
  if (decorator.expression.type === 'Identifier') {
431
548
  return false;
432
549
  }
433
- if (decorator.expression.type === 'CallExpression' && decorator.expression.arguments.length > 0) {
434
- const firstArg = decorator.expression.arguments[0];
435
- if (firstArg.type === 'ObjectExpression') {
436
- return firstArg.properties.some((prop) => {
437
- if (prop.type === 'Property' &&
438
- prop.key.type === 'Identifier' &&
439
- prop.key.name === 'each' &&
440
- prop.value.type === 'Literal') {
441
- return prop.value.value === true;
442
- }
443
- return false;
444
- });
550
+ if (decorator.expression.type === 'CallExpression') {
551
+ const args = decorator.expression.arguments;
552
+ // Check both first and second arguments for { each: true }
553
+ // Some decorators have validation options as first param: @IsString({ each: true })
554
+ // Others have it as second param: @Min(0, { each: true })
555
+ for (let i = 0; i < Math.min(args.length, 2); i++) {
556
+ const arg = args[i];
557
+ if (arg.type === 'ObjectExpression') {
558
+ const hasEach = arg.properties.some((prop) => {
559
+ if (prop.type === 'Property' &&
560
+ prop.key.type === 'Identifier' &&
561
+ prop.key.name === 'each' &&
562
+ prop.value.type === 'Literal') {
563
+ return prop.value.value === true;
564
+ }
565
+ return false;
566
+ });
567
+ if (hasEach)
568
+ return true;
569
+ }
445
570
  }
446
571
  }
447
572
  return false;
448
573
  }
574
+ /**
575
+ * Checks if @ValidateNested decorator includes the { each: true } option.
576
+ * Returns false for decorators without parentheses or without the each option.
577
+ */
578
+ function hasValidateNestedEachOption(decorator) {
579
+ return hasEachOption(decorator);
580
+ }
449
581
  /**
450
582
  * Validates if a decorator matches the TypeScript type annotation.
451
583
  * Handles special cases like @IsEnum validation and nullable unions.
452
584
  */
453
- function checkTypeMatch(decorator, typeAnnotation, actualType) {
585
+ function checkTypeMatch(decorator, typeAnnotation, actualType, checker, esTreeNodeMap) {
454
586
  const expectedTypes = decoratorTypeMap[decorator];
455
587
  // Skip decorators not in our map
456
588
  if (!expectedTypes || expectedTypes.length === 0) {
@@ -464,7 +596,7 @@ function checkTypeMatch(decorator, typeAnnotation, actualType) {
464
596
  if (isUnionEnumType(typeAnnotation)) {
465
597
  return expectedTypes.includes('union-literal');
466
598
  }
467
- if (typeAnnotation.type === 'TSTypeReference' && typeAnnotation.typeName.type === 'Identifier') {
599
+ if (typeAnnotation.type === 'TSTypeReference') {
468
600
  return expectedTypes.includes('type-reference');
469
601
  }
470
602
  return false;
@@ -472,7 +604,7 @@ function checkTypeMatch(decorator, typeAnnotation, actualType) {
472
604
  // For nullable unions (T | null | undefined), validate against the base type
473
605
  const nullableCheck = isNullableUnion(typeAnnotation);
474
606
  if (nullableCheck.isNullable && nullableCheck.baseType) {
475
- const baseTypeString = getTypeString(nullableCheck.baseType);
607
+ const baseTypeString = getTypeString(nullableCheck.baseType, checker, esTreeNodeMap);
476
608
  if (baseTypeString) {
477
609
  return expectedTypes.some((expected) => {
478
610
  if (expected === 'array' && baseTypeString === 'Array')
@@ -502,174 +634,32 @@ function checkTypeMatch(decorator, typeAnnotation, actualType) {
502
634
  * - Arrays of objects requiring @ValidateNested({ each: true })
503
635
  * - Nested objects requiring @ValidateNested()
504
636
  * - Nullable complex types (Address | null, Address | undefined)
505
- * - Arrays of nullable complex types ((Address | null)[])
637
+ * - Nested arrays (Address[][])
506
638
  * - Type literals
507
639
  * - class-transformer decorators
508
640
  * - Enum types (both TypeScript enums and union types)
509
641
  * - Literal types (e.g., "admin", 25)
510
642
  * - Intersection types (e.g., Profile & Settings)
643
+ * - Branded types (string & { __brand: 'UserId' })
511
644
  * - @Type(() => ClassName) decorator matching
512
645
  * - Readonly arrays (readonly T[])
513
646
  * - Tuple types ([T, U])
514
647
  * - Nullable unions (T | null | undefined)
515
648
  * - Unnecessary @ValidateNested on primitive arrays
516
- *
517
- * @example
518
- * // Bad - will trigger error
519
- * class User {
520
- * @IsString()
521
- * name!: number;
522
- * }
523
- *
524
- * @example
525
- * // ✅ Good - types match
526
- * class User {
527
- * @IsString()
528
- * name!: string;
529
- * }
530
- *
531
- * @example
532
- * // ❌ Bad - @Type doesn't match TypeScript type
533
- * class User {
534
- * @ValidateNested()
535
- * @Type(() => Address)
536
- * address: string;
537
- * }
538
- *
539
- * @example
540
- * // ✅ Good - @Type matches TypeScript type
541
- * class User {
542
- * @ValidateNested()
543
- * @Type(() => Address)
544
- * address: Address;
545
- * }
546
- *
547
- * @example
548
- * // ❌ Bad - array of objects without @ValidateNested
549
- * class User {
550
- * @IsArray()
551
- * addresses!: Address[];
552
- * }
553
- *
554
- * @example
555
- * // ✅ Good - array of objects with @ValidateNested
556
- * class User {
557
- * @IsArray()
558
- * @ValidateNested({ each: true })
559
- * @Type(() => Address)
560
- * addresses!: Address[];
561
- * }
562
- *
563
- * @example
564
- * // ✅ Good - array of primitives without @ValidateNested
565
- * class User {
566
- * @IsArray()
567
- * @IsString({ each: true })
568
- * tags!: string[];
569
- * }
570
- *
571
- * @example
572
- * // ❌ Bad - array of primitives with unnecessary @ValidateNested
573
- * class User {
574
- * @IsArray()
575
- * @ValidateNested({ each: true })
576
- * @IsString({ each: true })
577
- * tags!: string[];
578
- * }
579
- *
580
- * @example
581
- * // ❌ Bad - complex type without @ValidateNested
582
- * class User {
583
- * @IsDefined()
584
- * profile!: Profile;
585
- * }
586
- *
587
- * @example
588
- * // ✅ Good - complex type with @ValidateNested
589
- * class User {
590
- * @ValidateNested()
591
- * @Type(() => Profile)
592
- * profile!: Profile;
593
- * }
594
- *
595
- * @example
596
- * // ❌ Bad - intersection type without @ValidateNested
597
- * class User {
598
- * @IsDefined()
599
- * data!: Profile & Settings;
600
- * }
601
- *
602
- * @example
603
- * // ✅ Good - intersection type with @ValidateNested
604
- * class User {
605
- * @ValidateNested()
606
- * data!: Profile & Settings;
607
- * }
608
- *
609
- * @example
610
- * // ✅ Good - literal type
611
- * class User {
612
- * @IsString()
613
- * role!: "admin";
614
- * }
615
- *
616
- * @example
617
- * // ❌ Bad - @IsEnum with wrong enum type
618
- * class User {
619
- * @IsEnum(UserRole)
620
- * status!: UserStatus;
621
- * }
622
- *
623
- * @example
624
- * // ✅ Good - @IsEnum matches enum type
625
- * class User {
626
- * @IsEnum(UserStatus)
627
- * status!: UserStatus;
628
- * }
629
- *
630
- * @example
631
- * // ✅ Good - union type with @IsEnum
632
- * class User {
633
- * @IsEnum({ ACTIVE: 'active', INACTIVE: 'inactive' })
634
- * status!: 'active' | 'inactive';
635
- * }
636
- *
637
- * @example
638
- * // ❌ Bad - enum type used with wrong decorator
639
- * class User {
640
- * @IsString()
641
- * status!: UserStatus; // Should use @IsEnum, will report type mismatch
642
- * }
643
- *
644
- * @example
645
- * // ✅ Good - nullable union with @IsOptional
646
- * class User {
647
- * @IsOptional()
648
- * @IsString()
649
- * name!: string | null;
650
- * }
651
- *
652
- * @example
653
- * // ✅ Good - readonly array
654
- * class User {
655
- * @IsArray()
656
- * @ValidateNested({ each: true })
657
- * addresses!: readonly Address[];
658
- * }
659
- *
660
- * @example
661
- * // ✅ Good - tuple type
662
- * class User {
663
- * @IsArray()
664
- * coords!: [number, number];
665
- * }
649
+ * - Decorators with { each: true } option for array element validation
650
+ * - Template literal types (`user-${string}`)
651
+ * - Namespace/qualified type references (MyNamespace.MyEnum)
652
+ * - Invalid { each: true } on non-array types
653
+ * - Missing @Type decorator when @ValidateNested is present
654
+ * - Type aliases (via TypeScript type checker)
655
+ * - Record<K, V> utility types with primitive values
666
656
  */
667
657
  exports.default = createRule({
668
658
  name: 'decorator-type-match',
669
659
  meta: {
670
660
  type: 'problem',
671
661
  docs: {
672
- description: 'Ensure class-validator decorators match TypeScript type annotations, including arrays of objects, nested objects, enum types, literal types, intersection types, readonly arrays, tuple types, nullable unions, and @Type decorator matching',
662
+ description: 'Ensure class-validator decorators match TypeScript type annotations, including arrays of objects, nested objects, enum types, literal types, intersection types, readonly arrays, tuple types, nullable unions, @Type decorator matching, { each: true } option handling, template literals, namespace references, type aliases, branded types, and Record utility types',
673
663
  },
674
664
  messages: {
675
665
  mismatch: 'Decorator @{{decorator}} does not match type annotation {{actualType}}. Expected: {{expectedTypes}}',
@@ -679,11 +669,27 @@ exports.default = createRule({
679
669
  typeMismatch: '@Type(() => {{typeDecoratorClass}}) does not match type annotation {{actualType}}.',
680
670
  missingEachOption: 'Array of complex types requires @ValidateNested({ each: true }), but only @ValidateNested() was found.',
681
671
  unnecessaryValidateNested: 'Array of primitive type {{elementType}} does not need @ValidateNested(). Remove @ValidateNested() or use @{{decorator}}({ each: true }) instead.',
672
+ invalidEachOption: 'Decorator @{{decorator}} has { each: true } option but property type is not an array. Remove { each: true } or change type to an array.',
673
+ missingTypeDecorator: 'Complex type {{actualType}} with @ValidateNested() requires @Type(() => {{className}}) decorator for proper transformation.',
674
+ tupleValidationWarning: 'Tuple type contains complex elements. Consider using a regular array with @ValidateNested({ each: true }) or validate elements individually.',
682
675
  },
683
676
  schema: [],
684
677
  },
685
678
  defaultOptions: [],
686
679
  create(context) {
680
+ // Get TypeScript type checker if available
681
+ let checker = null;
682
+ let esTreeNodeMap = null;
683
+ try {
684
+ const parserServices = context.parserServices;
685
+ if (parserServices?.program && parserServices?.esTreeNodeToTSNodeMap) {
686
+ checker = parserServices.program.getTypeChecker();
687
+ esTreeNodeMap = parserServices.esTreeNodeToTSNodeMap;
688
+ }
689
+ }
690
+ catch {
691
+ // Type checker not available, continue with AST-only analysis
692
+ }
687
693
  return {
688
694
  /**
689
695
  * Analyzes class property definitions to validate decorator and type annotation matching
@@ -716,28 +722,31 @@ exports.default = createRule({
716
722
  /**
717
723
  * Determine the actual TypeScript type from the annotation.
718
724
  * Supports primitive types, arrays, type references, literals, and intersections.
725
+ * Uses TypeScript's type checker when available to resolve type aliases.
719
726
  */
720
- actualType = getTypeString(typeAnnotation);
727
+ actualType = getTypeString(typeAnnotation, checker, esTreeNodeMap);
721
728
  // Skip if we couldn't determine the type
722
729
  if (!actualType)
723
730
  return;
724
731
  const hasValidateNested = decorators.includes('ValidateNested');
725
732
  const hasIsEnum = decorators.includes('IsEnum');
733
+ const hasTypeDecorator = decorators.includes('Type');
726
734
  // Validate @IsEnum argument matches the type annotation for enum type references
727
- if (hasIsEnum && typeAnnotation.type === 'TSTypeReference' && typeAnnotation.typeName.type === 'Identifier') {
735
+ if (hasIsEnum && typeAnnotation.type === 'TSTypeReference') {
728
736
  const isEnumDecorator = node.decorators?.find((d) => d.expression.type === 'CallExpression' &&
729
737
  d.expression.callee.type === 'Identifier' &&
730
738
  d.expression.callee.name === 'IsEnum');
731
739
  if (isEnumDecorator) {
732
740
  const enumArg = getIsEnumArgument(isEnumDecorator);
741
+ const typeName = getTypeReferenceName(typeAnnotation.typeName);
733
742
  // For TypeScript enum references, the argument should match the type
734
- if (enumArg && enumArg !== typeAnnotation.typeName.name) {
743
+ if (enumArg && enumArg !== typeName) {
735
744
  context.report({
736
745
  node,
737
746
  messageId: 'enumMismatch',
738
747
  data: {
739
748
  enumArg,
740
- actualType: typeAnnotation.typeName.name,
749
+ actualType: typeName,
741
750
  },
742
751
  });
743
752
  }
@@ -751,8 +760,8 @@ exports.default = createRule({
751
760
  const typeClassName = getTypeDecoratorClassName(typeDecorator);
752
761
  // For non-array types, check if @Type matches the TypeScript type
753
762
  if (typeClassName && !isArrayType(typeAnnotation)) {
754
- if (typeAnnotation.type === 'TSTypeReference' && typeAnnotation.typeName.type === 'Identifier') {
755
- const tsTypeName = typeAnnotation.typeName.name;
763
+ if (typeAnnotation.type === 'TSTypeReference') {
764
+ const tsTypeName = getTypeReferenceName(typeAnnotation.typeName);
756
765
  if (typeClassName !== tsTypeName) {
757
766
  context.report({
758
767
  node,
@@ -764,7 +773,7 @@ exports.default = createRule({
764
773
  });
765
774
  }
766
775
  }
767
- else if (typeAnnotation.type !== 'TSTypeReference') {
776
+ else {
768
777
  // @Type is used with a primitive type
769
778
  context.report({
770
779
  node,
@@ -780,8 +789,8 @@ exports.default = createRule({
780
789
  if (typeClassName && isArrayType(typeAnnotation)) {
781
790
  const elementTypeNode = getArrayElementTypeNode(typeAnnotation);
782
791
  if (elementTypeNode) {
783
- if (elementTypeNode.type === 'TSTypeReference' && elementTypeNode.typeName.type === 'Identifier') {
784
- const elementTypeName = elementTypeNode.typeName.name;
792
+ if (elementTypeNode.type === 'TSTypeReference') {
793
+ const elementTypeName = getTypeReferenceName(elementTypeNode.typeName);
785
794
  if (typeClassName !== elementTypeName) {
786
795
  context.report({
787
796
  node,
@@ -795,7 +804,7 @@ exports.default = createRule({
795
804
  }
796
805
  else {
797
806
  // @Type is used with an array of primitives
798
- const elementType = getTypeString(elementTypeNode);
807
+ const elementType = getTypeString(elementTypeNode, checker, esTreeNodeMap);
799
808
  if (elementType) {
800
809
  context.report({
801
810
  node,
@@ -810,13 +819,24 @@ exports.default = createRule({
810
819
  }
811
820
  }
812
821
  }
822
+ // Special handling for tuple types
823
+ if (isTupleType(typeAnnotation)) {
824
+ const elements = getTupleElementTypes(typeAnnotation);
825
+ const hasComplexElements = elements.some((element) => isComplexType(element, checker, esTreeNodeMap));
826
+ if (hasComplexElements) {
827
+ context.report({
828
+ node,
829
+ messageId: 'tupleValidationWarning',
830
+ data: {},
831
+ });
832
+ }
833
+ }
813
834
  // Validate arrays of complex types have proper nested validation
814
- if (isArrayType(typeAnnotation)) {
835
+ if (isArrayType(typeAnnotation) && !isTupleType(typeAnnotation)) {
815
836
  const elementTypeNode = getArrayElementTypeNode(typeAnnotation);
816
- // Tuples return null for elementTypeNode, skip nested validation check
817
837
  if (elementTypeNode) {
818
- const elementTypeName = getTypeString(elementTypeNode);
819
- const isElementComplex = isComplexType(elementTypeNode);
838
+ const elementTypeName = getTypeString(elementTypeNode, checker, esTreeNodeMap);
839
+ const isElementComplex = isComplexType(elementTypeNode, checker, esTreeNodeMap);
820
840
  if (isElementComplex && elementTypeName) {
821
841
  // Complex element type - requires @ValidateNested({ each: true })
822
842
  if (!hasValidateNested) {
@@ -848,6 +868,26 @@ exports.default = createRule({
848
868
  data: {},
849
869
  });
850
870
  }
871
+ // Check if @Type decorator is present for complex element types
872
+ if (!hasTypeDecorator) {
873
+ let elementTypeToCheck = elementTypeNode;
874
+ // Handle nullable element types: (Address | null)[]
875
+ const nullableCheck = isNullableUnion(elementTypeNode);
876
+ if (nullableCheck.isNullable && nullableCheck.baseType) {
877
+ elementTypeToCheck = nullableCheck.baseType;
878
+ }
879
+ if (elementTypeToCheck.type === 'TSTypeReference') {
880
+ const className = getTypeReferenceName(elementTypeToCheck.typeName);
881
+ context.report({
882
+ node,
883
+ messageId: 'missingTypeDecorator',
884
+ data: {
885
+ actualType: `${className}[]`,
886
+ className,
887
+ },
888
+ });
889
+ }
890
+ }
851
891
  }
852
892
  }
853
893
  else if (!isElementComplex && hasValidateNested && elementTypeName) {
@@ -872,7 +912,43 @@ exports.default = createRule({
872
912
  // Skip type-agnostic decorators
873
913
  if (TYPE_AGNOSTIC_DECORATORS.has(decorator))
874
914
  continue;
875
- const matches = checkTypeMatch(decorator, typeAnnotation, actualType);
915
+ // Get the decorator node to check for { each: true }
916
+ const decoratorNode = node.decorators?.find((d) => {
917
+ if (d.expression.type === 'CallExpression' && d.expression.callee.type === 'Identifier') {
918
+ return d.expression.callee.name === decorator;
919
+ }
920
+ if (d.expression.type === 'Identifier') {
921
+ return d.expression.name === decorator;
922
+ }
923
+ return false;
924
+ });
925
+ // Check if this decorator has { each: true } option
926
+ const hasEach = decoratorNode ? hasEachOption(decoratorNode) : false;
927
+ // Validate { each: true } is only used with arrays
928
+ if (hasEach && !isArrayType(typeAnnotation)) {
929
+ context.report({
930
+ node,
931
+ messageId: 'invalidEachOption',
932
+ data: {
933
+ decorator,
934
+ },
935
+ });
936
+ continue;
937
+ }
938
+ let typeToCheck = actualType;
939
+ let typeNodeToCheck = typeAnnotation;
940
+ // If decorator has { each: true }, validate against array element type
941
+ if (hasEach && isArrayType(typeAnnotation)) {
942
+ const elementTypeNode = getArrayElementTypeNode(typeAnnotation);
943
+ if (elementTypeNode) {
944
+ const elementType = getTypeString(elementTypeNode, checker, esTreeNodeMap);
945
+ if (elementType) {
946
+ typeToCheck = elementType;
947
+ typeNodeToCheck = elementTypeNode;
948
+ }
949
+ }
950
+ }
951
+ const matches = checkTypeMatch(decorator, typeNodeToCheck, typeToCheck, checker, esTreeNodeMap);
876
952
  // Report mismatch
877
953
  if (!matches) {
878
954
  const expectedTypes = decoratorTypeMap[decorator];
@@ -881,7 +957,7 @@ exports.default = createRule({
881
957
  messageId: 'mismatch',
882
958
  data: {
883
959
  decorator,
884
- actualType,
960
+ actualType: typeToCheck,
885
961
  expectedTypes: expectedTypes?.join(' or ') || 'unknown',
886
962
  },
887
963
  });
@@ -889,7 +965,9 @@ exports.default = createRule({
889
965
  }
890
966
  // Validate complex object types have @ValidateNested decorator
891
967
  const hasTypedDecorator = decorators.some((d) => !TYPE_AGNOSTIC_DECORATORS.has(d) && decoratorTypeMap[d]);
892
- if (!isArrayType(typeAnnotation) && isComplexType(typeAnnotation) && !hasTypedDecorator) {
968
+ if (!isArrayType(typeAnnotation) &&
969
+ isComplexType(typeAnnotation, checker, esTreeNodeMap) &&
970
+ !hasTypedDecorator) {
893
971
  // Complex types should have @ValidateNested()
894
972
  if (!hasValidateNested && decorators.length > 0) {
895
973
  context.report({
@@ -901,6 +979,29 @@ exports.default = createRule({
901
979
  });
902
980
  }
903
981
  }
982
+ // Check if complex non-array types with @ValidateNested also have @Type
983
+ if (!isArrayType(typeAnnotation) &&
984
+ isComplexType(typeAnnotation, checker, esTreeNodeMap) &&
985
+ hasValidateNested &&
986
+ !hasTypeDecorator) {
987
+ let typeToCheck = typeAnnotation;
988
+ // Handle nullable complex types: Address | null | undefined
989
+ const nullableCheck = isNullableUnion(typeAnnotation);
990
+ if (nullableCheck.isNullable && nullableCheck.baseType) {
991
+ typeToCheck = nullableCheck.baseType;
992
+ }
993
+ if (typeToCheck.type === 'TSTypeReference') {
994
+ const className = getTypeReferenceName(typeToCheck.typeName);
995
+ context.report({
996
+ node,
997
+ messageId: 'missingTypeDecorator',
998
+ data: {
999
+ actualType: className,
1000
+ className,
1001
+ },
1002
+ });
1003
+ }
1004
+ }
904
1005
  },
905
1006
  };
906
1007
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-class-validator-type-match",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "ESLint plugin to ensure class-validator decorators match TypeScript type annotations",
5
5
  "keywords": [
6
6
  "eslint",