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");
+ },
+ },
+ };
+ },
+});