Repository: un-ts/eslint-plugin-import-x
Pull request #116 (2024-07-18 21:31:25)

feat: Add `ignoreUnusedTypeExports` option to `no-unused-modules`
Direct port of https://github.com/import-js/eslint-plugin-import/pull/3011.

Fixes: https://github.com/un-ts/eslint-plugin-import-x/issues/86.

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: cfe34cdf93c45baa1709eb9f2ba3b51707f29432

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/silverwind/eslint-plugin-import/new/ignoreUnusedTypeExports?filename=.changeset/fast-turkeys-compete.md&value=---%0A%22eslint-plugin-import-x%22%3A%20patch%0A---%0A%0Afeat%3A%20Add%20%60ignoreUnusedTypeExports%60%20option%20to%20%60no-unused-modules%60%0A)


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/un-ts/eslint-plugin-import-x/pr/116/builds/524254) or the icon next to each commit SHA.

Patch:
diff --git a/.changeset/hot-fireants-provide.md b/.changeset/hot-fireants-provide.md
new file mode 100644
index 00000000..db6070e0
--- /dev/null
+++ b/.changeset/hot-fireants-provide.md
@@ -0,0 +1,5 @@
+---
+"eslint-plugin-import-x": minor
+---
+
+Add `ignoreUnusedTypeExports` option to `no-unused-modules`
diff --git a/docs/rules/no-unused-modules.md b/docs/rules/no-unused-modules.md
index b11becdb..1f0f34a9 100644
--- a/docs/rules/no-unused-modules.md
+++ b/docs/rules/no-unused-modules.md
@@ -29,6 +29,7 @@ This rule takes the following option:
 
 - **`missingExports`**: if `true`, files without any exports are reported (defaults to `false`)
 - **`unusedExports`**: if `true`, exports without any static usage within other modules are reported (defaults to `false`)
+- **`ignoreUnusedTypeExports`**: if `true`, TypeScript type exports without any static usage within other modules are reported (defaults to `false` and has no effect unless `unusedExports` is `true`)
 - `src`: an array with files/paths to be analyzed. It only applies to unused exports. Defaults to `process.cwd()`, if not provided
 - `ignoreExports`: an array with files/paths for which unused exports will not be reported (e.g module entry points in a published package)
 
@@ -120,6 +121,16 @@ export function doAnything() {
 export default 5 // will not be reported
 ```
 
+### Unused exports with `ignoreUnusedTypeExports` set to `true`
+
+The following will not be reported:
+
+```ts
+export type Foo = {}; // will not be reported
+export interface Foo = {}; // will not be reported
+export enum Foo {}; // will not be reported
+```
+
 #### Important Note
 
 Exports from files listed as a main file (`main`, `browser`, or `bin` fields in `package.json`) will be ignored by default. This only applies if the `package.json` is not set to `private: true`
diff --git a/src/rules/no-unused-modules.ts b/src/rules/no-unused-modules.ts
index 20fa269b..7c759727 100644
--- a/src/rules/no-unused-modules.ts
+++ b/src/rules/no-unused-modules.ts
@@ -37,33 +37,36 @@ const { AST_NODE_TYPES } = TSESTree
 
 function forEachDeclarationIdentifier(
   declaration: TSESTree.Node | null,
-  cb: (name: string) => void,
+  cb: (name: string, isTypeExport: boolean) => void,
 ) {
   if (declaration) {
-    if (
-      declaration.type === AST_NODE_TYPES.FunctionDeclaration ||
-      declaration.type === AST_NODE_TYPES.ClassDeclaration ||
+    const isTypeDeclaration =
       declaration.type === AST_NODE_TYPES.TSInterfaceDeclaration ||
       declaration.type === AST_NODE_TYPES.TSTypeAliasDeclaration ||
       declaration.type === AST_NODE_TYPES.TSEnumDeclaration
+
+    if (
+      declaration.type === AST_NODE_TYPES.FunctionDeclaration ||
+      declaration.type === AST_NODE_TYPES.ClassDeclaration ||
+      isTypeDeclaration
     ) {
-      cb(declaration.id!.name)
+      cb(declaration.id!.name, isTypeDeclaration)
     } else if (declaration.type === AST_NODE_TYPES.VariableDeclaration) {
       for (const { id } of declaration.declarations) {
         if (id.type === AST_NODE_TYPES.ObjectPattern) {
           recursivePatternCapture(id, pattern => {
             if (pattern.type === AST_NODE_TYPES.Identifier) {
-              cb(pattern.name)
+              cb(pattern.name, false)
             }
           })
         } else if (id.type === AST_NODE_TYPES.ArrayPattern) {
           for (const el of id.elements) {
             if (el?.type === AST_NODE_TYPES.Identifier) {
-              cb(el.name)
+              cb(el.name, false)
             }
           }
         } else {
-          cb(id.name)
+          cb(id.name, false)
         }
       }
     }
@@ -397,6 +400,7 @@ type Options = {
   ignoreExports?: string[]
   missingExports?: string[]
   unusedExports?: boolean
+  ignoreUnusedTypeExports?: boolean
 }
 
 type MessageId = 'notFound' | 'unused'
@@ -441,6 +445,10 @@ export = createRule<Options[], MessageId>({
             description: 'report exports without any usage',
             type: 'boolean',
           },
+          ignoreUnusedTypeExports: {
+            description: 'ignore type exports without any usage',
+            type: 'boolean',
+          },
         },
         anyOf: [
           {
@@ -482,6 +490,7 @@ export = createRule<Options[], MessageId>({
       ignoreExports = [],
       missingExports,
       unusedExports,
+      ignoreUnusedTypeExports,
     } = context.options[0] || {}
 
     if (unusedExports) {
@@ -495,6 +504,10 @@ export = createRule<Options[], MessageId>({
         return
       }
 
+      if (ignoreUnusedTypeExports) {
+        return
+      }
+
       if (ignoredFiles.has(filename)) {
         return
       }
@@ -519,11 +532,19 @@ export = createRule<Options[], MessageId>({
       exportCount.set(AST_NODE_TYPES.ImportNamespaceSpecifier, namespaceImports)
     }
 
-    const checkUsage = (node: TSESTree.Node, exportedValue: string) => {
+    const checkUsage = (
+      node: TSESTree.Node,
+      exportedValue: string,
+      isTypeExport: boolean,
+    ) => {
       if (!unusedExports) {
         return
       }
 
+      if (isTypeExport && ignoreUnusedTypeExports) {
+        return
+      }
+
       if (ignoredFiles.has(filename)) {
         return
       }
@@ -991,14 +1012,14 @@ export = createRule<Options[], MessageId>({
         checkExportPresence(node)
       },
       ExportDefaultDeclaration(node) {
-        checkUsage(node, AST_NODE_TYPES.ImportDefaultSpecifier)
+        checkUsage(node, AST_NODE_TYPES.ImportDefaultSpecifier, false)
       },
       ExportNamedDeclaration(node) {
         for (const specifier of node.specifiers) {
-          checkUsage(specifier, getValue(specifier.exported))
+          checkUsage(specifier, getValue(specifier.exported), false)
         }
-        forEachDeclarationIdentifier(node.declaration, name => {
-          checkUsage(node, name)
+        forEachDeclarationIdentifier(node.declaration, (name, isTypeExport) => {
+          checkUsage(node, name, isTypeExport)
         })
       },
     }


Test patch:
diff --git a/test/rules/no-unused-modules.spec.ts b/test/rules/no-unused-modules.spec.ts
index 6435dfdc..ea4de899 100644
--- a/test/rules/no-unused-modules.spec.ts
+++ b/test/rules/no-unused-modules.spec.ts
@@ -38,6 +38,15 @@ const unusedExportsTypescriptOptions = [
   },
 ]
 
+const unusedExportsTypescriptIgnoreUnusedTypesOptions = [
+  {
+    unusedExports: true,
+    ignoreUnusedTypeExports: true,
+    src: [testFilePath('./no-unused-modules/typescript')],
+    ignoreExports: undefined,
+  },
+]
+
 const unusedExportsJsxOptions = [
   {
     unusedExports: true,
@@ -1332,6 +1341,66 @@ describe('TypeScript', () => {
   })
 })
 
+describe('ignoreUnusedTypeExports', () => {
+  const parser = parsers.TS
+
+  typescriptRuleTester.run('no-unused-modules', rule, {
+    valid: [
+      // unused vars should not report
+      test({
+        options: unusedExportsTypescriptIgnoreUnusedTypesOptions,
+        code: `export interface c {};`,
+        parser,
+        filename: testFilePath(
+          './no-unused-modules/typescript/file-ts-c-unused.ts',
+        ),
+      }),
+      test({
+        options: unusedExportsTypescriptIgnoreUnusedTypesOptions,
+        code: `export type d = {};`,
+        parser,
+        filename: testFilePath(
+          './no-unused-modules/typescript/file-ts-d-unused.ts',
+        ),
+      }),
+      test({
+        options: unusedExportsTypescriptIgnoreUnusedTypesOptions,
+        code: `export enum e { f };`,
+        parser,
+        filename: testFilePath(
+          './no-unused-modules/typescript/file-ts-e-unused.ts',
+        ),
+      }),
+      // used vars should not report
+      test({
+        options: unusedExportsTypescriptIgnoreUnusedTypesOptions,
+        code: `export interface c {};`,
+        parser,
+        filename: testFilePath(
+          './no-unused-modules/typescript/file-ts-c-used-as-type.ts',
+        ),
+      }),
+      test({
+        options: unusedExportsTypescriptIgnoreUnusedTypesOptions,
+        code: `export type d = {};`,
+        parser,
+        filename: testFilePath(
+          './no-unused-modules/typescript/file-ts-d-used-as-type.ts',
+        ),
+      }),
+      test({
+        options: unusedExportsTypescriptIgnoreUnusedTypesOptions,
+        code: `export enum e { f };`,
+        parser,
+        filename: testFilePath(
+          './no-unused-modules/typescript/file-ts-e-used-as-type.ts',
+        ),
+      }),
+    ],
+    invalid: [],
+  })
+})
+
 describe('correctly work with JSX only files', () => {
   jsxRuleTester.run('no-unused-modules', rule, {
     valid: [