-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(eslint-plugin): add
no-duplicate-type-constituents
rule (#5728)
* feat: add rule code * test: add test for rule * docs: add docs of new rule * refactor: make method definitions more concise * fix: change check option to ignore option * refactor: rename to type-constituents * refactor: use recursive type-node checker * fix: rename doc filename and test title * refactor: use removeRage instead of replaceText * refactor: narrows node comparison function argument type * fix: doc description * refactor: update hasComments logic * fix: remove cases that never occur * refactor: use type checker * fix: do not change fixer behavior with comments * fix: delete bracket with fixer * fix: fix test cases and meta data * refactor : also use ast node checker * refactor : organize test cases * fix: fix rule description * fix: modify Rule Details to match implementation * refactor: add uniq set in each case * refactor: delete type guard * refactor: add test case * refactor: delete unnecessary comparison logic * refactor: add test-case * feat: show which the previous type is duplicating * fix: use word constituents * fix: sample case * fix: lint message * fix: rule docs * fix: use === & !== * fix: No `noFormat` in test. * fix: correct examples * refactor: use `flatMap` * refactor: Do not use temporary `fixes` variable. * refactor: make type comparison lazy and use cache * refactor: no unnecessary loop in `fix` function. * refactor: get logic of tokens to be deleted * refactor: separate report function and solve fixer range problem * refactor: improved documentation. * fix: make additionalProperties false * fix: delete printing message {{duplicated}} * fix: do not abbreviate "unique" * refactor: reverse the key and value in cachedTypeMap to reduce the amount of calculation. * fix: reportLocation start * refactor: stop test generation and write tests naively. * refactor: Narrowing the type of options * Revert "refactor: Narrowing the type of options" This reverts commit a6b2382. * refactor: use Set instead of array
- Loading branch information
Showing
5 changed files
with
937 additions
and
0 deletions.
There are no files selected for viewing
61 changes: 61 additions & 0 deletions
61
packages/eslint-plugin/docs/rules/no-duplicate-type-constituents.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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`. | ||
|
||
<!--tabs--> | ||
|
||
### ❌ 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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
207 changes: 207 additions & 0 deletions
207
packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Options, MessageIds>({ | ||
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<Type, TSESTree.TypeNode> = new Map(); | ||
node.types.reduce<TSESTree.TypeNode[]>( | ||
(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, | ||
}), | ||
}; | ||
}, | ||
}); |
Oops, something went wrong.