Skip to content

feat(eslint-plugin): [no-duplicate-type-constituents] prevent unnecessary | undefined for optional parameters #9479

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Tabs>
<TabItem value="❌ Incorrect">

Expand All @@ -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) => {};
```

</TabItem>
Expand All @@ -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) => {};
```

</TabItem>
Expand Down
190 changes: 115 additions & 75 deletions packages/eslint-plugin/src/rules/no-duplicate-type-constituents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand All @@ -12,7 +19,7 @@ export type Options = [
},
];

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

const astIgnoreKeys = new Set(['range', 'loc', 'parent']);

Expand Down Expand Up @@ -79,6 +86,8 @@ export default createRule<Options, MessageIds>({
fixable: 'code',
messages: {
duplicate: '{{type}} type constituent is duplicated with {{previous}}.',
unnecessary:
'Explicit undefined is unnecessary on an optional parameter.',
},
schema: [
{
Expand All @@ -103,9 +112,14 @@ export default createRule<Options, MessageIds>({
],
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<Type, TSESTree.TypeNode>();
node.types.reduce<TSESTree.TypeNode[]>(
Expand All @@ -116,94 +130,120 @@ export default createRule<Options, MessageIds>({
return uniqueConstituents;
}

const duplicatedPreviousConstituentInAst = uniqueConstituents.find(
ele => isSameAstNode(ele, constituentNode),
);
if (duplicatedPreviousConstituentInAst) {
reportDuplicate(
{
duplicated: constituentNode,
duplicatePrevious: duplicatedPreviousConstituentInAst,
},
node,
const report = (
messageId: MessageIds,
data?: Record<string, unknown>,
): 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');
}
}
}
}),
}),
};
},
Expand Down
2 changes: 1 addition & 1 deletion packages/eslint-plugin/src/rules/prefer-find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy