diff --git a/packages/eslint-plugin/docs/rules/no-array-delete.md b/packages/eslint-plugin/docs/rules/no-array-delete.md new file mode 100644 index 000000000000..8497a7edff73 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-array-delete.md @@ -0,0 +1,37 @@ +--- +description: 'Disallow delete operator for arrays.' +--- + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/no-array-delete** for documentation. + +In JavaScript, using the `delete` operator on an array makes the given index empty and leaves the array length unchanged. +See [MDN's _Deleting array elements_ documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/delete#deleting_array_elements) for more information. +This can sometimes cause problems with performance and unexpected behaviors around array loops and index accesses. + +## Examples + + + +### ❌ Incorrect + +```ts +declare const array: unknown[]; +declare const index: number; + +delete array[index]; +``` + +### ✅ Correct + +```ts +declare const array: unknown[]; +declare const index: number; + +array.splice(index, 1); +``` + +## When Not To Use It + +If you don't care about having empty element in array, then you will not need this rule. diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index 777cf069827e..6834a84c64f5 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -53,6 +53,7 @@ export = { '@typescript-eslint/naming-convention': 'error', 'no-array-constructor': 'off', '@typescript-eslint/no-array-constructor': 'error', + '@typescript-eslint/no-array-delete': 'error', '@typescript-eslint/no-base-to-string': 'error', '@typescript-eslint/no-confusing-non-null-assertion': 'error', '@typescript-eslint/no-confusing-void-expression': 'error', diff --git a/packages/eslint-plugin/src/configs/strict.ts b/packages/eslint-plugin/src/configs/strict.ts index 26b71c931b00..3bd21f8b90e0 100644 --- a/packages/eslint-plugin/src/configs/strict.ts +++ b/packages/eslint-plugin/src/configs/strict.ts @@ -14,6 +14,7 @@ export = { '@typescript-eslint/consistent-type-definitions': 'warn', 'dot-notation': 'off', '@typescript-eslint/dot-notation': 'warn', + '@typescript-eslint/no-array-delete': 'warn', '@typescript-eslint/no-base-to-string': 'warn', '@typescript-eslint/no-confusing-non-null-assertion': 'warn', '@typescript-eslint/no-duplicate-enum-values': 'warn', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 405514edd1f0..0f92849f9ba3 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -32,6 +32,7 @@ import memberOrdering from './member-ordering'; import methodSignatureStyle from './method-signature-style'; import namingConvention from './naming-convention'; import noArrayConstructor from './no-array-constructor'; +import noArrayDelete from './no-array-delete'; import noBaseToString from './no-base-to-string'; import confusingNonNullAssertionLikeNotEqual from './no-confusing-non-null-assertion'; import noConfusingVoidExpression from './no-confusing-void-expression'; @@ -170,6 +171,7 @@ export default { 'method-signature-style': methodSignatureStyle, 'naming-convention': namingConvention, 'no-array-constructor': noArrayConstructor, + 'no-array-delete': noArrayDelete, 'no-base-to-string': noBaseToString, 'no-confusing-non-null-assertion': confusingNonNullAssertionLikeNotEqual, 'no-confusing-void-expression': noConfusingVoidExpression, diff --git a/packages/eslint-plugin/src/rules/no-array-delete.ts b/packages/eslint-plugin/src/rules/no-array-delete.ts new file mode 100644 index 000000000000..ac42edb232cb --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-array-delete.ts @@ -0,0 +1,91 @@ +import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import { unionTypeParts } from 'tsutils'; +import * as ts from 'typescript'; + +import * as util from '../util'; + +type MessageIds = 'arrayDelete' | 'suggestFunctionalDelete'; + +export default util.createRule<[], MessageIds>({ + name: 'no-array-delete', + meta: { + hasSuggestions: true, + docs: { + description: 'Disallow delete operator for arrays', + recommended: 'strict', + requiresTypeChecking: true, + }, + messages: { + arrayDelete: 'Using the delete operator on an array is dangerous.', + suggestFunctionalDelete: + 'Using Array.slice instead of delete keyword prevents empty array element.', + }, + schema: [], + type: 'problem', + fixable: 'code', + }, + defaultOptions: [], + create(context) { + const parserServices = util.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + + return { + "UnaryExpression[operator='delete'] > MemberExpression[computed]"( + node: TSESTree.MemberExpressionComputedName & { + parent: TSESTree.UnaryExpression; + }, + ): void { + const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node); + + const target = originalNode.getChildAt(0); + const key = originalNode.getChildAt(2); + + const targetType = util.getConstrainedTypeAtLocation(checker, target); + + if (!isTypeArrayTypeOrArrayInUnionOfTypes(targetType, checker)) { + return; + } + + const keyType = util.getConstrainedTypeAtLocation(checker, key); + + if (!util.isTypeFlagSet(keyType, ts.TypeFlags.NumberLike)) { + return; + } + + context.report({ + node, + messageId: 'arrayDelete', + suggest: [ + { + messageId: 'suggestFunctionalDelete', + fix(fixer): TSESLint.RuleFix | null { + const requiresParens = + node.property.type === AST_NODE_TYPES.SequenceExpression; + const keyText = key.getText(); + + if (util.isTypeFlagSet(keyType, ts.TypeFlags.String)) { + return null; + } + + return fixer.replaceText( + node.parent, + `${target.getText()}.splice(${ + requiresParens ? `(${keyText})` : keyText + }, 1)`, + ); + }, + }, + ], + }); + }, + }; + }, +}); + +function isTypeArrayTypeOrArrayInUnionOfTypes( + type: ts.Type, + checker: ts.TypeChecker, +): boolean { + return unionTypeParts(type).some(checker.isArrayType); +} diff --git a/packages/eslint-plugin/tests/rules/no-array-delete.test.ts b/packages/eslint-plugin/tests/rules/no-array-delete.test.ts new file mode 100644 index 000000000000..6a9c8ed82f52 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-array-delete.test.ts @@ -0,0 +1,390 @@ +import rule from '../../src/rules/no-array-delete'; +import { getFixturesRootDir, RuleTester } from '../RuleTester'; + +const rootDir = getFixturesRootDir(); +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 2015, + tsconfigRootDir: rootDir, + project: './tsconfig.json', + }, + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('no-array-delete', rule, { + valid: [ + ` +declare const obj: Record; +declare const key: PropertyKey; +delete obj[key]; + `, + ` +declare const arr: unknown[]; +delete arr.myprop; + `, + ` +declare const arr: unknown[]; +declare const i: string; +delete arr[i]; + `, + ` +declare const multiDimesnional: Array[][][][]; +declare const i: number; +delete multiDimesnional[i][i][i][i][i].someProp; + `, + ], + invalid: [ + { + code: ` +declare const arr: unknown[]; +declare const i: number; + +delete arr[i]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const arr: unknown[]; +declare const i: number; + +arr.splice(i, 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const arr: unknown[]; + +delete arr[10]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const arr: unknown[]; + +arr.splice(10, 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const arr: unknown[]; + +enum Enum { + X, + Y, +} + +delete arr[Enum.X]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const arr: unknown[]; + +enum Enum { + X, + Y, +} + +arr.splice(Enum.X, 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const arr: Array; +declare const i: number; + +delete arr[i]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const arr: Array; +declare const i: number; + +arr.splice(i, 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const obj: { prop: { arr: unknown[] } }; +declare const indexObj: { i: number }; + +delete obj.prop.arr[indexObj.i]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const obj: { prop: { arr: unknown[] } }; +declare const indexObj: { i: number }; + +obj.prop.arr.splice(indexObj.i, 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const i: number; +declare function getTarget(): unknown[]; + +delete getTarget()[i]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const i: number; +declare function getTarget(): unknown[]; + +getTarget().splice(i, 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const data: unknown[]; +declare function getKey(): number; + +delete data[getKey()]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const data: unknown[]; +declare function getKey(): number; + +data.splice(getKey(), 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const mayBeArr: number | number[]; +declare const i: number; + +delete mayBeArr[i]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const mayBeArr: number | number[]; +declare const i: number; + +mayBeArr.splice(i, 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const multiDimesnional: Array[][][][]; +declare const i: number; + +delete multiDimesnional[i][i][i][i][i]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const multiDimesnional: Array[][][][]; +declare const i: number; + +multiDimesnional[i][i][i][i].splice(i, 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const i: number; + +function trickyCase(t: T) { + delete t[i]; +} + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const i: number; + +function trickyCase(t: T) { + t.splice(i, 1); +} + `, + }, + ], + }, + ], + }, + { + code: ` +declare const i: number; + +function trickyCase1(t: T[]) { + delete t[i]; +} + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const i: number; + +function trickyCase1(t: T[]) { + t.splice(i, 1); +} + `, + }, + ], + }, + ], + }, + { + code: ` +declare const arr: unknown[]; +delete arr[Math.random() ? 1 : 1]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const arr: unknown[]; +arr.splice(Math.random() ? 1 : 1, 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const arr: unknown[]; +delete arr[Math.random() ? 1 : 'prop']; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const arr: unknown[]; +arr.splice(Math.random() ? 1 : 'prop', 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const arr: unknown[]; +declare function something(): unknown; + +delete arr[(something(), 1)]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [ + { + messageId: 'suggestFunctionalDelete', + output: ` +declare const arr: unknown[]; +declare function something(): unknown; + +arr.splice(((something(), 1)), 1); + `, + }, + ], + }, + ], + }, + { + code: ` +declare const arr: unknown[]; +declare const i: number | string; + +delete arr[i]; + `, + errors: [ + { + messageId: 'arrayDelete', + suggestions: [], + }, + ], + }, + ], +});