diff --git a/packages/comparisons/src/data.json b/packages/comparisons/src/data.json index 57fa601f6..de2292cec 100644 --- a/packages/comparisons/src/data.json +++ b/packages/comparisons/src/data.json @@ -17306,7 +17306,8 @@ "flint": { "name": "typeConstituentDuplicates", "plugin": "ts", - "preset": "logical" + "preset": "logical", + "status": "implemented" }, "oxlint": [ { diff --git a/packages/site/src/content/docs/rules/ts/typeConstituentDuplicates.mdx b/packages/site/src/content/docs/rules/ts/typeConstituentDuplicates.mdx new file mode 100644 index 000000000..c9270be1c --- /dev/null +++ b/packages/site/src/content/docs/rules/ts/typeConstituentDuplicates.mdx @@ -0,0 +1,71 @@ +--- +description: "Reports duplicate types in unions and intersections." +title: "typeConstituentDuplicates" +topic: "rules" +--- + +import { Tabs, TabItem } from "@astrojs/starlight/components"; +import { RuleEquivalents } from "~/components/RuleEquivalents"; +import RuleSummary from "~/components/RuleSummary.astro"; + + + +Duplicate types in unions and intersections are redundant and can make code harder to read. + +## Examples + +### Duplicate Union Types + + + + +```ts +type Value = string | number | string; +``` + + + + +```ts +type Value = string | number; +``` + + + + +### Duplicate Intersection Types + + + + +```ts +type Combined = BaseType & ExtendedType & BaseType; +``` + + + + +```ts +type Combined = BaseType & ExtendedType; +``` + + + + +## Options + +This rule is not configurable. + +## When Not To Use It + +If you have generated types or complex type manipulations where duplicates are intentional, +you may want to disable this rule. + +## Further Reading + +- [TypeScript: Union Types](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types) +- [TypeScript: Intersection Types](https://www.typescriptlang.org/docs/handbook/2/objects.html#intersection-types) + +## Equivalents in Other Linters + + diff --git a/packages/ts/src/plugin.ts b/packages/ts/src/plugin.ts index 0fd9c4c7e..3031fb1d1 100644 --- a/packages/ts/src/plugin.ts +++ b/packages/ts/src/plugin.ts @@ -223,6 +223,7 @@ import sequences from "./rules/sequences.ts"; import shadowedRestrictedNames from "./rules/shadowedRestrictedNames.ts"; import sparseArrays from "./rules/sparseArrays.ts"; import symbolDescriptions from "./rules/symbolDescriptions.ts"; +import typeConstituentDuplicates from "./rules/typeConstituentDuplicates.ts"; import typeofComparisons from "./rules/typeofComparisons.ts"; import unassignedVariables from "./rules/unassignedVariables.ts"; import undefinedVariables from "./rules/undefinedVariables.ts"; @@ -465,6 +466,7 @@ export const ts = createPlugin({ shadowedRestrictedNames, sparseArrays, symbolDescriptions, + typeConstituentDuplicates, typeofComparisons, unassignedVariables, undefinedVariables, diff --git a/packages/ts/src/rules/typeConstituentDuplicates.test.ts b/packages/ts/src/rules/typeConstituentDuplicates.test.ts new file mode 100644 index 000000000..d26a68d9b --- /dev/null +++ b/packages/ts/src/rules/typeConstituentDuplicates.test.ts @@ -0,0 +1,52 @@ +import rule from "./typeConstituentDuplicates.ts"; +import { ruleTester } from "./ruleTester.ts"; + +ruleTester.describe(rule, { + invalid: [ + { + code: `type Value = string | string;`, + snapshot: `type Value = string | string; + ~~~~~~ + Duplicate type in union.`, + }, + { + code: `type Value = 1 | 2 | 1;`, + snapshot: `type Value = 1 | 2 | 1; + ~ + Duplicate type in union.`, + }, + { + code: `type Value = "a" | "b" | "a";`, + snapshot: `type Value = "a" | "b" | "a"; + ~~~ + Duplicate type in union.`, + }, + { + code: `type Value = A & B & A;`, + snapshot: `type Value = A & B & A; + ~ + Duplicate type in intersection.`, + }, + { + code: `type Value = { name: string } & { name: string };`, + snapshot: `type Value = { name: string } & { name: string }; + ~~~~~~~~~~~~~~~~ + Duplicate type in intersection.`, + }, + { + code: `type Value = Array | Array;`, + snapshot: `type Value = Array | Array; + ~~~~~~~~~~~~~ + Duplicate type in union.`, + }, + ], + valid: [ + `type Value = string | number;`, + `type Value = 1 | 2 | 3;`, + `type Value = "a" | "b" | "c";`, + `type Value = A & B & C;`, + `type Value = { name: string } & { age: number };`, + `type Value = Array | Array;`, + `type Value = string;`, + ], +}); diff --git a/packages/ts/src/rules/typeConstituentDuplicates.ts b/packages/ts/src/rules/typeConstituentDuplicates.ts new file mode 100644 index 000000000..d2594d29b --- /dev/null +++ b/packages/ts/src/rules/typeConstituentDuplicates.ts @@ -0,0 +1,60 @@ +import { typescriptLanguage } from "@flint.fyi/typescript-language"; +import type ts from "typescript"; + +import { ruleCreator } from "./ruleCreator.ts"; + +export default ruleCreator.createRule(typescriptLanguage, { + about: { + description: "Reports duplicate types in unions and intersections.", + id: "typeConstituentDuplicates", + presets: ["logical"], + }, + messages: { + duplicateIntersection: { + primary: "Duplicate type in intersection.", + secondary: ["This type appears more than once in the intersection."], + suggestions: ["Remove the duplicate type."], + }, + duplicateUnion: { + primary: "Duplicate type in union.", + secondary: ["This type appears more than once in the union."], + suggestions: ["Remove the duplicate type."], + }, + }, + setup(context) { + function checkDuplicates( + types: ts.NodeArray, + sourceFile: ts.SourceFile, + messageId: "duplicateIntersection" | "duplicateUnion", + ) { + const seen = new Map(); + + for (const typeNode of types) { + const text = typeNode.getText(sourceFile); + + if (seen.has(text)) { + context.report({ + message: messageId, + range: { + begin: typeNode.getStart(sourceFile), + end: typeNode.getEnd(), + }, + }); + } else { + seen.set(text, typeNode); + } + } + } + + return { + visitors: { + IntersectionType(node, { sourceFile }) { + checkDuplicates(node.types, sourceFile, "duplicateIntersection"); + }, + UnionType(node, { sourceFile }) { + checkDuplicates(node.types, sourceFile, "duplicateUnion"); + }, + }, + }; + }, +});