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 13 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -0,0 +1,49 @@
---
description: 'Disallow duplicate union/intersection type members.'
---

> 🛑 This file is source code, not the primary documentation location! 🛑
>
> See **https://typescript-eslint.io/rules/no-duplicate-type-constituents** for documentation.

Although TypeScript supports duplicate union and intersection member values, people usually expect members to have unique values within the same intersection and union. Duplicate values make the code redundant and generally reduce readability.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Docs] Here and elsewhere in the file - we should be consistent with how we phrase it.

Suggested change
Although TypeScript supports duplicate union and intersection member values, people usually expect members to have unique values within the same intersection and union. Duplicate values make the code redundant and generally reduce readability.
Although TypeScript supports duplicate union and intersection constituents, people usually expect members to have unique values within the same intersection and union. Duplicate values make the code redundant and generally reduce readability.


## Rule Details

This rule disallows duplicate union or intersection type members. It only checks duplication on the notation. Members with the same value but different names are not marked duplicates.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Docs] I don't think this is accurate anymore?

I also don't know that people would know what "same value" means. Could you elaborate?

  • We do check whether type equality ends up being the same
  • But sometimes TypeScript treats things that are the same type as different ones:
    type WrapInValue<T> = { value: T };
    type T = { value: number } | WrapInValue<number>;

I'd suggest adding to the docs some explanation of how this rule determines whether two types are equivalent: that we check whether the syntax is the same, or if TypeScript treats them as the exact same type. This would both help explain edge cases to users and help explain why the code is doing two equality checks.

Yet again I am wishing for microsoft/TypeScript#9879 🥲 which would allow us to skip all this logic, and maybe even combine the rule with no-redundant-type-constituents...


<!--tabs-->

### ❌ Incorrect

```ts
type T1 = A | A | B;

type T2 = { a: string } & { a: string };

type T3 = [1, 2, 3] & [1, 2, 3];

type T4 = () => string | string;
```

### ✅ Correct

```ts
type T1 = A | B | C;

type T2 = { a: string } & { b: string };

type T3 = [1, 2, 3] & [1, 2, 3, 4];

type T4 = () => string | number;
```

## Options

### `ignoreIntersections`

When set to true, duplicate checks on intersection type members are ignored.
armano2 marked this conversation as resolved.
Show resolved Hide resolved

### `ignoreUnions`

When set to true, duplicate checks on union type members are ignored.
armano2 marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Expand Up @@ -54,6 +54,7 @@ export = {
'@typescript-eslint/no-dupe-class-members': 'error',
'@typescript-eslint/no-duplicate-enum-values': 'error',
'no-duplicate-imports': 'off',
'@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
Expand Up @@ -35,6 +35,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 @@ -164,6 +165,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
221 changes: 221 additions & 0 deletions packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts
@@ -0,0 +1,221 @@
import type { TSESLint, TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';

import * as util from '../util';

const nodeTypesAreEqual = <T extends TSESTree.Node>(
actualTypeNode: TSESTree.Node,
expectedTypeNode: T,
): actualTypeNode is T => {
return actualTypeNode.type === expectedTypeNode.type;
};

const constituentsAreEqual = (
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
actualTypeNode: TSESTree.TypeNode,
expectedTypeNode: TSESTree.TypeNode,
): boolean => {
switch (expectedTypeNode.type) {
// These types should never occur as constituents of a union/intersection
case AST_NODE_TYPES.TSAbstractKeyword:
case AST_NODE_TYPES.TSAsyncKeyword:
case AST_NODE_TYPES.TSDeclareKeyword:
case AST_NODE_TYPES.TSExportKeyword:
case AST_NODE_TYPES.TSNamedTupleMember:
case AST_NODE_TYPES.TSOptionalType:
case AST_NODE_TYPES.TSPrivateKeyword:
case AST_NODE_TYPES.TSProtectedKeyword:
case AST_NODE_TYPES.TSPublicKeyword:
case AST_NODE_TYPES.TSReadonlyKeyword:
case AST_NODE_TYPES.TSRestType:
case AST_NODE_TYPES.TSStaticKeyword:
case AST_NODE_TYPES.TSTypePredicate:
/* istanbul ignore next */
throw new Error(`Unexpected Type ${expectedTypeNode.type}`);
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved

default:
if (nodeTypesAreEqual(actualTypeNode, expectedTypeNode)) {
return astNodesAreEquals(actualTypeNode, expectedTypeNode);
}
return false;
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
}
};

const astIgnoreKeys = ['range', 'loc', 'parent'];

type NodeElementsType =
| number
| string
| bigint
| boolean
| null
| undefined
| RegExp
| TSESTree.BaseNode
| TSESTree.BaseNode[]
| [number, number];

const astNodesAreEquals = <T extends NodeElementsType>(
actualNode: T,
expectedNode: T,
): 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) => !astNodesAreEquals(nodeEle, expectedNode[index]),
);
}
if (isAstNodeType(actualNode) && isAstNodeType(expectedNode)) {
const actualNodeKeys = Object.keys(actualNode).filter(
key => !astIgnoreKeys.includes(key),
);
const expectedNodeKeys = Object.keys(expectedNode).filter(
key => !astIgnoreKeys.includes(key),
);
if (actualNodeKeys.length === expectedNodeKeys.length) {
return !actualNodeKeys.some(
actualNodeKey =>
!astNodesAreEquals(
actualNode[actualNodeKey],
expectedNode[actualNodeKey],
),
);
}
}
}
return false;
};
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved

const isAstNodeType = (node: object): node is Record<string, TSESTree.Node> => {
return node.constructor === Object;
};

export type Options = [
{
ignoreIntersections?: boolean;
ignoreUnions?: boolean;
},
];

export type MessageIds = 'duplicate' | 'suggestFix';

export default util.createRule<Options, MessageIds>({
name: 'no-duplicate-type-constituents',
meta: {
type: 'suggestion',
docs: {
description: 'Disallow duplicate union/intersection type members',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: 'Disallow duplicate union/intersection type members',
description: 'Disallow duplicate constituents of union or intersection types.',

recommended: false,
},
fixable: 'code',
hasSuggestions: true,
messages: {
duplicate: '{{type}} type member {{name}} is duplicated.',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 It might sometimes be difficult to know which previous type is duplicating the type.

// (let's pretend this has some obscure name the user doesn't understand)
type IsArray<T> = T extends any[] ? true : false;

type ActuallyDuplicated = IsArray<number> | IsArray<string>;

suggestFix: 'Delete duplicated members of type (removes all comments).',
},
schema: [
{
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 sourceCode = context.getSourceCode();
function checkDuplicate(
node: TSESTree.TSIntersectionType | TSESTree.TSUnionType,
): void {
const duplicateConstituents: {
node: TSESTree.TypeNode;
index: number;
}[] = [];

node.types.reduce<TSESTree.TypeNode[]>((acc, cur, index) => {
if (acc.some(ele => constituentsAreEqual(ele, cur))) {
duplicateConstituents.push({ node: cur, index });
return acc;
}
return [...acc, cur];
}, []);

const hasComments = node.types.some(type => {
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
const count =
sourceCode.getCommentsBefore(type).length +
sourceCode.getCommentsAfter(type).length;
return count > 0;
JoshuaKGoldberg marked this conversation as resolved.
Show resolved Hide resolved
});

const fix: TSESLint.ReportFixFunction = fixer => {
return duplicateConstituents.map(duplicateConstituent => {
const parent = duplicateConstituent.node.parent as
| TSESTree.TSUnionType
| TSESTree.TSIntersectionType;
const delteRangeStart =
parent.types[duplicateConstituent.index - 1].range[1];
return fixer.removeRange([
delteRangeStart,
duplicateConstituent.node.range[1],
]);
});
};

duplicateConstituents.forEach(duplicateConstituent => {
context.report({
data: {
name: sourceCode.getText(duplicateConstituent.node),
type:
node.type === AST_NODE_TYPES.TSIntersectionType
? 'Intersection'
: 'Union',
},
messageId: 'duplicate',
node,
// don't autofix if any of the types have leading/trailing comments
...(hasComments
? {
suggest: [
{
fix,
messageId: 'suggestFix',
},
],
}
: {
fix,
}),
});
});
}
return {
...(!ignoreIntersections && {
TSIntersectionType: checkDuplicate,
}),
...(!ignoreUnions && {
TSUnionType: checkDuplicate,
}),
};
},
});