eslint-plugin-class-validator-type-match 1.4.0 → 1.5.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" | "invalidEachOption" | "missingTypeDecorator" | "tupleValidationWarning", [], 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" | "multiTypeUnionWarning" | "mixedComplexityUnionWarning" | "pickOmitWarning", [], 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' | 'invalidEachOption' | 'missingTypeDecorator' | 'tupleValidationWarning';
2
+ type MessageIds = 'mismatch' | 'nestedArrayMismatch' | 'missingValidateNested' | 'enumMismatch' | 'typeMismatch' | 'missingEachOption' | 'unnecessaryValidateNested' | 'invalidEachOption' | 'missingTypeDecorator' | 'tupleValidationWarning' | 'multiTypeUnionWarning' | 'mixedComplexityUnionWarning' | 'pickOmitWarning';
3
3
  /**
4
4
  * ESLint rule to ensure class-validator decorators match TypeScript type annotations.
5
5
  *
@@ -29,6 +29,9 @@ type MessageIds = 'mismatch' | 'nestedArrayMismatch' | 'missingValidateNested' |
29
29
  * - Missing @Type decorator when @ValidateNested is present
30
30
  * - Type aliases (via TypeScript type checker)
31
31
  * - Record<K, V> utility types with primitive values
32
+ * - Utility types: Partial<T>, Required<T>, Pick<T, K>, Omit<T, K>, ReadonlyArray<T>, NonNullable<T>, Extract<T, U>, Exclude<T, U>
33
+ * - Multi-type unions with explicit warnings for complex scenarios
34
+ * - Mixed complexity unions (primitive | complex types)
32
35
  */
33
36
  declare const _default: ESLintUtils.RuleModule<MessageIds, [], unknown, ESLintUtils.RuleListener>;
34
37
  export default _default;
@@ -309,10 +309,10 @@ function getArrayElementTypeNode(typeAnnotation) {
309
309
  if (unwrapped.type === 'TSArrayType') {
310
310
  return unwrapped.elementType;
311
311
  }
312
- // Handle Array<Type> syntax
312
+ // Handle Array<Type> and ReadonlyArray<Type> syntax
313
313
  if (unwrapped.type === 'TSTypeReference' &&
314
314
  unwrapped.typeName.type === 'Identifier' &&
315
- unwrapped.typeName.name === 'Array' &&
315
+ (unwrapped.typeName.name === 'Array' || unwrapped.typeName.name === 'ReadonlyArray') &&
316
316
  unwrapped.typeArguments?.params[0]) {
317
317
  return unwrapped.typeArguments.params[0];
318
318
  }
@@ -372,6 +372,100 @@ function isRecordWithPrimitiveValue(typeNode, checker, esTreeNodeMap) {
372
372
  // If value type is a primitive, Record is not complex
373
373
  return valueTypeStr === 'string' || valueTypeStr === 'number' || valueTypeStr === 'boolean';
374
374
  }
375
+ /**
376
+ * Gets the underlying type from utility types like Partial, Required, Pick, Omit, etc.
377
+ * Returns null if not a supported utility type or if type arguments are missing.
378
+ */
379
+ function getUtilityTypeArgument(typeNode) {
380
+ const unwrapped = unwrapReadonlyOperator(typeNode);
381
+ if (unwrapped.type !== 'TSTypeReference') {
382
+ return null;
383
+ }
384
+ const typeName = getTypeReferenceName(unwrapped.typeName);
385
+ const supportedUtilityTypes = [
386
+ 'Partial',
387
+ 'Required',
388
+ 'Pick',
389
+ 'Omit',
390
+ 'Readonly',
391
+ 'NonNullable',
392
+ 'Extract',
393
+ 'Exclude',
394
+ 'ReadonlyArray',
395
+ ];
396
+ if (!supportedUtilityTypes.includes(typeName)) {
397
+ return null;
398
+ }
399
+ // All these utility types have at least one type argument
400
+ if (!unwrapped.typeArguments || unwrapped.typeArguments.params.length === 0) {
401
+ return null;
402
+ }
403
+ return {
404
+ utilityType: typeName,
405
+ typeArgument: unwrapped.typeArguments.params[0],
406
+ allTypeArguments: unwrapped.typeArguments.params,
407
+ };
408
+ }
409
+ /**
410
+ * Unwraps utility types to get the base type reference for @Type decorator suggestions.
411
+ * For Pick<User, 'name'> -> returns User
412
+ * For Partial<Address> -> returns Address
413
+ */
414
+ function unwrapUtilityTypeForClassName(typeNode) {
415
+ let current = typeNode;
416
+ // Keep unwrapping utility types until we hit the base type
417
+ while (true) {
418
+ const utilityInfo = getUtilityTypeArgument(current);
419
+ if (!utilityInfo) {
420
+ break;
421
+ }
422
+ // Use the first argument, assuming it's the source type for most utility types
423
+ current = utilityInfo.typeArgument;
424
+ }
425
+ return current;
426
+ }
427
+ /**
428
+ * Analyzes a union type to determine its composition.
429
+ * Returns information about what types are present in the union.
430
+ * Uses isComplexType to accurately classify each union member.
431
+ */
432
+ function analyzeUnionType(typeNode, checker = null, esTreeNodeMap = null) {
433
+ const result = {
434
+ isNullable: false,
435
+ hasMultiplePrimitives: false,
436
+ hasMultipleComplexTypes: false,
437
+ hasMixedComplexity: false,
438
+ nonNullTypes: [],
439
+ primitiveTypes: [],
440
+ complexTypes: [],
441
+ };
442
+ if (typeNode.type !== 'TSUnionType') {
443
+ return result;
444
+ }
445
+ for (const type of typeNode.types) {
446
+ if (type.type === 'TSNullKeyword' || type.type === 'TSUndefinedKeyword') {
447
+ result.isNullable = true;
448
+ continue;
449
+ }
450
+ result.nonNullTypes.push(type);
451
+ // Use isComplexType for accurate classification
452
+ const typeIsComplex = isComplexType(type, checker, esTreeNodeMap);
453
+ if (typeIsComplex) {
454
+ result.complexTypes.push(type);
455
+ }
456
+ else {
457
+ // It's a simple/primitive type
458
+ const typeStr = getTypeString(type, checker, esTreeNodeMap);
459
+ if (typeStr) {
460
+ result.primitiveTypes.push(typeStr);
461
+ }
462
+ }
463
+ }
464
+ result.hasMultiplePrimitives = result.primitiveTypes.length > 1;
465
+ result.hasMultipleComplexTypes = result.complexTypes.length > 1;
466
+ result.hasMixedComplexity = result.primitiveTypes.length > 0 && result.complexTypes.length > 0;
467
+ return result;
468
+ }
375
469
  /**
376
470
  * Determines if a type requires @ValidateNested decorator for proper validation.
377
471
  * Complex types include objects, class instances, type literals, and intersections.
@@ -391,14 +485,9 @@ function isComplexType(typeNode, checker = null, esTreeNodeMap = null) {
391
485
  }
392
486
  // Union types are complex if any non-null/undefined member is complex
393
487
  if (unwrapped.type === 'TSUnionType') {
394
- return unwrapped.types.some((type) => {
395
- // Skip null and undefined - they don't affect complexity
396
- if (type.type === 'TSNullKeyword' || type.type === 'TSUndefinedKeyword') {
397
- return false;
398
- }
399
- // Recursively check if this union member is complex
400
- return isComplexType(type, checker, esTreeNodeMap);
401
- });
488
+ const unionAnalysis = analyzeUnionType(unwrapped, checker, esTreeNodeMap);
489
+ // If there are any complex types in the union, it's complex
490
+ return unionAnalysis.complexTypes.length > 0;
402
491
  }
403
492
  // Intersection types: check if any member is a primitive (branded types)
404
493
  if (unwrapped.type === 'TSIntersectionType') {
@@ -431,6 +520,41 @@ function isComplexType(typeNode, checker = null, esTreeNodeMap = null) {
431
520
  if (nonValidatableTypes.includes(typeName)) {
432
521
  return false;
433
522
  }
523
+ // Check for utility types - delegate to the underlying type
524
+ const utilityTypeInfo = getUtilityTypeArgument(unwrapped);
525
+ if (utilityTypeInfo) {
526
+ // ReadonlyArray<T> behaves like Array<T>
527
+ if (utilityTypeInfo.utilityType === 'ReadonlyArray') {
528
+ return isComplexType(utilityTypeInfo.typeArgument, checker, esTreeNodeMap);
529
+ }
530
+ // Partial<T>, Required<T>, Readonly<T>, Pick<T, K>, Omit<T, K>
531
+ // For complexity checking, we need to use the type checker if available
532
+ if (['Partial', 'Required', 'Readonly', 'Pick', 'Omit'].includes(utilityTypeInfo.utilityType)) {
533
+ // Try to resolve with type checker first for Pick/Omit
534
+ if (checker &&
535
+ esTreeNodeMap &&
536
+ (utilityTypeInfo.utilityType === 'Pick' || utilityTypeInfo.utilityType === 'Omit')) {
537
+ const resolved = resolveTypeWithChecker(unwrapped, checker, esTreeNodeMap);
538
+ if (resolved) {
539
+ // If type checker resolved it to a primitive, it's not complex
540
+ return resolved !== 'string' && resolved !== 'number' && resolved !== 'boolean' && resolved !== 'Date';
541
+ }
542
+ }
543
+ // Fall back to checking if the underlying type is complex
544
+ // Note: Pick/Omit always create object types, but if we can't resolve with type checker,
545
+ // we check the source type. This may produce false positives for Pick<Complex, 'primitiveField'>
546
+ // but it's safer than false negatives.
547
+ return isComplexType(utilityTypeInfo.typeArgument, checker, esTreeNodeMap);
548
+ }
549
+ // NonNullable<T> - check if the underlying type is complex
550
+ if (utilityTypeInfo.utilityType === 'NonNullable') {
551
+ return isComplexType(utilityTypeInfo.typeArgument, checker, esTreeNodeMap);
552
+ }
553
+ // Extract<T, U> and Exclude<T, U> - check the first type argument
554
+ if (utilityTypeInfo.utilityType === 'Extract' || utilityTypeInfo.utilityType === 'Exclude') {
555
+ return isComplexType(utilityTypeInfo.typeArgument, checker, esTreeNodeMap);
556
+ }
557
+ }
434
558
  // Record<K, V> with primitive V is not complex
435
559
  if (isRecordWithPrimitiveValue(unwrapped, checker, esTreeNodeMap)) {
436
560
  return false;
@@ -458,7 +582,7 @@ function isArrayType(typeAnnotation) {
458
582
  }
459
583
  if (unwrapped.type === 'TSTypeReference' &&
460
584
  unwrapped.typeName.type === 'Identifier' &&
461
- unwrapped.typeName.name === 'Array') {
585
+ (unwrapped.typeName.name === 'Array' || unwrapped.typeName.name === 'ReadonlyArray')) {
462
586
  return true;
463
587
  }
464
588
  if (unwrapped.type === 'TSTupleType') {
@@ -580,7 +704,7 @@ function hasValidateNestedEachOption(decorator) {
580
704
  }
581
705
  /**
582
706
  * Validates if a decorator matches the TypeScript type annotation.
583
- * Handles special cases like @IsEnum validation and nullable unions.
707
+ * Handles special cases like @IsEnum validation, nullable unions, and utility types.
584
708
  */
585
709
  function checkTypeMatch(decorator, typeAnnotation, actualType, checker, esTreeNodeMap) {
586
710
  const expectedTypes = decoratorTypeMap[decorator];
@@ -601,6 +725,15 @@ function checkTypeMatch(decorator, typeAnnotation, actualType, checker, esTreeNo
601
725
  }
602
726
  return false;
603
727
  }
728
+ // For utility types, unwrap to the underlying type
729
+ const utilityTypeInfo = getUtilityTypeArgument(typeAnnotation);
730
+ if (utilityTypeInfo && utilityTypeInfo.utilityType !== 'ReadonlyArray') {
731
+ // Recursively check the underlying type
732
+ const underlyingType = getTypeString(utilityTypeInfo.typeArgument, checker, esTreeNodeMap);
733
+ if (underlyingType) {
734
+ return checkTypeMatch(decorator, utilityTypeInfo.typeArgument, underlyingType, checker, esTreeNodeMap);
735
+ }
736
+ }
604
737
  // For nullable unions (T | null | undefined), validate against the base type
605
738
  const nullableCheck = isNullableUnion(typeAnnotation);
606
739
  if (nullableCheck.isNullable && nullableCheck.baseType) {
@@ -653,13 +786,16 @@ function checkTypeMatch(decorator, typeAnnotation, actualType, checker, esTreeNo
653
786
  * - Missing @Type decorator when @ValidateNested is present
654
787
  * - Type aliases (via TypeScript type checker)
655
788
  * - Record<K, V> utility types with primitive values
789
+ * - Utility types: Partial<T>, Required<T>, Pick<T, K>, Omit<T, K>, ReadonlyArray<T>, NonNullable<T>, Extract<T, U>, Exclude<T, U>
790
+ * - Multi-type unions with explicit warnings for complex scenarios
791
+ * - Mixed complexity unions (primitive | complex types)
656
792
  */
657
793
  exports.default = createRule({
658
794
  name: 'decorator-type-match',
659
795
  meta: {
660
796
  type: 'problem',
661
797
  docs: {
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',
798
+ 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, Record utility types, and other utility types (Partial, Required, Pick, Omit, ReadonlyArray, NonNullable, Extract, Exclude). Provides explicit warnings for multi-type unions.',
663
799
  },
664
800
  messages: {
665
801
  mismatch: 'Decorator @{{decorator}} does not match type annotation {{actualType}}. Expected: {{expectedTypes}}',
@@ -672,6 +808,9 @@ exports.default = createRule({
672
808
  invalidEachOption: 'Decorator @{{decorator}} has { each: true } option but property type is not an array. Remove { each: true } or change type to an array.',
673
809
  missingTypeDecorator: 'Complex type {{actualType}} with @ValidateNested() requires @Type(() => {{className}}) decorator for proper transformation.',
674
810
  tupleValidationWarning: 'Tuple type contains complex elements. Consider using a regular array with @ValidateNested({ each: true }) or validate elements individually.',
811
+ multiTypeUnionWarning: 'Union type contains multiple complex types ({{types}}). Discriminated unions require custom validation logic - consider splitting into separate properties or using a custom validator.',
812
+ mixedComplexityUnionWarning: 'Union type mixes simple and complex types ({{primitives}} | {{complexTypes}}). This requires careful validation - simple types need type validators while complex types need @ValidateNested(). Consider using discriminated unions or custom validators.',
813
+ pickOmitWarning: 'Type {{utilityType}}<{{baseType}}, ...> may be picking/omitting primitive fields. If the resulting type is primitive, use the appropriate primitive decorator instead of @ValidateNested(). If complex, @ValidateNested() is correct.',
675
814
  },
676
815
  schema: [],
677
816
  },
@@ -831,6 +970,38 @@ exports.default = createRule({
831
970
  });
832
971
  }
833
972
  }
973
+ // Special handling for multi-type unions - only warn for truly problematic cases
974
+ if (typeAnnotation.type === 'TSUnionType' && !isUnionOfLiterals(typeAnnotation)) {
975
+ const unionAnalysis = analyzeUnionType(typeAnnotation, checker, esTreeNodeMap);
976
+ // Only warn about unions with multiple complex types (genuinely hard to validate)
977
+ // Don't warn about multi-primitive unions (string | number) as they're common and not necessarily wrong
978
+ if (unionAnalysis.hasMultipleComplexTypes) {
979
+ const complexTypeNames = unionAnalysis.complexTypes
980
+ .map((t) => getTypeString(t, checker, esTreeNodeMap) || 'unknown')
981
+ .join(' | ');
982
+ context.report({
983
+ node,
984
+ messageId: 'multiTypeUnionWarning',
985
+ data: {
986
+ types: complexTypeNames,
987
+ },
988
+ });
989
+ }
990
+ // Warn about unions mixing primitives and complex types (requires different decorators)
991
+ if (unionAnalysis.hasMixedComplexity) {
992
+ const complexTypeNames = unionAnalysis.complexTypes
993
+ .map((t) => getTypeString(t, checker, esTreeNodeMap) || 'unknown')
994
+ .join(' | ');
995
+ context.report({
996
+ node,
997
+ messageId: 'mixedComplexityUnionWarning',
998
+ data: {
999
+ primitives: unionAnalysis.primitiveTypes.join(' | '),
1000
+ complexTypes: complexTypeNames,
1001
+ },
1002
+ });
1003
+ }
1004
+ }
834
1005
  // Validate arrays of complex types have proper nested validation
835
1006
  if (isArrayType(typeAnnotation) && !isTupleType(typeAnnotation)) {
836
1007
  const elementTypeNode = getArrayElementTypeNode(typeAnnotation);
@@ -876,13 +1047,16 @@ exports.default = createRule({
876
1047
  if (nullableCheck.isNullable && nullableCheck.baseType) {
877
1048
  elementTypeToCheck = nullableCheck.baseType;
878
1049
  }
879
- if (elementTypeToCheck.type === 'TSTypeReference') {
880
- const className = getTypeReferenceName(elementTypeToCheck.typeName);
1050
+ // Unwrap utility types to get the base class name
1051
+ const unwrappedType = unwrapUtilityTypeForClassName(elementTypeToCheck);
1052
+ if (unwrappedType.type === 'TSTypeReference') {
1053
+ const className = getTypeReferenceName(unwrappedType.typeName);
1054
+ const displayType = getTypeString(elementTypeNode, checker, esTreeNodeMap);
881
1055
  context.report({
882
1056
  node,
883
1057
  messageId: 'missingTypeDecorator',
884
1058
  data: {
885
- actualType: `${className}[]`,
1059
+ actualType: `${displayType}[]`,
886
1060
  className,
887
1061
  },
888
1062
  });
@@ -970,13 +1144,32 @@ exports.default = createRule({
970
1144
  !hasTypedDecorator) {
971
1145
  // Complex types should have @ValidateNested()
972
1146
  if (!hasValidateNested && decorators.length > 0) {
973
- context.report({
974
- node,
975
- messageId: 'missingValidateNested',
976
- data: {
977
- actualType,
978
- },
979
- });
1147
+ // Check if it's a Pick/Omit type that might be a false positive
1148
+ const utilityInfo = getUtilityTypeArgument(typeAnnotation);
1149
+ if (utilityInfo && (utilityInfo.utilityType === 'Pick' || utilityInfo.utilityType === 'Omit')) {
1150
+ // Provide a more helpful message for Pick/Omit
1151
+ const baseTypeName = utilityInfo.typeArgument.type === 'TSTypeReference'
1152
+ ? getTypeReferenceName(utilityInfo.typeArgument.typeName)
1153
+ : 'unknown';
1154
+ context.report({
1155
+ node,
1156
+ messageId: 'pickOmitWarning',
1157
+ data: {
1158
+ utilityType: utilityInfo.utilityType,
1159
+ baseType: baseTypeName,
1160
+ },
1161
+ });
1162
+ }
1163
+ else {
1164
+ // Standard complex type warning
1165
+ context.report({
1166
+ node,
1167
+ messageId: 'missingValidateNested',
1168
+ data: {
1169
+ actualType,
1170
+ },
1171
+ });
1172
+ }
980
1173
  }
981
1174
  }
982
1175
  // Check if complex non-array types with @ValidateNested also have @Type
@@ -990,13 +1183,16 @@ exports.default = createRule({
990
1183
  if (nullableCheck.isNullable && nullableCheck.baseType) {
991
1184
  typeToCheck = nullableCheck.baseType;
992
1185
  }
993
- if (typeToCheck.type === 'TSTypeReference') {
994
- const className = getTypeReferenceName(typeToCheck.typeName);
1186
+ // Unwrap utility types to get the base class name
1187
+ const unwrappedType = unwrapUtilityTypeForClassName(typeToCheck);
1188
+ if (unwrappedType.type === 'TSTypeReference') {
1189
+ const className = getTypeReferenceName(unwrappedType.typeName);
1190
+ const displayType = getTypeString(typeToCheck, checker, esTreeNodeMap);
995
1191
  context.report({
996
1192
  node,
997
1193
  messageId: 'missingTypeDecorator',
998
1194
  data: {
999
- actualType: className,
1195
+ actualType: displayType || className,
1000
1196
  className,
1001
1197
  },
1002
1198
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-plugin-class-validator-type-match",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "ESLint plugin to ensure class-validator decorators match TypeScript type annotations",
5
5
  "keywords": [
6
6
  "eslint",