diff --git a/packages/eslint-plugin/docs/rules/no-misused-spread.md b/packages/eslint-plugin/docs/rules/no-misused-spread.md new file mode 100644 index 000000000000..605f1eb1cd44 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-misused-spread.md @@ -0,0 +1,45 @@ +--- +description: "Disallow spread operator that shouldn't be spread most of the time." +--- + +> 🛑 This file is source code, not the primary documentation location! 🛑 +> +> See **https://typescript-eslint.io/rules/no-misused-spread** for documentation. + +Spreading a function is almost always a mistake. Most of the time you forgot to call the function. + +## Examples + + + +### ❌ Incorrect + +```ts +const fn = () => ({ name: 'name' }); +const obj = { + ...fn, + value: 1, +}; +``` + +```ts +const fn = () => ({ value: 33 }); +const otherFn = ({ value: number }) => ({ value: value }); +otherFn({ ...fn }); +``` + +### ✅ Correct + +```ts +const fn = () => ({ name: 'name' }); +const obj = { + ...fn(), + value: 1, +}; +``` + +```ts +const fn = () => ({ value: 33 }); +const otherFn = ({ value: number }) => ({ value: value }); +otherFn({ ...fn() }); +``` diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index f7a3cc3dbcb0..92c30f46f046 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -97,6 +97,7 @@ export = { '@typescript-eslint/no-misused-new': 'error', '@typescript-eslint/no-misused-promises': 'error', '@typescript-eslint/no-mixed-enums': 'error', + '@typescript-eslint/no-misused-spread': 'error', '@typescript-eslint/no-namespace': 'error', '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', '@typescript-eslint/no-non-null-asserted-optional-chain': 'error', diff --git a/packages/eslint-plugin/src/configs/stylistic-type-checked.ts b/packages/eslint-plugin/src/configs/stylistic-type-checked.ts index 5c73ae3845b6..7844e5a1513a 100644 --- a/packages/eslint-plugin/src/configs/stylistic-type-checked.ts +++ b/packages/eslint-plugin/src/configs/stylistic-type-checked.ts @@ -23,6 +23,7 @@ export = { '@typescript-eslint/no-empty-function': 'error', '@typescript-eslint/no-empty-interface': 'error', '@typescript-eslint/no-inferrable-types': 'error', + '@typescript-eslint/no-misused-spread': 'error', '@typescript-eslint/non-nullable-type-assertion-style': 'error', '@typescript-eslint/prefer-for-of': 'error', '@typescript-eslint/prefer-function-type': 'error', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 9d87b8cac412..ff88c2a6b91e 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -61,6 +61,7 @@ import noMagicNumbers from './no-magic-numbers'; import noMeaninglessVoidOperator from './no-meaningless-void-operator'; import noMisusedNew from './no-misused-new'; import noMisusedPromises from './no-misused-promises'; +import noMisusedSpread from './no-misused-spread'; import noMixedEnums from './no-mixed-enums'; import noNamespace from './no-namespace'; import noNonNullAssertedNullishCoalescing from './no-non-null-asserted-nullish-coalescing'; @@ -198,6 +199,7 @@ export default { 'no-meaningless-void-operator': noMeaninglessVoidOperator, 'no-misused-new': noMisusedNew, 'no-misused-promises': noMisusedPromises, + 'no-misused-spread': noMisusedSpread, 'no-mixed-enums': noMixedEnums, 'no-namespace': noNamespace, 'no-non-null-asserted-nullish-coalescing': noNonNullAssertedNullishCoalescing, diff --git a/packages/eslint-plugin/src/rules/no-misused-spread.ts b/packages/eslint-plugin/src/rules/no-misused-spread.ts new file mode 100644 index 000000000000..544a70c6428a --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-misused-spread.ts @@ -0,0 +1,46 @@ +import { type TSESLint, type TSESTree } from '@typescript-eslint/utils'; + +import { createRule, getParserServices } from '../util'; + +type MessageIds = 'forbiddenFunctionSpread'; + +export default createRule<[], MessageIds>({ + defaultOptions: [], + name: 'no-misused-spread', + meta: { + docs: { + description: + "Disallow spread operator that shouldn't be spread most of the time", + recommended: 'stylistic', + requiresTypeChecking: true, + }, + messages: { + forbiddenFunctionSpread: + 'Spreading a function is almost always a mistake. Did you forget to call the function?', + }, + schema: [], + type: 'suggestion', + }, + create: (context): TSESLint.RuleListener => { + const listener = (node: TSESTree.SpreadElement): void => { + const services = getParserServices(context); + const checker = services.program.getTypeChecker(); + + const tsNode = services.esTreeNodeToTSNodeMap.get(node.argument); + const type = checker.getTypeAtLocation(tsNode); + + if ( + type.getProperties().length === 0 && + type.getCallSignatures().length > 0 + ) { + context.report({ + node, + messageId: 'forbiddenFunctionSpread', + }); + } + }; + return { + SpreadElement: listener, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/no-misused-spread.test.ts b/packages/eslint-plugin/tests/rules/no-misused-spread.test.ts new file mode 100644 index 000000000000..2d70b1f2623b --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-misused-spread.test.ts @@ -0,0 +1,106 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from '../../src/rules/no-misused-spread'; +import { getFixturesRootDir } from '../RuleTester'; + +const rootDir = getFixturesRootDir(); +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: rootDir, + project: './tsconfig.json', + }, +}); + +ruleTester.run('no-misused-spread', rule, { + valid: [ + ` +const a = () => ({ value: 33 }); +const b = { + ...a(), + name: 'name', +}; + `, + ` +const a = (x: number) => ({ value: x }); +const b = { + ...a(42), + name: 'name', +}; + `, + ` +interface FuncWithProps { + property?: string; + (): number; +} +const funcWithProps: FuncWithProps = () => 1; +funcWithProps.property = 'foo'; +const spreadFuncWithProps = { ...funcWithProps }; + `, + ` +type FuncWithProps = { + property?: string; + (): number; +}; +const funcWithProps: FuncWithProps = () => 1; +funcWithProps.property = 'foo'; +const spreadFuncWithProps = { ...funcWithProps }; + `, + ` +const a = { value: 33 }; +const b = { + ...a, + name: 'name', +}; + `, + ` +const a = {}; +const b = { + ...a, + name: 'name', +}; + `, + ` +const a = () => ({ value: 33 }); +const b = ({ value: number }) => ({ value: value }); +b({ ...a() }); + `, + ` +const a = [33]; +const b = { + ...a, + name: 'name', +}; + `, + ], + invalid: [ + { + code: ` +const a = () => ({ value: 33 }); +const b = { + ...a, + name: 'name', +}; + `, + errors: [{ line: 4, column: 3, messageId: 'forbiddenFunctionSpread' }], + }, + { + code: ` +const a = (x: number) => ({ value: x }); +const b = { + ...a, + name: 'name', +}; + `, + errors: [{ line: 4, column: 3, messageId: 'forbiddenFunctionSpread' }], + }, + { + code: ` +const a = () => ({ value: 33 }); +const b = ({ value: number }) => ({ value: value }); +b({ ...a }); + `, + errors: [{ line: 4, column: 5, messageId: 'forbiddenFunctionSpread' }], + }, + ], +}); diff --git a/packages/eslint-plugin/tests/schema-snapshots/no-misused-spread.shot b/packages/eslint-plugin/tests/schema-snapshots/no-misused-spread.shot new file mode 100644 index 000000000000..2f6b196189c8 --- /dev/null +++ b/packages/eslint-plugin/tests/schema-snapshots/no-misused-spread.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-spread 1`] = ` +" +# SCHEMA: + +[] + + +# TYPES: + +/** No options declared */ +type Options = [];" +`;