diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index d10d9f07990..ec14afa8c90 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -151,6 +151,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int | [`@typescript-eslint/no-unsafe-call`](./docs/rules/no-unsafe-call.md) | Disallows calling an any type value | :white_check_mark: | | :thought_balloon: | | [`@typescript-eslint/no-unsafe-member-access`](./docs/rules/no-unsafe-member-access.md) | Disallows member access on any typed variables | :white_check_mark: | | :thought_balloon: | | [`@typescript-eslint/no-unsafe-return`](./docs/rules/no-unsafe-return.md) | Disallows returning any from a function | :white_check_mark: | | :thought_balloon: | +| [`@typescript-eslint/no-useless-empty-export`](./docs/rules/no-useless-empty-export.md) | Disallow empty exports that don't change anything in a module file | | :wrench: | | | [`@typescript-eslint/no-var-requires`](./docs/rules/no-var-requires.md) | Disallows the use of require statements except in import statements | :white_check_mark: | | | | [`@typescript-eslint/non-nullable-type-assertion-style`](./docs/rules/non-nullable-type-assertion-style.md) | Prefers a non-null assertion over explicit type cast when possible | | :wrench: | :thought_balloon: | | [`@typescript-eslint/prefer-as-const`](./docs/rules/prefer-as-const.md) | Prefer usage of `as const` over literal type | :white_check_mark: | :wrench: | | diff --git a/packages/eslint-plugin/docs/rules/no-useless-empty-export.md b/packages/eslint-plugin/docs/rules/no-useless-empty-export.md new file mode 100644 index 00000000000..0cb24763f12 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-useless-empty-export.md @@ -0,0 +1,45 @@ +# `no-useless-empty-export` + +Disallow empty exports that don't change anything in a module file. + +## Rule Details + +An empty `export {}` statement is sometimes useful in TypeScript code to turn a file that would otherwise be a script file into a module file. +Per the TypeScript Handbook [Modules](https://www.typescriptlang.org/docs/handbook/modules.html) page: + +> In TypeScript, just as in ECMAScript 2015, any file containing a top-level import or export is considered a module. +> Conversely, a file without any top-level import or export declarations is treated as a script whose contents are available in the global scope (and therefore to modules as well). + +However, an `export {}` statement does nothing if there are any other top-level import or export statements in a file. + +Examples of code for this rule: + + + +### ❌ Incorrect + +```ts +export const value = 'Hello, world!'; +export {}; +``` + +```ts +import 'some-other-module'; +export {}; +``` + +### ✅ Correct + +```ts +export const value = 'Hello, world!'; +``` + +```ts +import 'some-other-module'; +``` + +## Attributes + +- [ ] ✅ Recommended +- [x] 🔧 Fixable +- [ ] 💭 Requires type information diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index c052ce4eb37..8b63b936b4a 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -115,6 +115,7 @@ export = { '@typescript-eslint/no-unused-vars': 'error', 'no-use-before-define': 'off', '@typescript-eslint/no-use-before-define': 'error', + '@typescript-eslint/no-useless-empty-export': 'error', 'no-useless-constructor': 'off', '@typescript-eslint/no-useless-constructor': 'error', '@typescript-eslint/no-var-requires': 'error', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 96251c3c22f..3249559746a 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -82,6 +82,7 @@ import noUnusedExpressions from './no-unused-expressions'; import noUnusedVars from './no-unused-vars'; import noUseBeforeDefine from './no-use-before-define'; import noUselessConstructor from './no-useless-constructor'; +import noUselessEmptyExport from './no-useless-empty-export'; import noVarRequires from './no-var-requires'; import nonNullableTypeAssertionStyle from './non-nullable-type-assertion-style'; import objectCurlySpacing from './object-curly-spacing'; @@ -206,6 +207,7 @@ export default { 'no-unused-vars': noUnusedVars, 'no-use-before-define': noUseBeforeDefine, 'no-useless-constructor': noUselessConstructor, + 'no-useless-empty-export': noUselessEmptyExport, 'no-var-requires': noVarRequires, 'non-nullable-type-assertion-style': nonNullableTypeAssertionStyle, 'object-curly-spacing': objectCurlySpacing, diff --git a/packages/eslint-plugin/src/rules/no-useless-empty-export.ts b/packages/eslint-plugin/src/rules/no-useless-empty-export.ts new file mode 100644 index 00000000000..c06c47b8f8e --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-useless-empty-export.ts @@ -0,0 +1,79 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils'; +import * as util from '../util'; + +function isEmptyExport( + node: TSESTree.Node, +): node is TSESTree.ExportNamedDeclaration { + return ( + node.type === AST_NODE_TYPES.ExportNamedDeclaration && + node.specifiers.length === 0 && + !node.declaration + ); +} + +const exportOrImportNodeTypes = new Set([ + AST_NODE_TYPES.ExportAllDeclaration, + AST_NODE_TYPES.ExportDefaultDeclaration, + AST_NODE_TYPES.ExportNamedDeclaration, + AST_NODE_TYPES.ExportSpecifier, + AST_NODE_TYPES.ImportDeclaration, + AST_NODE_TYPES.TSExportAssignment, + AST_NODE_TYPES.TSImportEqualsDeclaration, +]); + +export default util.createRule({ + name: 'no-useless-empty-export', + meta: { + docs: { + description: + "Disallow empty exports that don't change anything in a module file", + recommended: false, + suggestion: true, + }, + fixable: 'code', + hasSuggestions: true, + messages: { + uselessExport: 'Empty export does nothing and can be removed.', + }, + schema: [], + type: 'suggestion', + }, + defaultOptions: [], + create(context) { + function checkNode( + node: TSESTree.Program | TSESTree.TSModuleDeclaration, + ): void { + if (!Array.isArray(node.body)) { + return; + } + + let emptyExport: TSESTree.ExportNamedDeclaration | undefined; + let foundOtherExport = false; + + for (const statement of node.body) { + if (isEmptyExport(statement)) { + emptyExport = statement; + + if (foundOtherExport) { + break; + } + } else if (exportOrImportNodeTypes.has(statement.type)) { + foundOtherExport = true; + } + } + + if (emptyExport && foundOtherExport) { + context.report({ + fix: fixer => fixer.remove(emptyExport!), + messageId: 'uselessExport', + node: emptyExport, + }); + } + } + + return { + Program: checkNode, + TSModuleDeclaration: checkNode, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/no-useless-empty-export.test.ts b/packages/eslint-plugin/tests/rules/no-useless-empty-export.test.ts new file mode 100644 index 00000000000..ea13395ec9e --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-useless-empty-export.test.ts @@ -0,0 +1,125 @@ +/* eslint-disable eslint-comments/no-use */ +// this rule tests the spacing, which prettier will want to fix and break the tests +/* eslint "@typescript-eslint/internal/plugin-test-formatting": ["error", { formatWithPrettier: false }] */ +/* eslint-enable eslint-comments/no-use */ +import rule from '../../src/rules/no-useless-empty-export'; +import { RuleTester } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + parser: '@typescript-eslint/parser', +}); + +const error = { + messageId: 'uselessExport', +} as const; + +ruleTester.run('no-useless-empty-export', rule, { + valid: [ + "declare module '_'", + "import {} from '_';", + "import * as _ from '_';", + 'export = {};', + 'export = 3;', + 'export const _ = {};', + ` + const _ = {}; + export default _; + `, + ` + export * from '_'; + export = {}; + `, + ` + export {}; + `, + ], + invalid: [ + { + code: ` +export const _ = {}; +export {}; + `, + errors: [error], + output: ` +export const _ = {}; + + `, + }, + { + code: ` +export * from '_'; +export {}; + `, + errors: [error], + output: ` +export * from '_'; + + `, + }, + { + code: ` +export {}; +export * from '_'; + `, + errors: [error], + output: ` + +export * from '_'; + `, + }, + { + code: ` +const _ = {}; +export default _; +export {}; + `, + errors: [error], + output: ` +const _ = {}; +export default _; + + `, + }, + { + code: ` +export {}; +const _ = {}; +export default _; + `, + errors: [error], + output: ` + +const _ = {}; +export default _; + `, + }, + { + code: ` +const _ = {}; +export { _ }; +export {}; + `, + errors: [error], + output: ` +const _ = {}; +export { _ }; + + `, + }, + { + code: ` +import _ = require('_'); +export {}; + `, + errors: [error], + output: ` +import _ = require('_'); + + `, + }, + ], +});