diff --git a/packages/eslint-plugin/docs/rules/no-unsafe-declaration-merging.md b/packages/eslint-plugin/docs/rules/no-unsafe-declaration-merging.md new file mode 100644 index 00000000000..9c917aa3ee0 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-unsafe-declaration-merging.md @@ -0,0 +1,54 @@ +--- +description: 'Disallow unsafe declaration merging.' +--- + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/no-unsafe-declaration-merging** for documentation. + +TypeScript's "declaration merging" supports merging separate declarations with the same name. + +Declaration merging between classes and interfaces is unsafe. +The TypeScript compiler doesn't check whether properties are initialized, which can cause lead to TypeScript not detecting code that will cause runtime errors. + +```ts +interface Foo { + nums: number[]; +} + +class Foo {} + +const foo = new Foo(); + +foo.nums.push(1); // Runtime Error: Cannot read properties of undefined. +``` + +## Examples + + + +### ❌ Incorrect + +```ts +interface Foo {} + +class Foo {} +``` + +### ✅ Correct + +```ts +interface Foo {} +class Bar implements Foo {} + +namespace Baz {} +namespace Baz {} +enum Baz {} + +namespace Qux {} +function Qux() {} +``` + +## Further Reading + +- [Declaration Merging](https://www.typescriptlang.org/docs/handbook/declaration-merging.html) diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 8742d36b208..1f6530ead3e 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -108,6 +108,7 @@ export = { '@typescript-eslint/no-unsafe-argument': 'error', '@typescript-eslint/no-unsafe-assignment': 'error', '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-declaration-merging': 'error', '@typescript-eslint/no-unsafe-member-access': 'error', '@typescript-eslint/no-unsafe-return': 'error', 'no-unused-expressions': 'off', diff --git a/packages/eslint-plugin/src/configs/strict.ts b/packages/eslint-plugin/src/configs/strict.ts index a9c91f7c1ca..ccd44b85a0a 100644 --- a/packages/eslint-plugin/src/configs/strict.ts +++ b/packages/eslint-plugin/src/configs/strict.ts @@ -27,6 +27,7 @@ export = { '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'warn', '@typescript-eslint/no-unnecessary-condition': 'warn', '@typescript-eslint/no-unnecessary-type-arguments': 'warn', + '@typescript-eslint/no-unsafe-declaration-merging': 'warn', 'no-useless-constructor': 'off', '@typescript-eslint/no-useless-constructor': 'warn', '@typescript-eslint/non-nullable-type-assertion-style': 'warn', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 29a47ec2384..851661400fb 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -78,6 +78,7 @@ import noUnnecessaryTypeConstraint from './no-unnecessary-type-constraint'; import noUnsafeArgument from './no-unsafe-argument'; import noUnsafeAssignment from './no-unsafe-assignment'; import noUnsafeCall from './no-unsafe-call'; +import noUnsafeDeclarationMerging from './no-unsafe-declaration-merging'; import noUnsafeMemberAccess from './no-unsafe-member-access'; import noUnsafeReturn from './no-unsafe-return'; import noUnusedExpressions from './no-unused-expressions'; @@ -207,6 +208,7 @@ export default { 'no-unsafe-argument': noUnsafeArgument, 'no-unsafe-assignment': noUnsafeAssignment, 'no-unsafe-call': noUnsafeCall, + 'no-unsafe-declaration-merging': noUnsafeDeclarationMerging, 'no-unsafe-member-access': noUnsafeMemberAccess, 'no-unsafe-return': noUnsafeReturn, 'no-unused-expressions': noUnusedExpressions, diff --git a/packages/eslint-plugin/src/rules/no-unsafe-declaration-merging.ts b/packages/eslint-plugin/src/rules/no-unsafe-declaration-merging.ts new file mode 100644 index 00000000000..7982fe5c5f4 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unsafe-declaration-merging.ts @@ -0,0 +1,52 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import * as ts from 'typescript'; + +import * as util from '../util'; + +export default util.createRule({ + name: 'no-unsafe-declaration-merging', + meta: { + type: 'problem', + docs: { + description: 'Disallow unsafe declaration merging', + recommended: 'strict', + requiresTypeChecking: true, + }, + messages: { + unsafeMerging: + 'Unsafe declaration merging between classes and interfaces.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + const parserServices = util.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + + function checkUnsafeDeclaration( + node: TSESTree.Identifier, + unsafeKind: ts.SyntaxKind, + ): void { + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); + const type = checker.getTypeAtLocation(tsNode); + const symbol = type.getSymbol(); + if (symbol?.declarations?.some(decl => decl.kind === unsafeKind)) { + context.report({ + node, + messageId: 'unsafeMerging', + }); + } + } + + return { + ClassDeclaration(node): void { + if (node.id) { + checkUnsafeDeclaration(node.id, ts.SyntaxKind.InterfaceDeclaration); + } + }, + TSInterfaceDeclaration(node): void { + checkUnsafeDeclaration(node.id, ts.SyntaxKind.ClassDeclaration); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/no-unsafe-declaration-merging.test.ts b/packages/eslint-plugin/tests/rules/no-unsafe-declaration-merging.test.ts new file mode 100644 index 00000000000..e92c1ed36e7 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-unsafe-declaration-merging.test.ts @@ -0,0 +1,124 @@ +import rule from '../../src/rules/no-unsafe-declaration-merging'; +import { getFixturesRootDir, RuleTester } from '../RuleTester'; + +const rootPath = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + tsconfigRootDir: rootPath, + project: './tsconfig.json', + }, +}); + +ruleTester.run('no-unsafe-declaration-merging', rule, { + valid: [ + ` +interface Foo {} +class Bar implements Foo {} + `, + ` +namespace Foo {} +namespace Foo {} + `, + ` +enum Foo {} +namespace Foo {} + `, + ` +namespace Fooo {} +function Foo() {} + `, + ` +const Foo = class {}; + `, + ` +interface Foo { + props: string; +} + +function bar() { + return class Foo {}; +} + `, + ` +interface Foo { + props: string; +} + +(function bar() { + class Foo {} +})(); + `, + ` +declare global { + interface Foo {} +} + +class Foo {} + `, + ], + invalid: [ + { + code: ` +interface Foo {} +class Foo {} + `, + errors: [ + { + messageId: 'unsafeMerging', + line: 2, + column: 11, + }, + { + messageId: 'unsafeMerging', + line: 3, + column: 7, + }, + ], + }, + { + code: ` +namespace Foo { + export interface Bar {} +} +namespace Foo { + export class Bar {} +} + `, + errors: [ + { + messageId: 'unsafeMerging', + line: 3, + column: 20, + }, + { + messageId: 'unsafeMerging', + line: 6, + column: 16, + }, + ], + }, + { + code: ` +declare global { + interface Foo {} + class Foo {} +} + `, + errors: [ + { + messageId: 'unsafeMerging', + line: 3, + column: 13, + }, + { + messageId: 'unsafeMerging', + line: 4, + column: 9, + }, + ], + }, + ], +});