Skip to content

feat(eslint-plugin): [no-misused-objects-like] add new rule #10573

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/eslint-plugin/docs/rules/no-misused-object-likes.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Tabs>
<TabItem value="❌ Incorrect">
```ts
const mySet = new Set(['foo', 'bar']);
Object.values(mySet);

const myMap = new Map([['foo', 'bar'], ['hello', 'world']]);
Object.entries(myMap);
```
</TabItem>

<TabItem value="✅ Correct">
```ts
const mySet = new Set(['foo', 'bar']);
mySet.keys();

const myMap = new Map([['foo', 'bar'], ['hello', 'world']]);
myMap.entries();
```
</TabItem>

</Tabs>

## 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.
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/configs/disable-type-checked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
173 changes: 173 additions & 0 deletions packages/eslint-plugin/src/rules/no-misused-object-likes.ts
Original file line number Diff line number Diff line change
@@ -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<string, MessageIds>([
['assign', 'noMapOrSetInObjectAssign'],
['entries', 'noMapOrSetInObjectEntries'],
['hasOwn', 'noMapOrSetInObjectHasOwn'],
['hasOwnProperty', 'noMapOrSetInObjectHasOwnProperty'],
['keys', 'noMapOrSetInObjectKeys'],
['values', 'noMapOrSetInObjectValues'],
]);

export default createRule<Options, MessageIds>({
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<string, unknown[]>,
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;
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading