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
Changes from 13 commits
6ee8df7
90b5e96
1a3a620
29874a8
e03a7e8
95afec8
1e7cbb1
3c67cef
39f18bc
f430871
49e27be
b368abb
4455b49
98a008c
f1f507d
9e75e07
bec198c
71afc2e
7c79e8a
af57de9
f15047d
4571a23
5c609c1
aa31c67
fc9536e
56da0d4
2f2bbad
f5e6ce3
2d70212
fea730e
f6a9e32
cc5d3be
a1b36e4
22c60f8
ee6f9b2
44c52e1
f265116
05cc529
b924fdd
3180851
d9489ad
17fcdf9
9f6f2aa
0489f17
7b1712b
2e00a2c
2bbd34d
6e7094a
2bae913
9e46016
73b66a7
f161c61
255cc8c
e8d1aa1
47c2b4c
9113663
a55e36f
7a85718
a6b2382
9de38e4
abb92d8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. | ||
|
||
## 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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
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 |
||
|
||
<!--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
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -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', | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
recommended: false, | ||||||
}, | ||||||
fixable: 'code', | ||||||
hasSuggestions: true, | ||||||
messages: { | ||||||
duplicate: '{{type}} type member {{name}} is duplicated.', | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||||||
}), | ||||||
}; | ||||||
}, | ||||||
}); |
There was a problem hiding this comment.
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.