diff --git a/packages/eslint-plugin/docs/rules/no-misused-object-likes.mdx b/packages/eslint-plugin/docs/rules/no-misused-object-likes.mdx new file mode 100644 index 000000000000..5f27f8e41915 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-misused-object-likes.mdx @@ -0,0 +1,34 @@ +--- +description: 'Disallow using `Object.{assign|entries|hasOwn|keys|values}(...)` and the `in` operator on Map/Set objects.' +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/no-misused-object-likes** for documentation. + +Methods like `Object.assign()`, `Object.entries()`, `Object.hasOwn()`, `Object.keys()`, and `Object.values()` can be +used work with collections of data stored in objects. However, when working with `Map` or `Set` objects, even though +they are collections, using these methods are a mistake because they do not properly write to (in the case of +`Object.assign()`) or read from the object. + +This rule prevents such methods from being used on `Map` and `Set` objects. + + + + ```ts + console.log(Object.values(new Set('abc'))); + Object.assign(new Map(), { k: 'v' }); + ``` + + + ```ts + console.log([...new Set('abc').values()]); + new Map().set('k', 'v'); + ``` + + + +{/* Intentionally Omitted: When Not To Use It */} diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 107f369260ec..a5ce4b02ad79 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -73,6 +73,7 @@ export = { '@typescript-eslint/no-magic-numbers': 'error', '@typescript-eslint/no-meaningless-void-operator': 'error', '@typescript-eslint/no-misused-new': 'error', + '@typescript-eslint/no-misused-object-likes': 'error', '@typescript-eslint/no-misused-promises': 'error', '@typescript-eslint/no-mixed-enums': 'error', '@typescript-eslint/no-namespace': 'error', diff --git a/packages/eslint-plugin/src/configs/disable-type-checked.ts b/packages/eslint-plugin/src/configs/disable-type-checked.ts index 7cf867b382f2..a36c74de433c 100644 --- a/packages/eslint-plugin/src/configs/disable-type-checked.ts +++ b/packages/eslint-plugin/src/configs/disable-type-checked.ts @@ -24,6 +24,7 @@ export = { '@typescript-eslint/no-for-in-array': 'off', '@typescript-eslint/no-implied-eval': 'off', '@typescript-eslint/no-meaningless-void-operator': 'off', + '@typescript-eslint/no-misused-object-likes': 'off', '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/no-mixed-enums': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 72c62f3e0122..ab73da569316 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -52,6 +52,7 @@ import noLossOfPrecision from './no-loss-of-precision'; import noMagicNumbers from './no-magic-numbers'; import noMeaninglessVoidOperator from './no-meaningless-void-operator'; import noMisusedNew from './no-misused-new'; +import noMisusedObjectLikes from './no-misused-object-likes'; import noMisusedPromises from './no-misused-promises'; import noMixedEnums from './no-mixed-enums'; import noNamespace from './no-namespace'; @@ -181,6 +182,7 @@ const rules = { 'no-magic-numbers': noMagicNumbers, 'no-meaningless-void-operator': noMeaninglessVoidOperator, 'no-misused-new': noMisusedNew, + 'no-misused-object-likes': noMisusedObjectLikes, 'no-misused-promises': noMisusedPromises, 'no-mixed-enums': noMixedEnums, 'no-namespace': noNamespace, diff --git a/packages/eslint-plugin/src/rules/no-misused-object-likes.ts b/packages/eslint-plugin/src/rules/no-misused-object-likes.ts new file mode 100644 index 000000000000..9c732408a688 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-misused-object-likes.ts @@ -0,0 +1,80 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import type * as ts from 'typescript'; + +import { isSymbolFromDefaultLibrary } from '@typescript-eslint/type-utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import { + createRule, + getParserServices, + getStaticMemberAccessValue, +} from '../util'; + +const METHODS = ['assign', 'entries', 'hasOwn', 'keys', 'values']; + +export default createRule({ + name: 'no-misused-object-likes', + meta: { + type: 'problem', + docs: { + description: `Disallow using \`Object.{${METHODS.join(`|`)}}(...)\` and the \`in\` operator on Map/Set objects`, + requiresTypeChecking: true, + }, + messages: { + misusedObjectLike: + "Don't use {{used}} on {{objectClass}} objects — it will not properly check the contents.", + }, + schema: [], + }, + defaultOptions: [], + + create(context) { + const getSymbolIfFromDefaultLibrary = ( + node: TSESTree.Node, + ): ts.Symbol | undefined => { + const services = getParserServices(context); + const symbol = services.getTypeAtLocation(node).getSymbol(); + return isSymbolFromDefaultLibrary(services.program, symbol) + ? symbol + : undefined; + }; + const checkClassAndReport = (node: TSESTree.Node, used: string): void => { + const objectClass = getSymbolIfFromDefaultLibrary(node)?.name; + if (objectClass && /^(Readonly|Weak)?(Map|Set)$/.test(objectClass)) { + context.report({ + node, + messageId: 'misusedObjectLike', + data: { objectClass, used }, + }); + } + }; + return { + BinaryExpression(node): void { + if (node.operator === 'in') { + checkClassAndReport(node.right, 'the `in` operator'); + } + }, + CallExpression(node): void { + const { arguments: args, callee } = node; + if ( + args.length !== 1 || + callee.type !== AST_NODE_TYPES.MemberExpression + ) { + return; + } + const { object } = callee; + if ( + object.type !== AST_NODE_TYPES.Identifier || + object.name !== 'Object' || + getSymbolIfFromDefaultLibrary(object) + ) { + return; + } + const method = getStaticMemberAccessValue(callee, context); + if (typeof method === 'string' && METHODS.includes(method)) { + checkClassAndReport(args[0], `\`Object.${method}()\``); + } + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-misused-object-likes.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-misused-object-likes.shot new file mode 100644 index 000000000000..8f5e463f8210 --- /dev/null +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-misused-object-likes.shot @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Validating rule docs no-misused-object-likes.mdx code examples ESLint output 1`] = ` +"Incorrect + +console.log(Object.values(new Set('abc'))); + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Don't use \`Object.values()\` on Set objects — it will not properly check the contents. + Object.assign(new Map(), { k: 'v' }); +" +`; + +exports[`Validating rule docs no-misused-object-likes.mdx code examples ESLint output 2`] = ` +"Correct + +console.log([...new Set('abc').values()]); + new Map().set('k', 'v'); +" +`; diff --git a/packages/eslint-plugin/tests/rules/no-misused-object-likes.test.ts b/packages/eslint-plugin/tests/rules/no-misused-object-likes.test.ts new file mode 100644 index 000000000000..6ddf1d9ec5e0 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-misused-object-likes.test.ts @@ -0,0 +1,120 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from '../../src/rules/no-misused-object-likes'; +import { getFixturesRootDir } from '../RuleTester'; + +const rootPath = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: rootPath, + }, + }, +}); + +ruleTester.run('no-misused-object-likes', rule, { + valid: [ + ` + class ExMap extends Map {} + const map = new ExMap(); + Object.keys(map); + `, + ` + class ExMap extends Map {} + const map = new ExMap(); + Object.values(map); + `, + ` + class ExMap extends Map {} + const map = new ExMap(); + Object.entries(map); + `, + ` + const test = {}; + Object.entries(test); + `, + ` + const test = {}; + Object.keys(test); + `, + ` + const test = {}; + Object.values(test); + `, + ` + const test = []; + Object.keys(test); + `, + ` + const test = []; + Object.values(test); + `, + ` + const test = []; + Object.entries(test); + `, + ` + const test = 123; + Object.keys(test); + `, + 'Object.values(new (class Map {})());', + ` + const Object = { keys: () => {} }; + Object.keys(new Map()); + `, + ], + invalid: [ + { + code: ` + const map = new Map(); + const result = Object.keys(map); + `, + errors: [{ messageId: 'misusedObjectLike' }], + }, + { + code: ` + const map = new Map(); + const result = Object.entries(map); + `, + errors: [{ messageId: 'misusedObjectLike' }], + }, + { + code: ` + const map = new Map(); + const result = Object.values(map); + `, + errors: [{ messageId: 'misusedObjectLike' }], + }, + { + code: ` + const set = new Set(); + const result = Object.keys(set); + `, + errors: [{ messageId: 'misusedObjectLike' }], + }, + { + code: ` + const set = new Set(); + const result = Object.entries(set); + `, + errors: [{ messageId: 'misusedObjectLike' }], + }, + { + code: ` + const set = new Set(); + const result = Object.values(set); + `, + errors: [{ messageId: 'misusedObjectLike' }], + }, + { + code: ` + const test = new WeakMap(); + Object.keys(test); + `, + errors: [{ messageId: 'misusedObjectLike' }], + }, + { code: '4 in new Set();', errors: [{ messageId: 'misusedObjectLike' }] }, + ], +}); diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-misused-object-likes.shot b/packages/eslint-plugin/tests/schema-snapshots/no-misused-object-likes.shot new file mode 100644 index 000000000000..6c5b84e1e34e --- /dev/null +++ b/packages/eslint-plugin/tests/schema-snapshots/no-misused-object-likes.shot @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Rule schemas should be convertible to TS types for documentation purposes no-misused-object-likes 1`] = ` +" +# SCHEMA: + +[] + + +# TYPES: + +/** No options declared */ +type Options = [];" +`; diff --git a/packages/typescript-eslint/src/configs/all.ts b/packages/typescript-eslint/src/configs/all.ts index 1c177f1943bf..55743ebf875b 100644 --- a/packages/typescript-eslint/src/configs/all.ts +++ b/packages/typescript-eslint/src/configs/all.ts @@ -86,6 +86,7 @@ export default ( '@typescript-eslint/no-magic-numbers': 'error', '@typescript-eslint/no-meaningless-void-operator': 'error', '@typescript-eslint/no-misused-new': 'error', + '@typescript-eslint/no-misused-object-likes': 'error', '@typescript-eslint/no-misused-promises': 'error', '@typescript-eslint/no-mixed-enums': 'error', '@typescript-eslint/no-namespace': 'error', diff --git a/packages/typescript-eslint/src/configs/disable-type-checked.ts b/packages/typescript-eslint/src/configs/disable-type-checked.ts index b4c2afd20ec8..70075688ab36 100644 --- a/packages/typescript-eslint/src/configs/disable-type-checked.ts +++ b/packages/typescript-eslint/src/configs/disable-type-checked.ts @@ -31,6 +31,7 @@ export default ( '@typescript-eslint/no-for-in-array': 'off', '@typescript-eslint/no-implied-eval': 'off', '@typescript-eslint/no-meaningless-void-operator': 'off', + '@typescript-eslint/no-misused-object-likes': 'off', '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/no-mixed-enums': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off',