From fa911e85b8cafa6a86888692512cdfa9d04e6efe Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 2 Jul 2024 07:23:54 -0500 Subject: [PATCH 01/18] make the feature --- .../rules/no-duplicate-type-constituents.ts | 153 +++++++++--------- .../no-duplicate-type-constituents.test.ts | 6 + 2 files changed, 81 insertions(+), 78 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index 58bd5ad6db24..b85aa8907100 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -5,13 +5,10 @@ import type { Type } from 'typescript'; import { createRule, getParserServices } from '../util'; export type Options = [ - { - ignoreIntersections?: boolean; - ignoreUnions?: boolean; - }, + { ignoreIntersections?: boolean; ignoreUnions?: boolean }, ]; -export type MessageIds = 'duplicate'; +export type MessageIds = 'duplicate' | 'unnecessary'; const astIgnoreKeys = new Set(['range', 'loc', 'parent']); @@ -79,31 +76,25 @@ export default createRule({ fixable: 'code', messages: { duplicate: '{{type}} type constituent is duplicated with {{previous}}.', + unnecessary: + 'Explicit undefined is unnecessary on an optional parameter.', }, schema: [ { additionalProperties: false, type: 'object', properties: { - ignoreIntersections: { - type: 'boolean', - }, - ignoreUnions: { - type: 'boolean', - }, + ignoreIntersections: { type: 'boolean' }, + ignoreUnions: { type: 'boolean' }, }, }, ], }, - defaultOptions: [ - { - ignoreIntersections: false, - ignoreUnions: false, - }, - ], + defaultOptions: [{ ignoreIntersections: false, ignoreUnions: false }], create(context, [{ ignoreIntersections, ignoreUnions }]) { const parserServices = getParserServices(context); const checker = parserServices.program.getTypeChecker(); + const { sourceCode } = context; function checkDuplicate( node: TSESTree.TSIntersectionType | TSESTree.TSUnionType, @@ -111,18 +102,23 @@ export default createRule({ const cachedTypeMap = new Map(); node.types.reduce( (uniqueConstituents, constituentNode) => { + const reportDuplicate = ( + duplicatePrevious: TSESTree.TypeNode, + ): TSESTree.TypeNode[] => { + report('duplicate', constituentNode, { + type: + node.type === AST_NODE_TYPES.TSIntersectionType + ? 'Intersection' + : 'Union', + previous: sourceCode.getText(duplicatePrevious), + }); + return uniqueConstituents; + }; const duplicatedPreviousConstituentInAst = uniqueConstituents.find( ele => isSameAstNode(ele, constituentNode), ); if (duplicatedPreviousConstituentInAst) { - reportDuplicate( - { - duplicated: constituentNode, - duplicatePrevious: duplicatedPreviousConstituentInAst, - }, - node, - ); - return uniqueConstituents; + return reportDuplicate(duplicatedPreviousConstituentInAst); } const constituentNodeType = checker.getTypeAtLocation( parserServices.esTreeNodeToTSNodeMap.get(constituentNode), @@ -130,14 +126,7 @@ export default createRule({ const duplicatedPreviousConstituentInType = cachedTypeMap.get(constituentNodeType); if (duplicatedPreviousConstituentInType) { - reportDuplicate( - { - duplicated: constituentNode, - duplicatePrevious: duplicatedPreviousConstituentInType, - }, - node, - ); - return uniqueConstituents; + return reportDuplicate(duplicatedPreviousConstituentInType); } cachedTypeMap.set(constituentNodeType, constituentNode); return [...uniqueConstituents, constituentNode]; @@ -145,63 +134,71 @@ export default createRule({ [], ); } - function reportDuplicate( - duplicateConstituent: { - duplicated: TSESTree.TypeNode; - duplicatePrevious: TSESTree.TypeNode; - }, - parentNode: TSESTree.TSIntersectionType | TSESTree.TSUnionType, + function report( + messageId: MessageIds, + node: TSESTree.TypeNode, + data?: Record, ): void { - const beforeTokens = context.sourceCode.getTokensBefore( - duplicateConstituent.duplicated, - { filter: token => token.value === '|' || token.value === '&' }, - ); - const beforeUnionOrIntersectionToken = - beforeTokens[beforeTokens.length - 1]; - const bracketBeforeTokens = context.sourceCode.getTokensBetween( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const beforeUnionOrIntersectionToken = sourceCode + .getTokensBefore(node, { + filter: token => ['|', '&'].includes(token.value), + }) + .at(-1)!; + const bracketBeforeTokens = sourceCode.getTokensBetween( beforeUnionOrIntersectionToken, - duplicateConstituent.duplicated, + node, ); - const bracketAfterTokens = context.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, - }; + const bracketAfterTokens = sourceCode.getTokensAfter(node, { + count: bracketBeforeTokens.length, + }); context.report({ - data: { - type: - parentNode.type === AST_NODE_TYPES.TSIntersectionType - ? 'Intersection' - : 'Union', - previous: context.sourceCode.getText( - duplicateConstituent.duplicatePrevious, - ), + data, + messageId, + node, + loc: { + start: node.loc.start, + end: (bracketAfterTokens.at(-1) ?? node).loc.end, }, - messageId: 'duplicate', - node: duplicateConstituent.duplicated, - loc: reportLocation, - fix: fixer => { - return [ + fix: fixer => + [ beforeUnionOrIntersectionToken, ...bracketBeforeTokens, - duplicateConstituent.duplicated, + node, ...bracketAfterTokens, - ].map(token => fixer.remove(token)); - }, + ].map(token => fixer.remove(token)), }); } return { - ...(!ignoreIntersections && { - TSIntersectionType: checkDuplicate, - }), + ...(!ignoreIntersections && { TSIntersectionType: checkDuplicate }), ...(!ignoreUnions && { - TSUnionType: checkDuplicate, + TSUnionType(node): void { + checkDuplicate(node); + const maybeTypeAnnotation = node.parent; + if (maybeTypeAnnotation.type === AST_NODE_TYPES.TSTypeAnnotation) { + const maybeIdentifier = maybeTypeAnnotation.parent; + if ( + maybeIdentifier.type === AST_NODE_TYPES.Identifier && + maybeIdentifier.optional + ) { + const maybeFunction = maybeIdentifier.parent; + const { type } = maybeFunction; + if ( + (type === AST_NODE_TYPES.ArrowFunctionExpression || + type === AST_NODE_TYPES.FunctionDeclaration || + type === AST_NODE_TYPES.FunctionExpression) && + maybeFunction.params.includes(maybeIdentifier) + ) { + const explicitUndefined = node.types.find( + ({ type }) => type === AST_NODE_TYPES.TSUndefinedKeyword, + ); + if (explicitUndefined) { + report('unnecessary', explicitUndefined); + } + } + } + } + }, }), }; }, diff --git a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts index 376e3fa22683..07c9b1df4a2e 100644 --- a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts +++ b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts @@ -150,6 +150,7 @@ type T = Record; }, ], }, + { code: '(a: string | undefined) => {};' }, ], invalid: [ { @@ -664,5 +665,10 @@ type T = Record; }, ], }, + { + code: '(a?: string | undefined) => {};', + errors: [{ messageId: 'unnecessary' }], + output: '(a?: string ) => {};', + }, ], }); From 1db600aa9fafe0a79496b3def1e8726f330ab13c Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 2 Jul 2024 07:35:59 -0500 Subject: [PATCH 02/18] small refactor --- .../rules/no-duplicate-type-constituents.ts | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index b85aa8907100..5aa1e24a925c 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -102,31 +102,35 @@ export default createRule({ const cachedTypeMap = new Map(); node.types.reduce( (uniqueConstituents, constituentNode) => { - const reportDuplicate = ( - duplicatePrevious: TSESTree.TypeNode, - ): TSESTree.TypeNode[] => { - report('duplicate', constituentNode, { - type: - node.type === AST_NODE_TYPES.TSIntersectionType - ? 'Intersection' - : 'Union', - previous: sourceCode.getText(duplicatePrevious), - }); - return uniqueConstituents; + const reportIfDuplicate = ( + duplicatePrevious?: TSESTree.TypeNode, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + ): true | void => { + if (duplicatePrevious) { + report('duplicate', constituentNode, { + type: + node.type === AST_NODE_TYPES.TSIntersectionType + ? 'Intersection' + : 'Union', + previous: sourceCode.getText(duplicatePrevious), + }); + return true; + } }; - const duplicatedPreviousConstituentInAst = uniqueConstituents.find( - ele => isSameAstNode(ele, constituentNode), - ); - if (duplicatedPreviousConstituentInAst) { - return reportDuplicate(duplicatedPreviousConstituentInAst); + if ( + reportIfDuplicate( + uniqueConstituents.find(ele => + isSameAstNode(ele, constituentNode), + ), + ) + ) { + return uniqueConstituents; } const constituentNodeType = checker.getTypeAtLocation( parserServices.esTreeNodeToTSNodeMap.get(constituentNode), ); - const duplicatedPreviousConstituentInType = - cachedTypeMap.get(constituentNodeType); - if (duplicatedPreviousConstituentInType) { - return reportDuplicate(duplicatedPreviousConstituentInType); + if (reportIfDuplicate(cachedTypeMap.get(constituentNodeType))) { + return uniqueConstituents; } cachedTypeMap.set(constituentNodeType, constituentNode); return [...uniqueConstituents, constituentNode]; From 57357239a20b6b7a8d6eee74c1c1ccb6447af9c0 Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 2 Jul 2024 08:01:22 -0500 Subject: [PATCH 03/18] update docs --- .../docs/rules/no-duplicate-type-constituents.mdx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/eslint-plugin/docs/rules/no-duplicate-type-constituents.mdx b/packages/eslint-plugin/docs/rules/no-duplicate-type-constituents.mdx index 754d59087ddf..dcad6d39ee6d 100644 --- a/packages/eslint-plugin/docs/rules/no-duplicate-type-constituents.mdx +++ b/packages/eslint-plugin/docs/rules/no-duplicate-type-constituents.mdx @@ -17,6 +17,9 @@ 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`. +This rule also disallows explicitly listing `undefined` in a type union when a function parameter is marked as optional. +Doing so is unnecessary. + @@ -32,6 +35,8 @@ type T4 = [1, 2, 3] | [1, 2, 3]; type StringA = string; type StringB = string; type T5 = StringA | StringB; + +const fn = (a?: string | undefined) => {}; ``` @@ -49,6 +54,8 @@ type T4 = [1, 2, 3] | [1, 2, 3, 4]; type StringA = string; type NumberB = number; type T5 = StringA | NumberB; + +const fn = (a?: string) => {}; ``` From 502d68442346bf052213b01f0ce0bf41f76b53bd Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 2 Jul 2024 08:10:39 -0500 Subject: [PATCH 04/18] apply lint rule --- packages/eslint-plugin/src/rules/prefer-find.ts | 2 +- .../src/rules/use-unknown-in-catch-callback-variable.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin/src/rules/prefer-find.ts b/packages/eslint-plugin/src/rules/prefer-find.ts index 4461ec21da0a..679ef6a835da 100644 --- a/packages/eslint-plugin/src/rules/prefer-find.ts +++ b/packages/eslint-plugin/src/rules/prefer-find.ts @@ -331,7 +331,7 @@ function isStaticMemberAccessOfValue( | TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedName, value: string, - scope?: Scope.Scope | undefined, + scope?: Scope.Scope, ): boolean { if (!memberExpression.computed) { // x.memberName case. diff --git a/packages/eslint-plugin/src/rules/use-unknown-in-catch-callback-variable.ts b/packages/eslint-plugin/src/rules/use-unknown-in-catch-callback-variable.ts index b899b23c391d..f25702c77421 100644 --- a/packages/eslint-plugin/src/rules/use-unknown-in-catch-callback-variable.ts +++ b/packages/eslint-plugin/src/rules/use-unknown-in-catch-callback-variable.ts @@ -343,7 +343,7 @@ function isStaticMemberAccessOfValue( | TSESTree.MemberExpressionComputedName | TSESTree.MemberExpressionNonComputedName, value: string, - scope?: Scope.Scope | undefined, + scope?: Scope.Scope, ): boolean { if (!memberExpression.computed) { // x.memberName case. From 5ee073c10afecbccc71a2674a2f0a530b84325c8 Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 2 Jul 2024 08:23:16 -0500 Subject: [PATCH 05/18] add issue comment --- .../eslint-plugin/src/rules/no-duplicate-type-constituents.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index 5aa1e24a925c..e0d122a03821 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -104,6 +104,7 @@ export default createRule({ (uniqueConstituents, constituentNode) => { const reportIfDuplicate = ( duplicatePrevious?: TSESTree.TypeNode, + // https://github.com/typescript-eslint/typescript-eslint/issues/5752 // eslint-disable-next-line @typescript-eslint/no-invalid-void-type ): true | void => { if (duplicatePrevious) { From 1ce2f45eea2ea0fbd67bf8fece758220fddcec39 Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 2 Jul 2024 12:55:02 -0500 Subject: [PATCH 06/18] update snapshot --- .../no-duplicate-type-constituents.shot | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-duplicate-type-constituents.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-duplicate-type-constituents.shot index be96eb01aa3e..d070ef1e113b 100644 --- a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-duplicate-type-constituents.shot +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-duplicate-type-constituents.shot @@ -19,6 +19,9 @@ type StringA = string; type StringB = string; type T5 = StringA | StringB; ~~~~~~~ Union type constituent is duplicated with StringA. + +const fn = (a?: string | undefined) => {}; + ~~~~~~~~~ Explicit undefined is unnecessary on an optional parameter. " `; @@ -36,5 +39,7 @@ type T4 = [1, 2, 3] | [1, 2, 3, 4]; type StringA = string; type NumberB = number; type T5 = StringA | NumberB; + +const fn = (a?: string) => {}; " `; From 02097a4d213d1d9973aa503000bd2fd4dd01ecca Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Thu, 4 Jul 2024 12:32:17 -0500 Subject: [PATCH 07/18] revert accidental formatting changes --- .../rules/no-duplicate-type-constituents.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index e0d122a03821..af9640b84f40 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -5,7 +5,10 @@ import type { Type } from 'typescript'; import { createRule, getParserServices } from '../util'; export type Options = [ - { ignoreIntersections?: boolean; ignoreUnions?: boolean }, + { + ignoreIntersections?: boolean; + ignoreUnions?: boolean; + }, ]; export type MessageIds = 'duplicate' | 'unnecessary'; @@ -84,13 +87,22 @@ export default createRule({ additionalProperties: false, type: 'object', properties: { - ignoreIntersections: { type: 'boolean' }, - ignoreUnions: { type: 'boolean' }, + ignoreIntersections: { + type: 'boolean', + }, + ignoreUnions: { + type: 'boolean', + }, }, }, ], }, - defaultOptions: [{ ignoreIntersections: false, ignoreUnions: false }], + defaultOptions: [ + { + ignoreIntersections: false, + ignoreUnions: false, + }, + ], create(context, [{ ignoreIntersections, ignoreUnions }]) { const parserServices = getParserServices(context); const checker = parserServices.program.getTypeChecker(); From d79de67dbe7ce7b7468cb21a42619cbca9609f5b Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Thu, 4 Jul 2024 14:27:19 -0500 Subject: [PATCH 08/18] handle first constituent of union --- .../rules/no-duplicate-type-constituents.ts | 50 ++++++++++++++----- .../no-duplicate-type-constituents.test.ts | 5 ++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index af9640b84f40..130a19b54180 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -1,5 +1,6 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import console from 'console'; import type { Type } from 'typescript'; import { createRule, getParserServices } from '../util'; @@ -156,19 +157,43 @@ export default createRule({ node: TSESTree.TypeNode, data?: Record, ): void { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const beforeUnionOrIntersectionToken = sourceCode - .getTokensBefore(node, { + const getUnionOrIntersectionToken = ( + where: 'Before' | 'After', + at: number, + ): TSESTree.Token | undefined => + sourceCode[`getTokens${where}`](node, { filter: token => ['|', '&'].includes(token.value), - }) - .at(-1)!; - const bracketBeforeTokens = sourceCode.getTokensBetween( - beforeUnionOrIntersectionToken, - node, + }).at(at); + + const beforeUnionOrIntersectionToken = getUnionOrIntersectionToken( + 'Before', + -1, ); - const bracketAfterTokens = sourceCode.getTokensAfter(node, { - count: bracketBeforeTokens.length, - }); + let afterUnionOrIntersectionToken: TSESTree.Token | undefined; + let bracketBeforeTokens; + let bracketAfterTokens; + if (beforeUnionOrIntersectionToken) { + bracketBeforeTokens = sourceCode.getTokensBetween( + beforeUnionOrIntersectionToken, + node, + ); + bracketAfterTokens = sourceCode.getTokensAfter(node, { + count: bracketBeforeTokens.length, + }); + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + afterUnionOrIntersectionToken = getUnionOrIntersectionToken( + 'After', + 0, + )!; + bracketAfterTokens = sourceCode.getTokensBetween( + node, + afterUnionOrIntersectionToken, + ); + bracketBeforeTokens = sourceCode.getTokensBefore(node, { + count: bracketAfterTokens.length, + }); + } context.report({ data, messageId, @@ -183,7 +208,8 @@ export default createRule({ ...bracketBeforeTokens, node, ...bracketAfterTokens, - ].map(token => fixer.remove(token)), + afterUnionOrIntersectionToken, + ].flatMap(token => (token ? fixer.remove(token) : [])), }); } return { diff --git a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts index 07c9b1df4a2e..1849bb676e78 100644 --- a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts +++ b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts @@ -670,5 +670,10 @@ type T = Record; errors: [{ messageId: 'unnecessary' }], output: '(a?: string ) => {};', }, + { + code: '(arg?: undefined | string) => {};', + errors: [{ messageId: 'unnecessary' }], + output: '(arg?: string) => {};', + }, ], }); From 4b7743dbce034ba2aaeca7bde53f3ecc984082af Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Thu, 4 Jul 2024 23:19:12 -0500 Subject: [PATCH 09/18] lint --- .../eslint-plugin/src/rules/no-duplicate-type-constituents.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index 130a19b54180..5bd9dee56b7c 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -1,6 +1,5 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; -import console from 'console'; import type { Type } from 'typescript'; import { createRule, getParserServices } from '../util'; From eabd62c5ea78e0c57918ff6e103f140e62f0bbad Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Fri, 5 Jul 2024 07:18:56 -0500 Subject: [PATCH 10/18] use nullThrows --- .../rules/no-duplicate-type-constituents.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index 5bd9dee56b7c..bf1eac676a3a 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -2,7 +2,12 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import type { Type } from 'typescript'; -import { createRule, getParserServices } from '../util'; +import { + createRule, + getParserServices, + nullThrows, + NullThrowsReasons, +} from '../util'; export type Options = [ { @@ -180,11 +185,13 @@ export default createRule({ count: bracketBeforeTokens.length, }); } else { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - afterUnionOrIntersectionToken = getUnionOrIntersectionToken( - 'After', - 0, - )!; + afterUnionOrIntersectionToken = nullThrows( + getUnionOrIntersectionToken('After', 0), + NullThrowsReasons.MissingToken( + 'union or intersection token', + 'duplicate type constituent', + ), + ); bracketAfterTokens = sourceCode.getTokensBetween( node, afterUnionOrIntersectionToken, From e73a29d5e8885e248c37602440fc074113108dd2 Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Sun, 28 Jul 2024 15:36:44 -0500 Subject: [PATCH 11/18] add failing test --- .../tests/rules/no-duplicate-type-constituents.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts index 1849bb676e78..b479dec0ed60 100644 --- a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts +++ b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts @@ -671,9 +671,15 @@ type T = Record; output: '(a?: string ) => {};', }, { - code: '(arg?: undefined | string) => {};', + code: ` + type T = undefined; + (arg?: T | string) => {}; + `, errors: [{ messageId: 'unnecessary' }], - output: '(arg?: string) => {};', + output: ` + type T = undefined; + (arg?: string) => {}; + `, }, ], }); From 24e6d2140755350814515dd1a83ed9976720c57d Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 30 Jul 2024 18:54:32 -0500 Subject: [PATCH 12/18] WIP --- .../src/rules/no-duplicate-type-constituents.ts | 14 +++++++------- .../rules/no-duplicate-type-constituents.test.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index 5c3ba7d18457..199fd69af5b8 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -2,6 +2,8 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import * as tsutils from 'ts-api-utils'; import type { Type } from 'typescript'; +import console from 'console'; +import ts from 'typescript'; import { createRule, @@ -225,7 +227,7 @@ export default createRule({ ...(!ignoreIntersections && { TSIntersectionType: checkDuplicate }), ...(!ignoreUnions && { TSUnionType(node): void { - checkDuplicate(node); + let initialTypes; const maybeTypeAnnotation = node.parent; if (maybeTypeAnnotation.type === AST_NODE_TYPES.TSTypeAnnotation) { const maybeIdentifier = maybeTypeAnnotation.parent; @@ -241,15 +243,13 @@ export default createRule({ type === AST_NODE_TYPES.FunctionExpression) && maybeFunction.params.includes(maybeIdentifier) ) { - const explicitUndefined = node.types.find( - ({ type }) => type === AST_NODE_TYPES.TSUndefinedKeyword, - ); - if (explicitUndefined) { - report('unnecessary', explicitUndefined); - } + initialTypes = parserServices.program + .getTypeChecker() + .getUndefinedType(); } } } + checkDuplicate(node, initialTypes); }, }), }; diff --git a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts index 28cc4d7ca5dc..c41bc4485f01 100644 --- a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts +++ b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts @@ -690,5 +690,13 @@ type T = Record; (arg?: string) => {}; `, }, + { + only: true, + code: ` + type T = undefined; + type U = T | undefined; + `, + errors: [{ messageId: 'duplicate' }], + }, ], }); From d5d3af5b51da8de0919acda73f213d81ad4c3147 Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Wed, 31 Jul 2024 08:40:04 -0500 Subject: [PATCH 13/18] finish refactor --- .../rules/no-duplicate-type-constituents.ts | 176 +++++++++--------- .../no-duplicate-type-constituents.test.ts | 8 - 2 files changed, 91 insertions(+), 93 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index 199fd69af5b8..1d93311981fd 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -1,9 +1,9 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import * as tsutils from 'ts-api-utils'; +import { isTypeFlagSet } from 'ts-api-utils'; import type { Type } from 'typescript'; -import console from 'console'; -import ts from 'typescript'; +import * as ts from 'typescript'; import { createRule, @@ -117,6 +117,10 @@ export default createRule({ function checkDuplicate( node: TSESTree.TSIntersectionType | TSESTree.TSUnionType, + forEachNodeType?: ( + constituentNodeType: Type, + report: (messageId: MessageIds) => void, + ) => void, ): void { const cachedTypeMap = new Map(); node.types.reduce( @@ -127,13 +131,77 @@ export default createRule({ return uniqueConstituents; } + const report = ( + messageId: MessageIds, + data?: Record, + ): void => { + const getUnionOrIntersectionToken = ( + where: 'Before' | 'After', + at: number, + ): TSESTree.Token | undefined => + sourceCode[`getTokens${where}`](constituentNode, { + filter: token => ['|', '&'].includes(token.value), + }).at(at); + + const beforeUnionOrIntersectionToken = getUnionOrIntersectionToken( + 'Before', + -1, + ); + let afterUnionOrIntersectionToken: TSESTree.Token | undefined; + let bracketBeforeTokens; + let bracketAfterTokens; + if (beforeUnionOrIntersectionToken) { + bracketBeforeTokens = sourceCode.getTokensBetween( + beforeUnionOrIntersectionToken, + constituentNode, + ); + bracketAfterTokens = sourceCode.getTokensAfter(constituentNode, { + count: bracketBeforeTokens.length, + }); + } else { + afterUnionOrIntersectionToken = nullThrows( + getUnionOrIntersectionToken('After', 0), + NullThrowsReasons.MissingToken( + 'union or intersection token', + 'duplicate type constituent', + ), + ); + bracketAfterTokens = sourceCode.getTokensBetween( + constituentNode, + afterUnionOrIntersectionToken, + ); + bracketBeforeTokens = sourceCode.getTokensBefore( + constituentNode, + { + count: bracketAfterTokens.length, + }, + ); + } + context.report({ + data, + messageId, + node: constituentNode, + loc: { + start: constituentNode.loc.start, + end: (bracketAfterTokens.at(-1) ?? constituentNode).loc.end, + }, + fix: fixer => + [ + beforeUnionOrIntersectionToken, + ...bracketBeforeTokens, + constituentNode, + ...bracketAfterTokens, + afterUnionOrIntersectionToken, + ].flatMap(token => (token ? fixer.remove(token) : [])), + }); + }; const reportIfDuplicate = ( duplicatePrevious?: TSESTree.TypeNode, // https://github.com/typescript-eslint/typescript-eslint/issues/5752 // eslint-disable-next-line @typescript-eslint/no-invalid-void-type ): true | void => { if (duplicatePrevious) { - report('duplicate', constituentNode, { + report('duplicate', { type: node.type === AST_NODE_TYPES.TSIntersectionType ? 'Intersection' @@ -155,102 +223,40 @@ export default createRule({ if (reportIfDuplicate(cachedTypeMap.get(constituentNodeType))) { return uniqueConstituents; } + forEachNodeType?.(constituentNodeType, report); cachedTypeMap.set(constituentNodeType, constituentNode); return [...uniqueConstituents, constituentNode]; }, [], ); } - function report( - messageId: MessageIds, - node: TSESTree.TypeNode, - data?: Record, - ): void { - const getUnionOrIntersectionToken = ( - where: 'Before' | 'After', - at: number, - ): TSESTree.Token | undefined => - sourceCode[`getTokens${where}`](node, { - filter: token => ['|', '&'].includes(token.value), - }).at(at); - const beforeUnionOrIntersectionToken = getUnionOrIntersectionToken( - 'Before', - -1, - ); - let afterUnionOrIntersectionToken: TSESTree.Token | undefined; - let bracketBeforeTokens; - let bracketAfterTokens; - if (beforeUnionOrIntersectionToken) { - bracketBeforeTokens = sourceCode.getTokensBetween( - beforeUnionOrIntersectionToken, - node, - ); - bracketAfterTokens = sourceCode.getTokensAfter(node, { - count: bracketBeforeTokens.length, - }); - } else { - afterUnionOrIntersectionToken = nullThrows( - getUnionOrIntersectionToken('After', 0), - NullThrowsReasons.MissingToken( - 'union or intersection token', - 'duplicate type constituent', - ), - ); - bracketAfterTokens = sourceCode.getTokensBetween( - node, - afterUnionOrIntersectionToken, - ); - bracketBeforeTokens = sourceCode.getTokensBefore(node, { - count: bracketAfterTokens.length, - }); - } - context.report({ - data, - messageId, - node, - loc: { - start: node.loc.start, - end: (bracketAfterTokens.at(-1) ?? node).loc.end, - }, - fix: fixer => - [ - beforeUnionOrIntersectionToken, - ...bracketBeforeTokens, - node, - ...bracketAfterTokens, - afterUnionOrIntersectionToken, - ].flatMap(token => (token ? fixer.remove(token) : [])), - }); - } return { ...(!ignoreIntersections && { TSIntersectionType: checkDuplicate }), ...(!ignoreUnions && { - TSUnionType(node): void { - let initialTypes; - const maybeTypeAnnotation = node.parent; - if (maybeTypeAnnotation.type === AST_NODE_TYPES.TSTypeAnnotation) { - const maybeIdentifier = maybeTypeAnnotation.parent; - if ( - maybeIdentifier.type === AST_NODE_TYPES.Identifier && - maybeIdentifier.optional - ) { - const maybeFunction = maybeIdentifier.parent; - const { type } = maybeFunction; + TSUnionType: (node): void => + checkDuplicate(node, (constituentNodeType, report) => { + const maybeTypeAnnotation = node.parent; + if (maybeTypeAnnotation.type === AST_NODE_TYPES.TSTypeAnnotation) { + const maybeIdentifier = maybeTypeAnnotation.parent; if ( - (type === AST_NODE_TYPES.ArrowFunctionExpression || - type === AST_NODE_TYPES.FunctionDeclaration || - type === AST_NODE_TYPES.FunctionExpression) && - maybeFunction.params.includes(maybeIdentifier) + maybeIdentifier.type === AST_NODE_TYPES.Identifier && + maybeIdentifier.optional ) { - initialTypes = parserServices.program - .getTypeChecker() - .getUndefinedType(); + const maybeFunction = maybeIdentifier.parent; + const { type } = maybeFunction; + if ( + (type === AST_NODE_TYPES.ArrowFunctionExpression || + type === AST_NODE_TYPES.FunctionDeclaration || + type === AST_NODE_TYPES.FunctionExpression) && + maybeFunction.params.includes(maybeIdentifier) && + isTypeFlagSet(constituentNodeType, ts.TypeFlags.Undefined) + ) { + report('unnecessary'); + } } } - } - checkDuplicate(node, initialTypes); - }, + }), }), }; }, diff --git a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts index c41bc4485f01..28cc4d7ca5dc 100644 --- a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts +++ b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts @@ -690,13 +690,5 @@ type T = Record; (arg?: string) => {}; `, }, - { - only: true, - code: ` - type T = undefined; - type U = T | undefined; - `, - errors: [{ messageId: 'duplicate' }], - }, ], }); From e2253c522abccdfc170006664a0609564666f1f0 Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Wed, 31 Jul 2024 08:56:10 -0500 Subject: [PATCH 14/18] cleanup --- .../rules/no-duplicate-type-constituents.ts | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index 1d93311981fd..7ea9535af556 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -195,32 +195,18 @@ export default createRule({ ].flatMap(token => (token ? fixer.remove(token) : [])), }); }; - const reportIfDuplicate = ( - duplicatePrevious?: TSESTree.TypeNode, - // https://github.com/typescript-eslint/typescript-eslint/issues/5752 - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type - ): true | void => { - if (duplicatePrevious) { - report('duplicate', { - type: - node.type === AST_NODE_TYPES.TSIntersectionType - ? 'Intersection' - : 'Union', - previous: sourceCode.getText(duplicatePrevious), - }); - return true; - } - }; - if ( - reportIfDuplicate( - uniqueConstituents.find(ele => - isSameAstNode(ele, constituentNode), - ), - ) - ) { - return uniqueConstituents; - } - if (reportIfDuplicate(cachedTypeMap.get(constituentNodeType))) { + const duplicatePrevious = + uniqueConstituents.find(ele => + isSameAstNode(ele, constituentNode), + ) ?? cachedTypeMap.get(constituentNodeType); + if (duplicatePrevious) { + report('duplicate', { + type: + node.type === AST_NODE_TYPES.TSIntersectionType + ? 'Intersection' + : 'Union', + previous: sourceCode.getText(duplicatePrevious), + }); return uniqueConstituents; } forEachNodeType?.(constituentNodeType, report); From bb88e90861af45d2ab7e3aa1e7954f2401b5a03a Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 13 Aug 2024 07:19:35 -0500 Subject: [PATCH 15/18] clean up nits --- .../src/rules/no-duplicate-type-constituents.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index 9e9f5c492502..5568b0e240ca 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -1,7 +1,6 @@ import type { TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; import * as tsutils from 'ts-api-utils'; -import { isTypeFlagSet } from 'ts-api-utils'; import type { Type } from 'typescript'; import * as ts from 'typescript'; @@ -217,7 +216,9 @@ export default createRule({ } return { - ...(!ignoreIntersections && { TSIntersectionType: checkDuplicate }), + ...(!ignoreIntersections && { + TSIntersectionType: checkDuplicate, + }), ...(!ignoreUnions && { TSUnionType: (node): void => checkDuplicate(node, (constituentNodeType, report) => { @@ -235,7 +236,10 @@ export default createRule({ type === AST_NODE_TYPES.FunctionDeclaration || type === AST_NODE_TYPES.FunctionExpression) && maybeFunction.params.includes(maybeIdentifier) && - isTypeFlagSet(constituentNodeType, ts.TypeFlags.Undefined) + tsutils.isTypeFlagSet( + constituentNodeType, + ts.TypeFlags.Undefined, + ) ) { report('unnecessary'); } From b47b88f9501ccbdac53a45e314ba69e403151b86 Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 13 Aug 2024 08:08:52 -0500 Subject: [PATCH 16/18] add more node types --- .../rules/no-duplicate-type-constituents.ts | 7 +- .../no-duplicate-type-constituents.test.ts | 91 +++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index 5568b0e240ca..100de16339e2 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -7,6 +7,7 @@ import * as ts from 'typescript'; import { createRule, getParserServices, + isFunctionOrFunctionType, nullThrows, NullThrowsReasons, } from '../util'; @@ -230,11 +231,9 @@ export default createRule({ maybeIdentifier.optional ) { const maybeFunction = maybeIdentifier.parent; - const { type } = maybeFunction; if ( - (type === AST_NODE_TYPES.ArrowFunctionExpression || - type === AST_NODE_TYPES.FunctionDeclaration || - type === AST_NODE_TYPES.FunctionExpression) && + (isFunctionOrFunctionType(maybeFunction) || + maybeFunction.type === AST_NODE_TYPES.TSDeclareFunction) && maybeFunction.params.includes(maybeIdentifier) && tsutils.isTypeFlagSet( constituentNodeType, diff --git a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts index c78283a7a322..e6fef19c8bbe 100644 --- a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts +++ b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts @@ -691,5 +691,96 @@ type T = Record; (arg?: string) => {}; `, }, + { + code: ` + interface F { + (a?: string | undefined): void; + } + `, + errors: [{ messageId: 'unnecessary' }], + output: ` + interface F { + (a?: string ): void; + } + `, + }, + { + code: 'type fn = new (a?: string | undefined) => void;', + errors: [{ messageId: 'unnecessary' }], + output: 'type fn = new (a?: string ) => void;', + }, + { + code: 'function f(a?: string | undefined) {}', + errors: [{ messageId: 'unnecessary' }], + output: 'function f(a?: string ) {}', + }, + { + code: 'f = function (a?: string | undefined) {};', + errors: [{ messageId: 'unnecessary' }], + output: 'f = function (a?: string ) {};', + }, + { + code: 'declare function f(a?: string | undefined): void;', + errors: [{ messageId: 'unnecessary' }], + output: 'declare function f(a?: string ): void;', + }, + { + code: ` + declare class bb { + f(a?: string | undefined): void; + } + `, + errors: [{ messageId: 'unnecessary' }], + output: ` + declare class bb { + f(a?: string ): void; + } + `, + }, + { + code: ` + interface ee { + f(a?: string | undefined): void; + } + `, + errors: [{ messageId: 'unnecessary' }], + output: ` + interface ee { + f(a?: string ): void; + } + `, + }, + { + code: ` + interface ee { + new (a?: string | undefined): void; + } + `, + errors: [{ messageId: 'unnecessary' }], + output: ` + interface ee { + new (a?: string ): void; + } + `, + }, + { + code: 'type fn = (a?: string | undefined) => void;', + errors: [{ messageId: 'unnecessary' }], + output: 'type fn = (a?: string ) => void;', + }, + { + only: true, + code: ` + abstract class cc { + abstract f(a?: string | undefined): void; + } + `, + errors: [{ messageId: 'unnecessary' }], + output: ` + abstract class cc { + abstract f(a?: string ): void; + } + `, + }, ], }); From 15e9e42ea26b46fcbb699189fb0ce9c0cb93fece Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 13 Aug 2024 08:12:53 -0500 Subject: [PATCH 17/18] remove only --- .../tests/rules/no-duplicate-type-constituents.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts index e6fef19c8bbe..3460bfb74113 100644 --- a/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts +++ b/packages/eslint-plugin/tests/rules/no-duplicate-type-constituents.test.ts @@ -769,7 +769,6 @@ type T = Record; output: 'type fn = (a?: string ) => void;', }, { - only: true, code: ` abstract class cc { abstract f(a?: string | undefined): void; From 63d64001154be001a8dc7e096ea4e1f2484a3db5 Mon Sep 17 00:00:00 2001 From: Abraham Guo Date: Tue, 27 Aug 2024 07:19:09 -0500 Subject: [PATCH 18/18] remove now-unnecessary check --- .../eslint-plugin/src/rules/no-duplicate-type-constituents.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts index 100de16339e2..4594873943cb 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -232,8 +232,7 @@ export default createRule({ ) { const maybeFunction = maybeIdentifier.parent; if ( - (isFunctionOrFunctionType(maybeFunction) || - maybeFunction.type === AST_NODE_TYPES.TSDeclareFunction) && + isFunctionOrFunctionType(maybeFunction) && maybeFunction.params.includes(maybeIdentifier) && tsutils.isTypeFlagSet( constituentNodeType,