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 bc7be7e4bc2f..e254a6ca4c3e 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) => {}; ``` 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 d92de657aa47..4594873943cb 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts @@ -2,8 +2,15 @@ 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 * as ts from 'typescript'; -import { createRule, getParserServices } from '../util'; +import { + createRule, + getParserServices, + isFunctionOrFunctionType, + nullThrows, + NullThrowsReasons, +} from '../util'; export type Options = [ { @@ -12,7 +19,7 @@ export type Options = [ }, ]; -export type MessageIds = 'duplicate'; +export type MessageIds = 'duplicate' | 'unnecessary'; const astIgnoreKeys = new Set(['range', 'loc', 'parent']); @@ -79,6 +86,8 @@ export default createRule({ fixable: 'code', messages: { duplicate: '{{type}} type constituent is duplicated with {{previous}}.', + unnecessary: + 'Explicit undefined is unnecessary on an optional parameter.', }, schema: [ { @@ -103,9 +112,14 @@ export default createRule({ ], create(context, [{ ignoreIntersections, ignoreUnions }]) { const parserServices = getParserServices(context); + const { sourceCode } = context; function checkDuplicate( node: TSESTree.TSIntersectionType | TSESTree.TSUnionType, + forEachNodeType?: ( + constituentNodeType: Type, + report: (messageId: MessageIds) => void, + ) => void, ): void { const cachedTypeMap = new Map(); node.types.reduce( @@ -116,94 +130,120 @@ export default createRule({ return uniqueConstituents; } - const duplicatedPreviousConstituentInAst = uniqueConstituents.find( - ele => isSameAstNode(ele, constituentNode), - ); - if (duplicatedPreviousConstituentInAst) { - reportDuplicate( - { - duplicated: constituentNode, - duplicatePrevious: duplicatedPreviousConstituentInAst, - }, - node, + 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, ); - return uniqueConstituents; - } - const duplicatedPreviousConstituentInType = - cachedTypeMap.get(constituentNodeType); - if (duplicatedPreviousConstituentInType) { - reportDuplicate( - { - duplicated: constituentNode, - duplicatePrevious: duplicatedPreviousConstituentInType, + 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, }, - node, - ); + fix: fixer => + [ + beforeUnionOrIntersectionToken, + ...bracketBeforeTokens, + constituentNode, + ...bracketAfterTokens, + afterUnionOrIntersectionToken, + ].flatMap(token => (token ? fixer.remove(token) : [])), + }); + }; + 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); cachedTypeMap.set(constituentNodeType, constituentNode); return [...uniqueConstituents, constituentNode]; }, [], ); } - function reportDuplicate( - duplicateConstituent: { - duplicated: TSESTree.TypeNode; - duplicatePrevious: TSESTree.TypeNode; - }, - parentNode: TSESTree.TSIntersectionType | TSESTree.TSUnionType, - ): 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( - beforeUnionOrIntersectionToken, - duplicateConstituent.duplicated, - ); - 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, - }; - context.report({ - data: { - type: - parentNode.type === AST_NODE_TYPES.TSIntersectionType - ? 'Intersection' - : 'Union', - previous: context.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, + TSUnionType: (node): void => + checkDuplicate(node, (constituentNodeType, report) => { + 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; + if ( + isFunctionOrFunctionType(maybeFunction) && + maybeFunction.params.includes(maybeIdentifier) && + tsutils.isTypeFlagSet( + constituentNodeType, + ts.TypeFlags.Undefined, + ) + ) { + report('unnecessary'); + } + } + } + }), }), }; }, diff --git a/packages/eslint-plugin/src/rules/prefer-find.ts b/packages/eslint-plugin/src/rules/prefer-find.ts index 587cd5d0f03d..7d1c6fed86dc 100644 --- a/packages/eslint-plugin/src/rules/prefer-find.ts +++ b/packages/eslint-plugin/src/rules/prefer-find.ts @@ -332,7 +332,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/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 986abb42d50c..d19e5032506b 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) => {}; " `; 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 afc053781bc3..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 @@ -160,6 +160,7 @@ type T = Record; { code: 'type T = A | A | string;', }, + { code: '(a: string | undefined) => {};' }, ], invalid: [ { @@ -674,5 +675,111 @@ type T = Record; }, ], }, + { + code: '(a?: string | undefined) => {};', + errors: [{ messageId: 'unnecessary' }], + output: '(a?: string ) => {};', + }, + { + code: ` + type T = undefined; + (arg?: T | string) => {}; + `, + errors: [{ messageId: 'unnecessary' }], + output: ` + type T = undefined; + (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;', + }, + { + code: ` + abstract class cc { + abstract f(a?: string | undefined): void; + } + `, + errors: [{ messageId: 'unnecessary' }], + output: ` + abstract class cc { + abstract f(a?: string ): void; + } + `, + }, ], });