Skip to content

Commit

Permalink
feat(eslint-plugin): add no-duplicate-type-constituents rule (#5728)
Browse files Browse the repository at this point in the history
* 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
sajikix committed Mar 24, 2023
1 parent 9f2122d commit bc31078
Show file tree
Hide file tree
Showing 5 changed files with 937 additions and 0 deletions.
@@ -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
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
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
@@ -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,
}),
};
},
});

0 comments on commit bc31078

Please sign in to comment.