From 7b2c8ccf3e56dd8be46403ca67495d6bc6106b1d Mon Sep 17 00:00:00 2001 From: Stepan Kuzmin Date: Mon, 15 Sep 2025 16:55:11 +0300 Subject: [PATCH 1/2] WIP: `no-object-methods-on-collections` --- .../no-object-methods-on-collections.mdx | 34 +++ .../eslint-plugin/src/configs/eslintrc/all.ts | 1 + .../configs/eslintrc/disable-type-checked.ts | 1 + .../eslint-plugin/src/configs/flat/all.ts | 1 + .../src/configs/flat/disable-type-checked.ts | 1 + packages/eslint-plugin/src/rules/index.ts | 2 + .../rules/no-object-methods-on-collections.ts | 119 +++++++++ .../no-object-methods-on-collections.shot | 10 + .../no-object-methods-on-collections.test.ts | 228 ++++++++++++++++++ .../no-object-methods-on-collections.shot | 10 + 10 files changed, 407 insertions(+) create mode 100644 packages/eslint-plugin/docs/rules/no-object-methods-on-collections.mdx create mode 100644 packages/eslint-plugin/src/rules/no-object-methods-on-collections.ts create mode 100644 packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-object-methods-on-collections.shot create mode 100644 packages/eslint-plugin/tests/rules/no-object-methods-on-collections.test.ts create mode 100644 packages/eslint-plugin/tests/schema-snapshots/no-object-methods-on-collections.shot diff --git a/packages/eslint-plugin/docs/rules/no-object-methods-on-collections.mdx b/packages/eslint-plugin/docs/rules/no-object-methods-on-collections.mdx new file mode 100644 index 000000000000..c54b11aaaa06 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-object-methods-on-collections.mdx @@ -0,0 +1,34 @@ +--- +description: 'Disallow using Object.keys, Object.values, and Object.entries on Map and Set instances.' +--- + +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-object-methods-on-collections** for documentation. + +Methods like `Object.entries()`, `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/eslintrc/all.ts b/packages/eslint-plugin/src/configs/eslintrc/all.ts index 34f7fd540450..1cbe6157c75e 100644 --- a/packages/eslint-plugin/src/configs/eslintrc/all.ts +++ b/packages/eslint-plugin/src/configs/eslintrc/all.ts @@ -80,6 +80,7 @@ export = { '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', '@typescript-eslint/no-non-null-asserted-optional-chain': 'error', '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-object-methods-on-collections': 'error', 'no-redeclare': 'off', '@typescript-eslint/no-redeclare': 'error', '@typescript-eslint/no-redundant-type-constituents': 'error', diff --git a/packages/eslint-plugin/src/configs/eslintrc/disable-type-checked.ts b/packages/eslint-plugin/src/configs/eslintrc/disable-type-checked.ts index 6853a151722d..bea0b1b8eb4d 100644 --- a/packages/eslint-plugin/src/configs/eslintrc/disable-type-checked.ts +++ b/packages/eslint-plugin/src/configs/eslintrc/disable-type-checked.ts @@ -27,6 +27,7 @@ export = { '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/no-misused-spread': 'off', '@typescript-eslint/no-mixed-enums': 'off', + '@typescript-eslint/no-object-methods-on-collections': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', '@typescript-eslint/no-unnecessary-condition': 'off', diff --git a/packages/eslint-plugin/src/configs/flat/all.ts b/packages/eslint-plugin/src/configs/flat/all.ts index 777028d8580c..bfdfc1e41528 100644 --- a/packages/eslint-plugin/src/configs/flat/all.ts +++ b/packages/eslint-plugin/src/configs/flat/all.ts @@ -93,6 +93,7 @@ export default ( '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', '@typescript-eslint/no-non-null-asserted-optional-chain': 'error', '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-object-methods-on-collections': 'error', 'no-redeclare': 'off', '@typescript-eslint/no-redeclare': 'error', '@typescript-eslint/no-redundant-type-constituents': 'error', diff --git a/packages/eslint-plugin/src/configs/flat/disable-type-checked.ts b/packages/eslint-plugin/src/configs/flat/disable-type-checked.ts index 5a48e1722775..03df23dbaad0 100644 --- a/packages/eslint-plugin/src/configs/flat/disable-type-checked.ts +++ b/packages/eslint-plugin/src/configs/flat/disable-type-checked.ts @@ -34,6 +34,7 @@ export default ( '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/no-misused-spread': 'off', '@typescript-eslint/no-mixed-enums': 'off', + '@typescript-eslint/no-object-methods-on-collections': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', '@typescript-eslint/no-unnecessary-condition': 'off', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 2a82c7e18c6b..9980c1b74920 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -59,6 +59,7 @@ import noNamespace from './no-namespace'; import noNonNullAssertedNullishCoalescing from './no-non-null-asserted-nullish-coalescing'; import noNonNullAssertedOptionalChain from './no-non-null-asserted-optional-chain'; import noNonNullAssertion from './no-non-null-assertion'; +import noObjectMethodsOnCollections from './no-object-methods-on-collections'; import noRedeclare from './no-redeclare'; import noRedundantTypeConstituents from './no-redundant-type-constituents'; import noRequireImports from './no-require-imports'; @@ -192,6 +193,7 @@ const rules = { 'no-non-null-asserted-nullish-coalescing': noNonNullAssertedNullishCoalescing, 'no-non-null-asserted-optional-chain': noNonNullAssertedOptionalChain, 'no-non-null-assertion': noNonNullAssertion, + 'no-object-methods-on-collections': noObjectMethodsOnCollections, 'no-redeclare': noRedeclare, 'no-redundant-type-constituents': noRedundantTypeConstituents, 'no-require-imports': noRequireImports, diff --git a/packages/eslint-plugin/src/rules/no-object-methods-on-collections.ts b/packages/eslint-plugin/src/rules/no-object-methods-on-collections.ts new file mode 100644 index 000000000000..f019c06d4409 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-object-methods-on-collections.ts @@ -0,0 +1,119 @@ +import { ESLintUtils, AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import { createRule } from '../util'; + +const COLLECTION_TYPES = ['Map', 'Set', 'WeakMap', 'WeakSet'] as const; + +const ALTERNATIVES = { + 'Object.assign': { + Map: 'map.set(key, value) or new Map([...map, ...otherMap])', + Set: 'set.add(value) or new Set([...set, ...otherSet])', + WeakMap: 'map.set(key, value)', + WeakSet: 'set.add(value)', + }, + 'Object.entries': { + Map: 'Array.from(map.entries())', + Set: 'Array.from(set.entries())', + WeakMap: 'Array.from(map.entries())', + WeakSet: 'Array.from(set.entries())', + }, + 'Object.keys': { + Map: 'Array.from(map.keys())', + Set: 'Array.from(set.values())', + WeakMap: 'Array.from(map.keys())', + WeakSet: 'Array.from(set.values())', + }, + 'Object.values': { + Map: 'Array.from(map.values())', + Set: 'Array.from(set.values())', + WeakMap: 'Array.from(map.values())', + WeakSet: 'Array.from(set.values())', + }, +}; + +export default createRule({ + name: 'no-object-methods-on-collections', + meta: { + type: 'problem', + docs: { + description: 'Disallow using Object methods on Map and Set instances', + requiresTypeChecking: true, + }, + messages: { + noObjectMethodsOnCollections: + 'Using {{method}}() on a {{type}} is incorrect. Use {{alternative}} instead.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + const parserServices = ESLintUtils.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + + return { + CallExpression(node) { + // Is it Object.keys/values/entries/assign() call? + if ( + node.callee.type !== AST_NODE_TYPES.MemberExpression || + node.callee.object.type !== AST_NODE_TYPES.Identifier || + node.callee.object.name !== 'Object' || + node.callee.property.type !== AST_NODE_TYPES.Identifier || + !['keys', 'values', 'entries', 'assign'].includes( + node.callee.property.name, + ) + ) { + return; + } + + const methodName = node.callee.property.name; + + // For keys/values/entries, we need exactly 1 argument + // For assign, we need at least 1 argument (the target) + if (methodName === 'assign') { + if (node.arguments.length < 1) { + return; + } + } else { + if (node.arguments.length !== 1) { + return; + } + } + + // Get argument type as a string + const argument = node.arguments[0]; + const tsNode = parserServices.esTreeNodeToTSNodeMap.get(argument); + const type = checker.getTypeAtLocation(tsNode); + const typeString = checker.typeToString(type); + + // Skip unknown types + if (typeString === 'any' || typeString === 'unknown') { + return; + } + + // Is the argument a collection type? + const collectionType = COLLECTION_TYPES.find(t => + typeString.includes(t), + ); + if (!collectionType) { + return; + } + + const fullMethodName = + `Object.${methodName}` as keyof typeof ALTERNATIVES; + const alternative = + ALTERNATIVES[fullMethodName][collectionType] || + `the ${collectionType}'s own methods`; + + context.report({ + node, + messageId: 'noObjectMethodsOnCollections', + data: { + type: collectionType, + alternative, + method: fullMethodName, + }, + }); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-object-methods-on-collections.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-object-methods-on-collections.shot new file mode 100644 index 000000000000..502e49b8e3e3 --- /dev/null +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-object-methods-on-collections.shot @@ -0,0 +1,10 @@ +Incorrect + +console.log(Object.values(new Set('abc'))); + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Using Object.values() on a Set always returns an empty array. Use Array.from(set.values()) instead. + Object.assign(new Map(), { k: 'v' }); + +Correct + +console.log([...new Set('abc').values()]); + new Map().set('k', 'v'); diff --git a/packages/eslint-plugin/tests/rules/no-object-methods-on-collections.test.ts b/packages/eslint-plugin/tests/rules/no-object-methods-on-collections.test.ts new file mode 100644 index 000000000000..00a2b38f102f --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-object-methods-on-collections.test.ts @@ -0,0 +1,228 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from '../../src/rules/no-object-methods-on-collections'; +import { getFixturesRootDir } from '../RuleTester'; + +const rootDir = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: rootDir, + }, + }, +}); + +ruleTester.run('no-object-methods-on-collections', rule, { + valid: [ + { + code: ` + const test = {}; + Object.entries(test); + `, + }, + { + code: ` + const test = {}; + Object.keys(test); + `, + }, + { + code: ` + const test = {}; + Object.values(test); + `, + }, + { + code: ` + const test = []; + Object.keys(test); + `, + }, + { + code: ` + const test = []; + Object.values(test); + `, + }, + { + code: ` + const test = []; + Object.entries(test); + `, + }, + /* + { + options: [{checkObjectKeysForMap: false}], + code: ` + const map = new Map(); + const result = Object.keys(map); + `, + }, + { + options: [{checkObjectEntriesForMap: false}], + code: ` + const map = new Map(); + const result = Object.entries(map); + `, + }, + { + options: [{checkObjectValuesForMap: false}], + code: ` + const map = new Map(); + const result = Object.values(map); + `, + }, + { + options: [{checkObjectKeysForSet: false}], + code: ` + const set = new Set(); + const result = Object.keys(set); + `, + }, + { + options: [{checkObjectEntriesForSet: false}], + code: ` + const set = new Set(); + const result = Object.entries(set); + `, + }, + { + options: [{checkObjectValuesForSet: false}], + code: ` + const set = new Set(); + const result = Object.values(set); + `, + }, + */ + { + code: ` + const test = 123; + Object.keys(test); + `, + }, + { + code: ` + const obj = {}; + Object.assign(obj, { key: 'value' }); + `, + }, + { + code: ` + const arr = []; + Object.assign(arr, { key: 'value' }); + `, + }, + ], + invalid: [ + { + code: ` + const map = new Map(); + const result = Object.keys(map); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const map = new Map(); + const result = Object.entries(map); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const map = new Map(); + const result = Object.values(map); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const set = new Set(); + const result = Object.keys(set); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const set = new Set(); + const result = Object.entries(set); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const set = new Set(); + const result = Object.values(set); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + class ExMap extends Map {} + const map = new ExMap(); + Object.keys(map); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + class ExMap extends Map {} + const map = new ExMap(); + Object.values(map); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + class ExMap extends Map {} + const map = new ExMap(); + Object.entries(map); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const test = new WeakMap(); + Object.keys(test); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const test = new WeakSet(); + Object.values(test); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const map = new Map(); + Object.assign(map, { key: 'value' }); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const set = new Set(); + Object.assign(set, { key: 'value' }); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const map = new WeakMap(); + Object.assign(map, { key: 'value' }); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + { + code: ` + const set = new WeakSet(); + Object.assign(set, { key: 'value' }); + `, + errors: [{ messageId: 'noObjectMethodsOnCollections' }], + }, + ], +}); diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-object-methods-on-collections.shot b/packages/eslint-plugin/tests/schema-snapshots/no-object-methods-on-collections.shot new file mode 100644 index 000000000000..42f81875ed94 --- /dev/null +++ b/packages/eslint-plugin/tests/schema-snapshots/no-object-methods-on-collections.shot @@ -0,0 +1,10 @@ + +# SCHEMA: + +[] + + +# TYPES: + +/** No options declared */ +type Options = []; \ No newline at end of file From bedf3dcb9920f3030556b2a0aa1f32855ac93d61 Mon Sep 17 00:00:00 2001 From: Stepan Kuzmin Date: Fri, 24 Oct 2025 18:03:42 +0300 Subject: [PATCH 2/2] fixup --- .../no-object-methods-on-collections.test.ts | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/packages/eslint-plugin/tests/rules/no-object-methods-on-collections.test.ts b/packages/eslint-plugin/tests/rules/no-object-methods-on-collections.test.ts index 00a2b38f102f..cb942e972c2b 100644 --- a/packages/eslint-plugin/tests/rules/no-object-methods-on-collections.test.ts +++ b/packages/eslint-plugin/tests/rules/no-object-methods-on-collections.test.ts @@ -52,50 +52,6 @@ ruleTester.run('no-object-methods-on-collections', rule, { Object.entries(test); `, }, - /* - { - options: [{checkObjectKeysForMap: false}], - code: ` - const map = new Map(); - const result = Object.keys(map); - `, - }, - { - options: [{checkObjectEntriesForMap: false}], - code: ` - const map = new Map(); - const result = Object.entries(map); - `, - }, - { - options: [{checkObjectValuesForMap: false}], - code: ` - const map = new Map(); - const result = Object.values(map); - `, - }, - { - options: [{checkObjectKeysForSet: false}], - code: ` - const set = new Set(); - const result = Object.keys(set); - `, - }, - { - options: [{checkObjectEntriesForSet: false}], - code: ` - const set = new Set(); - const result = Object.entries(set); - `, - }, - { - options: [{checkObjectValuesForSet: false}], - code: ` - const set = new Set(); - const result = Object.values(set); - `, - }, - */ { code: ` const test = 123;