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..f81191eb523a --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-misused-object-likes.mdx @@ -0,0 +1,43 @@ +--- +description: 'Disallow using `Object` methods on `Map` or `Set` when it might cause unexpected behavior.' +--- + +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. + +Object methods like `Object.keys` and `Object.values` are intended for plain objects. Using them on `Map` or `Set` is a common mistake that may lead to unexpected behavior. + +This rule disallows such usage, encouraging correct alternatives. + + + + ```ts + const mySet = new Set(['foo', 'bar']); + Object.values(mySet); + + const myMap = new Map([['foo', 'bar'], ['hello', 'world']]); + Object.entries(myMap); + ``` + + + + ```ts + const mySet = new Set(['foo', 'bar']); + mySet.keys(); + + const myMap = new Map([['foo', 'bar'], ['hello', 'world']]); + myMap.entries(); + ``` + + + + +## When Not To Use It + +If your application extends the `Map` or `Set` types in unusual ways or manipulating their class prototype chains, you might not want this rule. + +You might consider using [ESLint disable comments](https://eslint.org/docs/latest/use/configure/rules#using-configuration-comments-1) for those specific situations instead of completely disabling this rule. diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index cd77c7dc2835..93455358e4ec 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 061df20cdd65..8c8210bbee18 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 de51a8bea55d..a1d3ebb0a0bf 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'; @@ -183,6 +184,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..3fdc40fb2241 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-misused-object-likes.ts @@ -0,0 +1,173 @@ +import type { + ParserServicesWithTypeInformation, + TSESTree, +} from '@typescript-eslint/utils'; +import type { RuleContext } from '@typescript-eslint/utils/ts-eslint'; +import type * as ts from 'typescript'; + +import { + getConstrainedTypeAtLocation, + isBuiltinSymbolLike, +} from '@typescript-eslint/type-utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; + +import { + createRule, + getParserServices, + getStaticMemberAccessValue, +} from '../util'; + +type Options = []; + +type MessageIds = + | 'noMapOrSetInObjectAssign' + | 'noMapOrSetInObjectEntries' + | 'noMapOrSetInObjectHasOwn' + | 'noMapOrSetInObjectHasOwnProperty' + | 'noMapOrSetInObjectKeys' + | 'noMapOrSetInObjectValues'; + +const objectConstructorMethodsMap = new Map([ + ['assign', 'noMapOrSetInObjectAssign'], + ['entries', 'noMapOrSetInObjectEntries'], + ['hasOwn', 'noMapOrSetInObjectHasOwn'], + ['hasOwnProperty', 'noMapOrSetInObjectHasOwnProperty'], + ['keys', 'noMapOrSetInObjectKeys'], + ['values', 'noMapOrSetInObjectValues'], +]); + +export default createRule({ + name: 'no-misused-object-likes', + meta: { + type: 'problem', + docs: { + description: + 'Disallow using `Object` methods on `Map` or `Set` when it might cause unexpected behavior', + requiresTypeChecking: true, + }, + messages: { + noMapOrSetInObjectAssign: + "Using 'Object.assign()' with a '{{type}}' may lead to unexpected results. Consider alternative approaches instead.", + noMapOrSetInObjectEntries: + "Using 'Object.entries()' on a '{{type}}' will return an empty array. Consider using the 'entries()' method instead.", + noMapOrSetInObjectHasOwn: + "Using 'Object.hasOwn()' on a '{{type}}' may lead to unexpected results. Consider using the 'has(key)' method instead.", + noMapOrSetInObjectHasOwnProperty: + "Using 'Object.hasOwnProperty()' on a '{{type}}' may lead to unexpected results. Consider using the 'has(key)' method instead.", + noMapOrSetInObjectKeys: + "Using 'Object.keys()' on a '{{type}}' will return an empty array. Consider using the 'keys()' method instead.", + noMapOrSetInObjectValues: + "Using 'Object.values()' on a '{{type}}' will return an empty array. Consider using the 'values()' method instead.", + }, + schema: [], + }, + defaultOptions: [], + create(context) { + const services = getParserServices(context); + + return { + CallExpression(node): void { + const objectMethodName = getPotentiallyMisusedObjectConstructorMethod( + context, + services, + node, + ); + + if (objectMethodName == null) { + return; + } + + const objectArgument = node.arguments.at(0); + + if (!objectArgument) { + return; + } + + const typeName = getViolatingTypeName( + services, + getConstrainedTypeAtLocation(services, objectArgument), + ); + + if (typeName) { + const messageId = objectConstructorMethodsMap.get(objectMethodName); + + if (messageId) { + context.report({ + node, + messageId, + data: { + type: typeName, + }, + }); + } + } + }, + }; + }, +}); + +function getViolatingTypeName( + services: ParserServicesWithTypeInformation, + type: ts.Type, +): string | null { + if (isSet(services.program, type)) { + return 'Set'; + } + + if (isMap(services.program, type)) { + return 'Map'; + } + + return null; +} + +function isMap(program: ts.Program, type: ts.Type): boolean { + return isTypeRecurser(type, t => + isBuiltinSymbolLike(program, t, ['Map', 'ReadonlyMap', 'WeakMap']), + ); +} + +function isSet(program: ts.Program, type: ts.Type): boolean { + return isTypeRecurser(type, t => + isBuiltinSymbolLike(program, t, ['Set', 'ReadonlySet', 'WeakSet']), + ); +} + +function isTypeRecurser( + type: ts.Type, + predicate: (t: ts.Type) => boolean, +): boolean { + if (type.isUnionOrIntersection()) { + return type.types.some(t => isTypeRecurser(t, predicate)); + } + + return predicate(type); +} + +function getPotentiallyMisusedObjectConstructorMethod( + context: RuleContext, + services: ParserServicesWithTypeInformation, + node: TSESTree.CallExpression, +): string | null { + if (node.callee.type !== AST_NODE_TYPES.MemberExpression) { + return null; + } + + const objectType = getConstrainedTypeAtLocation(services, node.callee.object); + + if (!isBuiltinSymbolLike(services.program, objectType, 'ObjectConstructor')) { + return null; + } + + const staticAccessValue = getStaticMemberAccessValue(node.callee, context); + + if (typeof staticAccessValue !== 'string') { + return null; + } + + if (!objectConstructorMethodsMap.has(staticAccessValue)) { + return null; + } + + return staticAccessValue; +} 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..07b8f3d2c420 --- /dev/null +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-misused-object-likes.shot @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Validating rule docs no-misused-object-likes.mdx code examples ESLint output 1`] = ` +"Incorrect + +const mySet = new Set(['foo', 'bar']); + Object.values(mySet); + ~~~~~~~~~~~~~~~~~~~~ Using 'Object.values()' on a 'Set' will return an empty array. Consider using the 'values()' method instead. + +const myMap = new Map([['foo', 'bar'], ['hello', 'world']]); + Object.entries(myMap); + ~~~~~~~~~~~~~~~~~~~~~ Using 'Object.entries()' on a 'Map' will return an empty array. Consider using the 'entries()' method instead. +" +`; + +exports[`Validating rule docs no-misused-object-likes.mdx code examples ESLint output 2`] = ` +"Correct + +const mySet = new Set(['foo', 'bar']); + mySet.keys(); + +const myMap = new Map([['foo', 'bar'], ['hello', 'world']]); + myMap.entries(); +" +`; 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..3cc37818ead9 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-misused-object-likes.test.ts @@ -0,0 +1,329 @@ +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: [ + "Object.keys({ a: 'a' });", + 'Object.keys([1, 2, 3]);', + 'Object.keys([1, 2, 3] as const);', + ` +declare const data: unknown; +Object.keys(data); + `, + ` +declare const data: any; +Object.keys(data); + `, + ` +declare const data: [1, 2, 3]; +Object.keys(data); + `, + ` +declare const data: Set; +data.keys(); + `, + ` +declare const data: Map; +data.keys(); + `, + ` +declare const data: Set; +Object.keys(data); + `, + ` +declare const data: Map; +Object.keys(data); + `, + ` +declare const data: Map; +Object.keys(data); + `, + ` +function test(data: T) { + Object.keys(data); +} + `, + ` +function test(data: string[]) { + Object.keys(data); +} + `, + ` +function test(data: Record) { + Object.keys(data); +} + `, + ` +declare const data: Iterable; +Object.keys(data); + `, + // error type + ` +Object.keys(data); + `, + ` +declare const data: Set; +keys(data); + `, + ` +declare const data: Set; +Object.create(data); + `, + ` +declare const data: Set; +Object[Symbol.iterator](data); + `, + ` +Object.keys(); + `, + ], + invalid: [ + { + code: ` +declare const data: Set; +Object.keys(data); + `, + errors: [ + { + column: 1, + data: { + type: 'Set', + }, + line: 3, + messageId: 'noMapOrSetInObjectKeys', + }, + ], + }, + { + code: ` +declare const data: Set; +Object.values(data, 'extra-arg'); + `, + errors: [ + { + column: 1, + data: { + type: 'Set', + }, + line: 3, + messageId: 'noMapOrSetInObjectValues', + }, + ], + }, + { + code: ` +declare const data: Set | { a: number }; +Object.assign(data); + `, + errors: [ + { + column: 1, + data: { + type: 'Set', + }, + line: 3, + messageId: 'noMapOrSetInObjectAssign', + }, + ], + }, + { + code: ` +declare const data: + | { a: number } + | ({ b: boolean } | ({ c: string } & Set)); +Object.entries(data); + `, + errors: [ + { + column: 1, + data: { + type: 'Set', + }, + line: 5, + messageId: 'noMapOrSetInObjectEntries', + }, + ], + }, + { + code: ` +function test>(data: T) { + Object.hasOwn(data, 'key'); +} + `, + errors: [ + { + column: 3, + data: { + type: 'Set', + }, + line: 3, + messageId: 'noMapOrSetInObjectHasOwn', + }, + ], + }, + { + code: ` +class ExtendedSet extends Set {} + +declare const data: ExtendedSet; +Object.hasOwnProperty(data, 'key'); + `, + errors: [ + { + column: 1, + data: { + type: 'Set', + }, + line: 5, + messageId: 'noMapOrSetInObjectHasOwnProperty', + }, + ], + }, + { + code: ` +declare const data: Set; +Object['values'](data); + `, + errors: [ + { + column: 1, + data: { + type: 'Set', + }, + line: 3, + messageId: 'noMapOrSetInObjectValues', + }, + ], + }, + { + code: ` +declare const data: Map; +Object.keys(data); + `, + errors: [ + { + column: 1, + data: { + type: 'Map', + }, + line: 3, + messageId: 'noMapOrSetInObjectKeys', + }, + ], + }, + { + code: ` +declare const data: Map; +Object.assign(data, 'extra-arg'); + `, + errors: [ + { + column: 1, + data: { + type: 'Map', + }, + line: 3, + messageId: 'noMapOrSetInObjectAssign', + }, + ], + }, + { + code: ` +declare const data: Map | { a: string }; +Object.entries(data); + `, + errors: [ + { + column: 1, + data: { + type: 'Map', + }, + line: 3, + messageId: 'noMapOrSetInObjectEntries', + }, + ], + }, + { + code: ` +declare const data: + | { a: number } + | ({ b: boolean } | ({ c: string } & Map)); +Object.keys(data); + `, + errors: [ + { + column: 1, + data: { + type: 'Map', + }, + line: 5, + messageId: 'noMapOrSetInObjectKeys', + }, + ], + }, + { + code: ` +function test>(data: T) { + Object.hasOwn(data, 'foo'); +} + `, + errors: [ + { + column: 3, + data: { + type: 'Map', + }, + line: 3, + messageId: 'noMapOrSetInObjectHasOwn', + }, + ], + }, + { + code: ` +class ExtendedMap extends Map {} + +declare const data: ExtendedMap; +Object.values(data); + `, + errors: [ + { + column: 1, + data: { + type: 'Map', + }, + line: 5, + messageId: 'noMapOrSetInObjectValues', + }, + ], + }, + { + code: ` +declare const data: Map; +Object['keys'](data); + `, + errors: [ + { + column: 1, + data: { + type: 'Map', + }, + line: 3, + messageId: 'noMapOrSetInObjectKeys', + }, + ], + }, + ], +}); 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 cd16389445a5..63eb9f363891 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 eeb80399882c..e604ea49a7b3 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',