Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(eslint-plugin): add no-duplicate-type-constituents rule #5728

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
6ee8df7
feat: add rule code
sajikix Oct 1, 2022
90b5e96
test: add test for rule
sajikix Oct 1, 2022
1a3a620
docs: add docs of new rule
sajikix Oct 1, 2022
29874a8
refactor: make method definitions more concise
sajikix Oct 5, 2022
e03a7e8
fix: change check option to ignore option
sajikix Oct 5, 2022
95afec8
refactor: rename to type-constituents
sajikix Oct 8, 2022
1e7cbb1
refactor: use recursive type-node checker
sajikix Oct 10, 2022
3c67cef
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Oct 10, 2022
39f18bc
fix: rename doc filename and test title
sajikix Oct 11, 2022
f430871
refactor: use removeRage instead of replaceText
sajikix Oct 15, 2022
49e27be
refactor: narrows node comparison function argument type
sajikix Oct 15, 2022
b368abb
fix: doc description
sajikix Oct 16, 2022
4455b49
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Oct 16, 2022
98a008c
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Nov 6, 2022
f1f507d
refactor: update hasComments logic
sajikix Nov 3, 2022
9e75e07
fix: remove cases that never occur
sajikix Nov 3, 2022
bec198c
refactor: use type checker
sajikix Nov 4, 2022
71afc2e
fix: do not change fixer behavior with comments
sajikix Nov 4, 2022
7c79e8a
fix: delete bracket with fixer
sajikix Nov 5, 2022
af57de9
fix: fix test cases and meta data
sajikix Nov 6, 2022
f15047d
refactor : also use ast node checker
sajikix Nov 13, 2022
4571a23
refactor : organize test cases
sajikix Nov 19, 2022
5c609c1
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Nov 19, 2022
aa31c67
fix: fix rule description
sajikix Nov 23, 2022
fc9536e
fix: modify Rule Details to match implementation
sajikix Nov 23, 2022
56da0d4
refactor: add uniq set in each case
sajikix Nov 23, 2022
2f2bbad
refactor: delete type guard
sajikix Dec 5, 2022
f5e6ce3
refactor: add test case
sajikix Dec 10, 2022
2d70212
refactor: delete unnecessary comparison logic
sajikix Dec 10, 2022
fea730e
refactor: add test-case
sajikix Dec 10, 2022
f6a9e32
feat: show which the previous type is duplicating
sajikix Dec 11, 2022
cc5d3be
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Dec 11, 2022
a1b36e4
fix: use word constituents
sajikix Dec 12, 2022
22c60f8
fix: sample case
sajikix Dec 12, 2022
ee6f9b2
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Jan 17, 2023
44c52e1
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Feb 4, 2023
f265116
fix: lint message
sajikix Feb 4, 2023
05cc529
fix: rule docs
sajikix Feb 4, 2023
b924fdd
fix: use === & !==
sajikix Feb 4, 2023
3180851
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Feb 18, 2023
d9489ad
fix: No `noFormat` in test.
sajikix Feb 18, 2023
17fcdf9
fix: correct examples
sajikix Feb 18, 2023
9f6f2aa
refactor: use `flatMap`
sajikix Feb 18, 2023
0489f17
refactor: Do not use temporary `fixes` variable.
sajikix Feb 18, 2023
7b1712b
refactor: make type comparison lazy and use cache
sajikix Feb 18, 2023
2e00a2c
refactor: no unnecessary loop in `fix` function.
sajikix Feb 18, 2023
2bbd34d
refactor: get logic of tokens to be deleted
sajikix Feb 20, 2023
6e7094a
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Feb 24, 2023
2bae913
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Mar 13, 2023
9e46016
Merge branch 'main' into add-rule-no-duplicate-type-union-intersectio…
sajikix Mar 22, 2023
73b66a7
refactor: separate report function and solve fixer range problem
sajikix Mar 22, 2023
f161c61
refactor: improved documentation.
sajikix Mar 24, 2023
255cc8c
fix: make additionalProperties false
sajikix Mar 24, 2023
e8d1aa1
fix: delete printing message {{duplicated}}
sajikix Mar 24, 2023
47c2b4c
fix: do not abbreviate "unique"
sajikix Mar 24, 2023
9113663
refactor: reverse the key and value in cachedTypeMap to reduce the am…
sajikix Mar 24, 2023
a55e36f
fix: reportLocation start
sajikix Mar 24, 2023
7a85718
refactor: stop test generation and write tests naively.
sajikix Mar 24, 2023
a6b2382
refactor: Narrowing the type of options
sajikix Mar 24, 2023
9de38e4
Revert "refactor: Narrowing the type of options"
sajikix Mar 24, 2023
abb92d8
refactor: use Set instead of array
sajikix Mar 24, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
207 changes: 207 additions & 0 deletions packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts
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;
};
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved

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',
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
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,
}),
};
},
});
Loading