diff --git a/packages/eslint-plugin/docs/rules/no-duplicate-type-constituents.md b/packages/eslint-plugin/docs/rules/no-duplicate-type-constituents.md new file mode 100644 index 000000000000..879d0c6ca74c --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-duplicate-type-constituents.md @@ -0,0 +1,61 @@ +--- +description: 'Disallow duplicate constituents of union or intersection types.' +--- + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/no-duplicate-type-constituents** for documentation. + +TypeScript supports types ("constituents") within union and intersection types being duplicates of each other. +However, developers typically expect each constituent to be unique within its intersection or union. +Duplicate values make the code overly verbose and generally reduce readability. + +## Rule Details + +This rule disallows duplicate union or intersection constituents. +We consider types to be duplicate if they evaluate to the same result in the type system. +For example, given `type A = string` and `type T = string | A`, this rule would flag that `A` is the same type as `string`. + + + +### ❌ Incorrect + +```ts +type T1 = 'A' | 'A'; + +type T2 = A | A | B; + +type T3 = { a: string } & { a: string }; + +type T4 = [1, 2, 3] | [1, 2, 3]; + +type StringA = string; +type StringB = string; +type T5 = StringA | StringB; +``` + +### ✅ Correct + +```ts +type T1 = 'A' | 'B'; + +type T2 = A | B | C; + +type T3 = { a: string } & { b: string }; + +type T4 = [1, 2, 3] | [1, 2, 3, 4]; + +type StringA = string; +type NumberB = number; +type T5 = StringA | NumberB; +``` + +## Options + +### `ignoreIntersections` + +When set to true, duplicate checks on intersection type constituents are ignored. + +### `ignoreUnions` + +When set to true, duplicate checks on union type constituents are ignored. diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 63ce9b1305a9..e712d0b6f290 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -59,6 +59,7 @@ export = { 'no-dupe-class-members': 'off', '@typescript-eslint/no-dupe-class-members': 'error', '@typescript-eslint/no-duplicate-enum-values': 'error', + '@typescript-eslint/no-duplicate-type-constituents': 'error', '@typescript-eslint/no-dynamic-delete': 'error', 'no-empty-function': 'off', '@typescript-eslint/no-empty-function': 'error', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index e1d871103ec9..88f6d8c2cb82 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -38,6 +38,7 @@ import noConfusingVoidExpression from './no-confusing-void-expression'; import noDupeClassMembers from './no-dupe-class-members'; import noDuplicateEnumValues from './no-duplicate-enum-values'; import noDuplicateImports from './no-duplicate-imports'; +import noDuplicateTypeConstituents from './no-duplicate-type-constituents'; import noDynamicDelete from './no-dynamic-delete'; import noEmptyFunction from './no-empty-function'; import noEmptyInterface from './no-empty-interface'; @@ -174,6 +175,7 @@ export default { 'no-dupe-class-members': noDupeClassMembers, 'no-duplicate-enum-values': noDuplicateEnumValues, 'no-duplicate-imports': noDuplicateImports, + 'no-duplicate-type-constituents': noDuplicateTypeConstituents, 'no-dynamic-delete': noDynamicDelete, 'no-empty-function': noEmptyFunction, 'no-empty-interface': noEmptyInterface, diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts new file mode 100644 index 000000000000..180ad3b340fc --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -0,0 +1,207 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import type { Type } from 'typescript'; + +import * as util from '../util'; + +export type Options = [ + { + ignoreIntersections?: boolean; + ignoreUnions?: boolean; + }, +]; + +export type MessageIds = 'duplicate'; + +const astIgnoreKeys = new Set(['range', 'loc', 'parent']); + +const isSameAstNode = (actualNode: unknown, expectedNode: unknown): boolean => { + if (actualNode === expectedNode) { + return true; + } + if ( + actualNode && + expectedNode && + typeof actualNode === 'object' && + typeof expectedNode === 'object' + ) { + if (Array.isArray(actualNode) && Array.isArray(expectedNode)) { + if (actualNode.length !== expectedNode.length) { + return false; + } + return !actualNode.some( + (nodeEle, index) => !isSameAstNode(nodeEle, expectedNode[index]), + ); + } + const actualNodeKeys = Object.keys(actualNode).filter( + key => !astIgnoreKeys.has(key), + ); + const expectedNodeKeys = Object.keys(expectedNode).filter( + key => !astIgnoreKeys.has(key), + ); + if (actualNodeKeys.length !== expectedNodeKeys.length) { + return false; + } + if ( + actualNodeKeys.some( + actualNodeKey => + !Object.prototype.hasOwnProperty.call(expectedNode, actualNodeKey), + ) + ) { + return false; + } + if ( + actualNodeKeys.some( + actualNodeKey => + !isSameAstNode( + actualNode[actualNodeKey as keyof typeof actualNode], + expectedNode[actualNodeKey as keyof typeof expectedNode], + ), + ) + ) { + return false; + } + return true; + } + return false; +}; + +export default util.createRule({ + name: 'no-duplicate-type-constituents', + meta: { + type: 'suggestion', + docs: { + description: + 'Disallow duplicate constituents of union or intersection types', + recommended: false, + requiresTypeChecking: true, + }, + fixable: 'code', + messages: { + duplicate: '{{type}} type constituent is duplicated with {{previous}}.', + }, + schema: [ + { + additionalProperties: false, + type: 'object', + properties: { + ignoreIntersections: { + type: 'boolean', + }, + ignoreUnions: { + type: 'boolean', + }, + }, + }, + ], + }, + defaultOptions: [ + { + ignoreIntersections: false, + ignoreUnions: false, + }, + ], + create(context, [{ ignoreIntersections, ignoreUnions }]) { + const parserServices = util.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + + function checkDuplicate( + node: TSESTree.TSIntersectionType | TSESTree.TSUnionType, + ): void { + const cachedTypeMap: Map = new Map(); + node.types.reduce( + (uniqueConstituents, constituentNode) => { + const duplicatedPreviousConstituentInAst = uniqueConstituents.find( + ele => isSameAstNode(ele, constituentNode), + ); + if (duplicatedPreviousConstituentInAst) { + reportDuplicate( + { + duplicated: constituentNode, + duplicatePrevious: duplicatedPreviousConstituentInAst, + }, + node, + ); + return uniqueConstituents; + } + const constituentNodeType = checker.getTypeAtLocation( + parserServices.esTreeNodeToTSNodeMap.get(constituentNode), + ); + const duplicatedPreviousConstituentInType = + cachedTypeMap.get(constituentNodeType); + if (duplicatedPreviousConstituentInType) { + reportDuplicate( + { + duplicated: constituentNode, + duplicatePrevious: duplicatedPreviousConstituentInType, + }, + node, + ); + return uniqueConstituents; + } + cachedTypeMap.set(constituentNodeType, constituentNode); + return [...uniqueConstituents, constituentNode]; + }, + [], + ); + } + function reportDuplicate( + duplicateConstituent: { + duplicated: TSESTree.TypeNode; + duplicatePrevious: TSESTree.TypeNode; + }, + parentNode: TSESTree.TSIntersectionType | TSESTree.TSUnionType, + ): void { + const sourceCode = context.getSourceCode(); + const beforeTokens = sourceCode.getTokensBefore( + duplicateConstituent.duplicated, + { filter: token => token.value === '|' || token.value === '&' }, + ); + const beforeUnionOrIntersectionToken = + beforeTokens[beforeTokens.length - 1]; + const bracketBeforeTokens = sourceCode.getTokensBetween( + beforeUnionOrIntersectionToken, + duplicateConstituent.duplicated, + ); + const bracketAfterTokens = sourceCode.getTokensAfter( + duplicateConstituent.duplicated, + { count: bracketBeforeTokens.length }, + ); + const reportLocation: TSESTree.SourceLocation = { + start: duplicateConstituent.duplicated.loc.start, + end: + bracketAfterTokens.length > 0 + ? bracketAfterTokens[bracketAfterTokens.length - 1].loc.end + : duplicateConstituent.duplicated.loc.end, + }; + context.report({ + data: { + type: + parentNode.type === AST_NODE_TYPES.TSIntersectionType + ? 'Intersection' + : 'Union', + previous: sourceCode.getText(duplicateConstituent.duplicatePrevious), + }, + messageId: 'duplicate', + node: duplicateConstituent.duplicated, + loc: reportLocation, + fix: fixer => { + return [ + beforeUnionOrIntersectionToken, + ...bracketBeforeTokens, + duplicateConstituent.duplicated, + ...bracketAfterTokens, + ].map(token => fixer.remove(token)); + }, + }); + } + return { + ...(!ignoreIntersections && { + TSIntersectionType: checkDuplicate, + }), + ...(!ignoreUnions && { + TSUnionType: checkDuplicate, + }), + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts new file mode 100644 index 000000000000..c1b57d8914ba --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts @@ -0,0 +1,666 @@ +import rule from '../../src/rules/no-duplicate-type-constituents'; +import { getFixturesRootDir, RuleTester } from '../RuleTester'; + +const rootPath = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +ruleTester.run('no-duplicate-type-constituents', rule, { + valid: [ + { + code: 'type T = 1 | 2;', + }, + { + code: "type T = 1 | '1';", + }, + { + code: 'type T = true & boolean;', + }, + { + code: 'type T = null | undefined;', + }, + { + code: 'type T = any | unknown;', + }, + { + code: 'type T = { a: string } | { b: string };', + }, + { + code: 'type T = { a: string; b: number } | { b: number; a: string };', + }, + { + code: 'type T = { a: string | number };', + }, + { + code: 'type T = Set | Set;', + }, + { + code: 'type T = Class | Class;', + }, + { + code: 'type T = string[] | number[];', + }, + { + code: 'type T = string[][] | string[];', + }, + { + code: 'type T = [1, 2, 3] | [1, 2, 4];', + }, + { + code: 'type T = [1, 2, 3] | [1, 2, 3, 4];', + }, + { + code: "type T = 'A' | string[];", + }, + { + code: 'type T = (() => string) | (() => void);', + }, + { + code: 'type T = () => string | void;', + }, + { + code: 'type T = () => null | undefined;', + }, + { + code: 'type T = (arg: string | number) => void;', + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type T = A | B; + `, + }, + { + code: ` +type A = 'A'; +type B = 'B'; +const a: A | B = 'A'; + `, + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type T = A | /* comment */ B; + `, + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type T = 'A' | 'B'; + `, + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type C = 'C'; +type T = A | B | C; + `, + }, + { + code: 'type T = readonly string[] | string[];', + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type C = 'C'; +type D = 'D'; +type T = (A | B) | (C | D); + `, + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type T = (A | B) | (A & B); + `, + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type T = Record; + `, + }, + { + code: 'type T = A | A;', + options: [ + { + ignoreUnions: true, + }, + ], + }, + { + code: 'type T = A & A;', + options: [ + { + ignoreIntersections: true, + }, + ], + }, + ], + invalid: [ + { + code: 'type T = 1 | 1;', + output: `type T = 1 ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: '1', + }, + }, + ], + }, + { + code: 'type T = true & true;', + output: `type T = true ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Intersection', + previous: 'true', + }, + }, + ], + }, + { + code: 'type T = null | null;', + output: `type T = null ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'null', + }, + }, + ], + }, + { + code: 'type T = any | any;', + output: `type T = any ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'any', + }, + }, + ], + }, + { + code: 'type T = { a: string | string };', + output: `type T = { a: string };`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'string', + }, + }, + ], + }, + { + code: 'type T = { a: string } | { a: string };', + output: `type T = { a: string } ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: '{ a: string }', + }, + }, + ], + }, + { + code: 'type T = { a: string; b: number } | { a: string; b: number };', + output: `type T = { a: string; b: number } ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: '{ a: string; b: number }', + }, + }, + ], + }, + { + code: 'type T = Set | Set;', + output: `type T = Set ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'Set', + }, + }, + ], + }, + { + code: ` +type IsArray = T extends any[] ? true : false; +type ActuallyDuplicated = IsArray | IsArray; + `, + output: ` +type IsArray = T extends any[] ? true : false; +type ActuallyDuplicated = IsArray ; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'IsArray', + }, + }, + ], + }, + { + code: 'type T = Class | Class;', + output: `type T = Class ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'Class', + }, + }, + ], + }, + { + code: 'type T = string[] | string[];', + output: `type T = string[] ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'string[]', + }, + }, + ], + }, + { + code: 'type T = string[][] | string[][];', + output: `type T = string[][] ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'string[][]', + }, + }, + ], + }, + { + code: 'type T = [1, 2, 3] | [1, 2, 3];', + output: `type T = [1, 2, 3] ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: '[1, 2, 3]', + }, + }, + ], + }, + { + code: 'type T = () => string | string;', + output: `type T = () => string ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'string', + }, + }, + ], + }, + { + code: 'type T = () => null | null;', + output: `type T = () => null ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'null', + }, + }, + ], + }, + { + code: 'type T = (arg: string | string) => void;', + output: `type T = (arg: string ) => void;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'string', + }, + }, + ], + }, + { + code: "type T = 'A' | 'A';", + output: `type T = 'A' ;`, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: "'A'", + }, + }, + ], + }, + { + code: ` +type A = 'A'; +type T = A | A; + `, + output: ` +type A = 'A'; +type T = A ; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A', + }, + }, + ], + }, + { + code: ` +type A = 'A'; +const a: A | A = 'A'; + `, + output: ` +type A = 'A'; +const a: A = 'A'; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A', + }, + }, + ], + }, + { + code: ` +type A = 'A'; +type T = A | /* comment */ A; + `, + output: ` +type A = 'A'; +type T = A /* comment */ ; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A', + }, + }, + ], + }, + { + code: ` +type A1 = 'A'; +type A2 = 'A'; +type A3 = 'A'; +type T = A1 | A2 | A3; + `, + output: ` +type A1 = 'A'; +type A2 = 'A'; +type A3 = 'A'; +type T = A1 ; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A1', + }, + }, + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A1', + }, + }, + ], + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type T = A | B | A; + `, + output: ` +type A = 'A'; +type B = 'B'; +type T = A | B ; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A', + }, + }, + ], + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type T = A | B | A | B; + `, + output: ` +type A = 'A'; +type B = 'B'; +type T = A | B ; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A', + }, + }, + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'B', + }, + }, + ], + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type T = A | B | A | A; + `, + output: ` +type A = 'A'; +type B = 'B'; +type T = A | B ; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A', + }, + }, + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A', + }, + }, + ], + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type C = 'C'; +type T = A | B | A | C; + `, + output: ` +type A = 'A'; +type B = 'B'; +type C = 'C'; +type T = A | B | C; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A', + }, + }, + ], + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type T = (A | B) | (A | B); + `, + output: ` +type A = 'A'; +type B = 'B'; +type T = (A | B) ; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A | B', + }, + }, + ], + }, + { + code: ` +type A = 'A'; +type T = A | (A | A); + `, + output: ` +type A = 'A'; +type T = A ; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: `A`, + }, + }, + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A', + }, + }, + ], + }, + { + code: ` +type A = 'A'; +type B = 'B'; +type C = 'C'; +type D = 'D'; +type F = (A | B) | (A | B) | ((C | D) & (A | B)) | (A | B); + `, + output: ` +type A = 'A'; +type B = 'B'; +type C = 'C'; +type D = 'D'; +type F = (A | B) | ((C | D) & (A | B)) ; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A | B', + }, + }, + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A | B', + }, + }, + ], + }, + { + code: ` +type A = 'A'; +type T = Record; + `, + output: ` +type A = 'A'; +type T = Record; + `, + errors: [ + { + messageId: 'duplicate', + data: { + type: 'Union', + previous: 'A', + }, + }, + ], + }, + ], +});