diff --git a/packages/eslint-plugin/docs/rules/no-misused-spread.mdx b/packages/eslint-plugin/docs/rules/no-misused-spread.mdx new file mode 100644 index 000000000000..35e708433900 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-misused-spread.mdx @@ -0,0 +1,132 @@ +--- +description: 'Disallow using the spread operator 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-spread** for documentation. + +[Spread syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) (`...`) is a JavaScript feature for creating an object with the joined properties of one or more other objects. +TypeScript allows spreading objects whose properties are not typically meant to be enumerated, such as arrays and class instances. + +This rule disallows using the spread syntax on values whose types indicate doing so may cause unexpected behavior. +That includes the following cases: + +- Spreading a `Promise` into an object. + You probably meant to `await` it. +- Spreading a function without properties into an object. + You probably meant to call it. +- Spreading an iterable (`Array`, `Map`, etc.) into an object. + Iterable objects usually do not have meaningful enumerable properties and you probably meant to spread it into an array instead. +- Spreading a string into an array. + String enumeration behaviors in JavaScript around encoded characters are often surprising. +- Spreading a `class` into an object. + This copies all static own properties of the class, but none of the inheritance chain. +- Spreading a class instance into an object. + This does not faithfully copy the instance because only its own properties are copied, but the inheritance chain is lost, including all its methods. + +## Examples + + + + +```ts +declare const promise: Promise; +const spreadPromise = { ...promise }; + +declare function getObject(): Record; +const getObjectSpread = { ...getObject }; + +declare const map: Map; +const mapSpread = { ...map }; + +declare const userName: string; +const characters = [...userName]; +``` + +```ts +declare class Box { + value: number; +} +const boxSpread = { ...Box }; + +declare const instance: Box; +const instanceSpread = { ...box }; +``` + + + + +```ts +declare const promise: Promise; +const spreadPromise = { ...(await promise) }; + +declare function getObject(): Record; +const getObjectSpread = { ...getObject() }; + +declare const map: Map; +const mapObject = Object.fromEntries(map); + +declare const userName: string; +const characters = userName.split(''); +``` + + + + +## Options + +### `allow` + +{/* insert option description */} + +This option takes the shared [`TypeOrValueSpecifier` format](/packages/type-utils/type-or-value-specifier). + +Examples of a configuration for this option in a `file.ts` file: + +```json +"@typescript-eslint/no-misused-spread": [ + "error", + { + "allow": [ + { "from": "file", "name": "BrandedString", "path": "file.ts" }, + ] + } +] +``` + + + + +```ts option='{"allow":[{ "from": "file", "name": "BrandedString" }]}' +declare const unbrandedString: string; + +const spreadUnbrandedString = [...unbrandedString]; +``` + + + + +```ts option='{"allow":[{ "from": "file", "name": "BrandedString" }]}' +type BrandedString = string & { __brand: 'safe' }; + +declare const brandedString: BrandedString; + +const spreadBrandedString = [...brandedString]; +``` + + + + +## When Not To Use It + +If your application intentionally works with raw data in unusual ways, such as directly manipulating class prototype chains, you might not want this rule. + +If your use cases for unusual spreads only involve a few types, you might consider using [ESLint disable comments](https://eslint.org/docs/latest/use/configure/rules#using-configuration-comments-1) and/or the [`allow` option](#allow) instead of completely disabling this rule. + +## Further Reading + +- [Strings Shouldn't Be Iterable By Default](https://www.xanthir.com/b4wJ1) diff --git a/packages/eslint-plugin/src/configs/all.ts b/packages/eslint-plugin/src/configs/all.ts index cd77c7dc2835..6febc52e3977 100644 --- a/packages/eslint-plugin/src/configs/all.ts +++ b/packages/eslint-plugin/src/configs/all.ts @@ -74,6 +74,7 @@ export = { '@typescript-eslint/no-meaningless-void-operator': 'error', '@typescript-eslint/no-misused-new': 'error', '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/no-misused-spread': 'error', '@typescript-eslint/no-mixed-enums': 'error', '@typescript-eslint/no-namespace': 'error', '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', diff --git a/packages/eslint-plugin/src/configs/disable-type-checked.ts b/packages/eslint-plugin/src/configs/disable-type-checked.ts index 95efeca0cd5b..2229d6715172 100644 --- a/packages/eslint-plugin/src/configs/disable-type-checked.ts +++ b/packages/eslint-plugin/src/configs/disable-type-checked.ts @@ -25,6 +25,7 @@ export = { '@typescript-eslint/no-implied-eval': 'off', '@typescript-eslint/no-meaningless-void-operator': 'off', '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/no-misused-spread': 'off', '@typescript-eslint/no-mixed-enums': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', diff --git a/packages/eslint-plugin/src/configs/strict-type-checked-only.ts b/packages/eslint-plugin/src/configs/strict-type-checked-only.ts index e5be4357f195..bdbfa931472f 100644 --- a/packages/eslint-plugin/src/configs/strict-type-checked-only.ts +++ b/packages/eslint-plugin/src/configs/strict-type-checked-only.ts @@ -22,6 +22,7 @@ export = { '@typescript-eslint/no-implied-eval': 'error', '@typescript-eslint/no-meaningless-void-operator': 'error', '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/no-misused-spread': 'error', '@typescript-eslint/no-mixed-enums': 'error', '@typescript-eslint/no-redundant-type-constituents': 'error', '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'error', diff --git a/packages/eslint-plugin/src/configs/strict-type-checked.ts b/packages/eslint-plugin/src/configs/strict-type-checked.ts index b00f5eb0fc14..7e0e06046a43 100644 --- a/packages/eslint-plugin/src/configs/strict-type-checked.ts +++ b/packages/eslint-plugin/src/configs/strict-type-checked.ts @@ -36,6 +36,7 @@ export = { '@typescript-eslint/no-meaningless-void-operator': 'error', '@typescript-eslint/no-misused-new': 'error', '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/no-misused-spread': 'error', '@typescript-eslint/no-mixed-enums': 'error', '@typescript-eslint/no-namespace': 'error', '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index de51a8bea55d..f1587b5d2b1f 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -53,6 +53,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'; @@ -184,6 +185,7 @@ const rules = { '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..4fe980893616 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-misused-spread.ts @@ -0,0 +1,267 @@ +import type { TSESTree } from '@typescript-eslint/utils'; + +import * as tsutils from 'ts-api-utils'; +import * as ts from 'typescript'; + +import type { TypeOrValueSpecifier } from '../util'; + +import { + createRule, + getConstrainedTypeAtLocation, + getParserServices, + isBuiltinSymbolLike, + isPromiseLike, + isTypeFlagSet, + readonlynessOptionsSchema, + typeMatchesSomeSpecifier, +} from '../util'; + +type Options = [ + { + allow?: TypeOrValueSpecifier[]; + }, +]; + +type MessageIds = + | 'noArraySpreadInObject' + | 'noClassDeclarationSpreadInObject' + | 'noClassInstanceSpreadInObject' + | 'noFunctionSpreadInObject' + | 'noIterableSpreadInObject' + | 'noMapSpreadInObject' + | 'noPromiseSpreadInObject' + | 'noStringSpread'; + +export default createRule({ + name: 'no-misused-spread', + meta: { + type: 'problem', + docs: { + description: + 'Disallow using the spread operator when it might cause unexpected behavior', + recommended: 'strict', + requiresTypeChecking: true, + }, + messages: { + noArraySpreadInObject: + 'Using the spread operator on an array in an object will result in a list of indices.', + noClassDeclarationSpreadInObject: + 'Using the spread operator on class declarations will spread only their static properties, and will lose their class prototype.', + noClassInstanceSpreadInObject: + 'Using the spread operator on class instances will lose their class prototype.', + noFunctionSpreadInObject: + 'Using the spread operator on a function without additional properties can cause unexpected behavior. Did you forget to call the function?', + noIterableSpreadInObject: + 'Using the spread operator on an Iterable in an object can cause unexpected behavior.', + noMapSpreadInObject: + 'Using the spread operator on a Map in an object will result in an empty object. Did you mean to use `Object.fromEntries(map)` instead?', + noPromiseSpreadInObject: + 'Using the spread operator on Promise in an object can cause unexpected behavior. Did you forget to await the promise?', + noStringSpread: + "Using the spread operator on a string can cause unexpected behavior. Prefer `.split('')` instead.", + }, + schema: [ + { + type: 'object', + additionalProperties: false, + properties: { + allow: { + ...readonlynessOptionsSchema.properties.allow, + description: + 'An array of type specifiers that are known to be safe to spread.', + }, + }, + }, + ], + }, + + defaultOptions: [ + { + allow: [], + }, + ], + + create(context, [options]) { + const services = getParserServices(context); + const checker = services.program.getTypeChecker(); + + function checkArrayOrCallSpread(node: TSESTree.SpreadElement): void { + const type = getConstrainedTypeAtLocation(services, node.argument); + + if ( + !typeMatchesSomeSpecifier(type, options.allow, services.program) && + isString(type) + ) { + context.report({ + node, + messageId: 'noStringSpread', + }); + } + } + + function checkObjectSpread(node: TSESTree.SpreadElement): void { + const type = getConstrainedTypeAtLocation(services, node.argument); + + if (typeMatchesSomeSpecifier(type, options.allow, services.program)) { + return; + } + + if (isPromise(services.program, type)) { + context.report({ + node, + messageId: 'noPromiseSpreadInObject', + }); + + return; + } + + if (isFunctionWithoutProps(type)) { + context.report({ + node, + messageId: 'noFunctionSpreadInObject', + }); + + return; + } + + if (isMap(services.program, type)) { + context.report({ + node, + messageId: 'noMapSpreadInObject', + }); + + return; + } + + if (isArray(checker, type)) { + context.report({ + node, + messageId: 'noArraySpreadInObject', + }); + + return; + } + + if ( + isIterable(type, checker) && + // Don't report when the type is string, since TS will flag it already + !isString(type) + ) { + context.report({ + node, + messageId: 'noIterableSpreadInObject', + }); + + return; + } + + if (isClassInstance(checker, type)) { + context.report({ + node, + messageId: 'noClassInstanceSpreadInObject', + }); + + return; + } + + if (isClassDeclaration(type)) { + context.report({ + node, + messageId: 'noClassDeclarationSpreadInObject', + }); + } + } + + return { + 'ArrayExpression > SpreadElement': checkArrayOrCallSpread, + 'CallExpression > SpreadElement': checkArrayOrCallSpread, + 'ObjectExpression > SpreadElement': checkObjectSpread, + }; + }, +}); + +function isIterable(type: ts.Type, checker: ts.TypeChecker): boolean { + return tsutils + .typeParts(type) + .some( + t => !!tsutils.getWellKnownSymbolPropertyOfType(t, 'iterator', checker), + ); +} + +function isArray(checker: ts.TypeChecker, type: ts.Type): boolean { + return isTypeRecurser( + type, + t => checker.isArrayType(t) || checker.isTupleType(t), + ); +} + +function isString(type: ts.Type): boolean { + return isTypeRecurser(type, t => isTypeFlagSet(t, ts.TypeFlags.StringLike)); +} + +function isFunctionWithoutProps(type: ts.Type): boolean { + return isTypeRecurser( + type, + t => t.getCallSignatures().length > 0 && t.getProperties().length === 0, + ); +} + +function isPromise(program: ts.Program, type: ts.Type): boolean { + return isTypeRecurser(type, t => isPromiseLike(program, t)); +} + +function isClassInstance(checker: ts.TypeChecker, type: ts.Type): boolean { + return isTypeRecurser(type, t => { + // If the type itself has a construct signature, it's a class(-like) + if (t.getConstructSignatures().length) { + return false; + } + + const symbol = t.getSymbol(); + + // If the type's symbol has a construct signature, the type is an instance + return !!symbol + ?.getDeclarations() + ?.some( + declaration => + checker + .getTypeOfSymbolAtLocation(symbol, declaration) + .getConstructSignatures().length, + ); + }); +} + +function isClassDeclaration(type: ts.Type): boolean { + return isTypeRecurser(type, t => { + if ( + tsutils.isObjectType(t) && + tsutils.isObjectFlagSet(t, ts.ObjectFlags.InstantiationExpressionType) + ) { + return true; + } + + const kind = t.getSymbol()?.valueDeclaration?.kind; + + return ( + kind === ts.SyntaxKind.ClassDeclaration || + kind === ts.SyntaxKind.ClassExpression + ); + }); +} + +function isMap(program: ts.Program, type: ts.Type): boolean { + return isTypeRecurser(type, t => + isBuiltinSymbolLike(program, t, ['Map', 'ReadonlyMap', 'WeakMap']), + ); +} + +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); +} diff --git a/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-misused-spread.shot b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-misused-spread.shot new file mode 100644 index 000000000000..c0b94ec789d9 --- /dev/null +++ b/packages/eslint-plugin/tests/docs-eslint-output-snapshots/no-misused-spread.shot @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Validating rule docs no-misused-spread.mdx code examples ESLint output 1`] = ` +"Incorrect + +declare const promise: Promise; +const spreadPromise = { ...promise }; + ~~~~~~~~~~ Using the spread operator on Promise in an object can cause unexpected behavior. Did you forget to await the promise? + +declare function getObject(): Record; +const getObjectSpread = { ...getObject }; + ~~~~~~~~~~~~ Using the spread operator on a function without additional properties can cause unexpected behavior. Did you forget to call the function? + +declare const map: Map; +const mapSpread = { ...map }; + ~~~~~~ Using the spread operator on a Map in an object will result in an empty object. Did you mean to use \`Object.fromEntries(map)\` instead? + +declare const userName: string; +const characters = [...userName]; + ~~~~~~~~~~~ Using the spread operator on a string can cause unexpected behavior. Prefer \`.split('')\` instead. +" +`; + +exports[`Validating rule docs no-misused-spread.mdx code examples ESLint output 2`] = ` +"Incorrect + +declare class Box { + value: number; +} +const boxSpread = { ...Box }; + ~~~~~~ Using the spread operator on class declarations will spread only their static properties, and will lose their class prototype. + +declare const instance: Box; +const instanceSpread = { ...box }; +" +`; + +exports[`Validating rule docs no-misused-spread.mdx code examples ESLint output 3`] = ` +"Correct + +declare const promise: Promise; +const spreadPromise = { ...(await promise) }; + +declare function getObject(): Record; +const getObjectSpread = { ...getObject() }; + +declare const map: Map; +const mapObject = Object.fromEntries(map); + +declare const userName: string; +const characters = userName.split(''); +" +`; + +exports[`Validating rule docs no-misused-spread.mdx code examples ESLint output 4`] = ` +"Incorrect +Options: {"allow":[{ "from": "file", "name": "BrandedString" }]} + +declare const unbrandedString: string; + +const spreadUnbrandedString = [...unbrandedString]; + ~~~~~~~~~~~~~~~~~~ Using the spread operator on a string can cause unexpected behavior. Prefer \`.split('')\` instead. +" +`; + +exports[`Validating rule docs no-misused-spread.mdx code examples ESLint output 5`] = ` +"Correct +Options: {"allow":[{ "from": "file", "name": "BrandedString" }]} + +type BrandedString = string & { __brand: 'safe' }; + +declare const brandedString: BrandedString; + +const spreadBrandedString = [...brandedString]; +" +`; diff --git a/packages/eslint-plugin/tests/fixtures/tsconfig-with-dom.json b/packages/eslint-plugin/tests/fixtures/tsconfig-with-dom.json new file mode 100644 index 000000000000..6168cfcb8d54 --- /dev/null +++ b/packages/eslint-plugin/tests/fixtures/tsconfig-with-dom.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["esnext", "DOM"] + } +} 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..38ebeea2dcab --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-misused-spread.test.ts @@ -0,0 +1,1497 @@ +import { noFormat, RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from '../../src/rules/no-misused-spread'; +import { getFixturesRootDir } from '../RuleTester'; + +const rootPath = getFixturesRootDir(); + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + project: './tsconfig-with-dom.json', + tsconfigRootDir: rootPath, + }, + }, +}); + +ruleTester.run('no-misused-spread', rule, { + valid: [ + 'const a = [...[1, 2, 3]];', + 'const a = [...([1, 2, 3] as const)];', + ` + declare const data: any; + const a = [...data]; + `, + ` + declare const data: unknown; + const a = [...data]; + `, + ` + const a = [1, 2, 3]; + const b = [...a]; + `, + ` + const a = [1, 2, 3] as const; + const b = [...a]; + `, + ` + declare function getArray(): number[]; + const a = [...getArray()]; + `, + ` + declare function getTuple(): readonly number[]; + const a = [...getTuple()]; + `, + ` + const iterator = { + *[Symbol.iterator]() { + yield 1; + yield 2; + yield 3; + }, + }; + + const a = [...iterator]; + `, + ` + declare const data: Iterable | number[]; + + const a = [...data]; + `, + ` + declare const data: Iterable & number[]; + + const a = [...data]; + `, + ` + declare function getIterable(): Iterable; + + const a = [...getIterable()]; + `, + ` + declare const data: Uint8Array; + + const a = [...data]; + `, + ` + declare const data: TypedArray; + + const a = [...data]; + `, + 'const o = { ...{ a: 1, b: 2 } };', + 'const o = { ...({ a: 1, b: 2 } as const) };', + ` + declare const obj: any; + + const o = { ...obj }; + `, + ` + declare const obj: { a: number; b: number } | any; + + const o = { ...obj }; + `, + ` + declare const obj: { a: number; b: number } & any; + + const o = { ...obj }; + `, + ` + const obj = { a: 1, b: 2 }; + const o = { ...obj }; + `, + ` + declare const obj: { a: number; b: number }; + const o = { ...obj }; + `, + ` + declare function getObject(): { a: number; b: number }; + const o = { ...getObject() }; + `, + ` + function f() {} + + f.prop = 1; + + const o = { ...f }; + `, + ` + const f = () => {}; + + f.prop = 1; + + const o = { ...f }; + `, + ` + function* generator() {} + + generator.prop = 1; + + const o = { ...generator }; + `, + ` + declare const promiseLike: PromiseLike; + + const o = { ...promiseLike }; + `, + + { + code: ` + const promise = new Promise(() => {}); + const o = { ...promise }; + `, + options: [{ allow: ['Promise'] }], + }, + ` + interface A {} + + declare const a: A; + + const o = { ...a }; + `, + + // This case is being flagged by TS already, but since we check in the code + // for `Iterable`s, it catches string as well, so this test exists to ensure + // we don't flag it. + ` + const o = { ...'test' }; + `, + + { + code: ` + const str: string = 'test'; + const a = [...str]; + `, + options: [{ allow: ['string'] }], + }, + { + code: ` + function f() {} + + const a = { ...f }; + `, + options: [{ allow: ['f'] }], + }, + { + code: ` + declare const iterator: Iterable; + + const a = { ...iterator }; + `, + options: [ + { + allow: [{ from: 'lib', name: 'Iterable' }], + }, + ], + }, + { + code: ` + type BrandedString = string & { __brand: 'safe' }; + + declare const brandedString: BrandedString; + + const spreadBrandedString = [...brandedString]; + `, + options: [ + { + allow: [{ from: 'file', name: 'BrandedString' }], + }, + ], + }, + { + code: ` + type CustomIterable = { + [Symbol.iterator]: () => Generator; + }; + + declare const iterator: CustomIterable; + + const a = { ...iterator }; + `, + options: [{ allow: ['CustomIterable'] }], + }, + { + code: ` + type CustomIterable = { + [Symbol.iterator]: () => string; + }; + + declare const iterator: CustomIterable; + + const a = { ...iterator }; + `, + options: [ + { + allow: [{ from: 'file', name: 'CustomIterable' }], + }, + ], + }, + { + code: ` + declare module 'module' { + export type CustomIterable = { + [Symbol.iterator]: () => string; + }; + } + + import { CustomIterable } from 'module'; + + declare const iterator: CustomIterable; + + const a = { ...iterator }; + `, + options: [ + { + allow: [ + { from: 'package', name: 'CustomIterable', package: 'module' }, + ], + }, + ], + }, + { + code: ` + class A { + a = 1; + } + + const a = new A(); + + const o = { ...a }; + `, + options: [{ allow: ['A'] }], + }, + { + code: ` + const a = { + ...class A { + static value = 1; + }, + }; + `, + options: [{ allow: ['A'] }], + }, + ], + + invalid: [ + { + code: "const a = [...'test'];", + errors: [ + { + column: 12, + endColumn: 21, + line: 1, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + function withText(text: Text) { + return [...text]; + } + `, + errors: [ + { + column: 19, + endColumn: 26, + line: 3, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + const test = 'hello'; + const a = [...test]; + `, + errors: [ + { + column: 20, + endColumn: 27, + line: 3, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + const test = \`he\${'ll'}o\`; + const a = [...test]; + `, + errors: [ + { + column: 20, + endColumn: 27, + line: 3, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + declare const test: string; + const a = [...test]; + `, + errors: [ + { + column: 20, + endColumn: 27, + line: 3, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + declare const test: string | number[]; + const a = [...test]; + `, + errors: [ + { + column: 20, + endColumn: 27, + line: 3, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + declare const test: string & { __brand: 'test' }; + const a = [...test]; + `, + errors: [ + { + column: 20, + endColumn: 27, + line: 3, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + declare const test: number | (boolean | (string & { __brand: true })); + const a = [...test]; + `, + errors: [ + { + column: 20, + endColumn: 27, + line: 3, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + declare function getString(): string; + const a = [...getString()]; + `, + errors: [ + { + column: 20, + endColumn: 34, + line: 3, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + declare function textIdentity(...args: string[]); + + declare const text: string; + + textIdentity(...text); + `, + errors: [ + { + column: 22, + endColumn: 29, + line: 6, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + declare function textIdentity(...args: string[]); + + declare const text: string; + + textIdentity(...text, 'and', ...text); + `, + errors: [ + { + column: 22, + endColumn: 29, + line: 6, + messageId: 'noStringSpread', + }, + { + column: 38, + endColumn: 45, + line: 6, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + declare function textIdentity(...args: string[]); + + function withText(text: Text) { + textIdentity(...text); + } + `, + errors: [ + { + column: 24, + endColumn: 31, + line: 5, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + declare function getString(): T; + const a = [...getString()]; + `, + errors: [ + { + column: 20, + endColumn: 34, + line: 3, + messageId: 'noStringSpread', + }, + ], + }, + { + code: ` + declare function getString(): string & { __brand: 'test' }; + const a = [...getString()]; + `, + errors: [ + { + column: 20, + endColumn: 34, + line: 3, + messageId: 'noStringSpread', + }, + ], + }, + { + code: 'const o = { ...[1, 2, 3] };', + errors: [ + { + column: 13, + endColumn: 25, + line: 1, + messageId: 'noArraySpreadInObject', + }, + ], + }, + { + code: ` + const arr = [1, 2, 3]; + const o = { ...arr }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noArraySpreadInObject', + }, + ], + }, + { + code: ` + const arr = [1, 2, 3] as const; + const o = { ...arr }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noArraySpreadInObject', + }, + ], + }, + { + code: ` + declare const arr: number[]; + const o = { ...arr }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noArraySpreadInObject', + }, + ], + }, + { + code: ` + declare const arr: readonly number[]; + const o = { ...arr }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noArraySpreadInObject', + }, + ], + }, + { + code: ` + declare const arr: number[] | string[]; + const o = { ...arr }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noArraySpreadInObject', + }, + ], + }, + { + code: ` + declare const arr: number[] & string[]; + const o = { ...arr }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noArraySpreadInObject', + }, + ], + }, + { + code: ` + declare function getArray(): number[]; + const o = { ...getArray() }; + `, + errors: [ + { + column: 21, + endColumn: 34, + line: 3, + messageId: 'noArraySpreadInObject', + }, + ], + }, + { + code: ` + declare function getArray(): readonly number[]; + const o = { ...getArray() }; + `, + errors: [ + { + column: 21, + endColumn: 34, + line: 3, + messageId: 'noArraySpreadInObject', + }, + ], + }, + { + code: 'const o = { ...new Set([1, 2, 3]) };', + errors: [ + { + column: 13, + endColumn: 34, + line: 1, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + const set = new Set([1, 2, 3]); + const o = { ...set }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + declare const set: Set; + const o = { ...set }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + declare const set: WeakSet; + const o = { ...set }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + declare const set: ReadonlySet; + const o = { ...set }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + declare const set: Set | { a: number }; + const o = { ...set }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + declare function getSet(): Set; + const o = { ...getSet() }; + `, + errors: [ + { + column: 21, + endColumn: 32, + line: 3, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + const o = { + ...new Map([ + ['test-1', 1], + ['test-2', 2], + ]), + }; + `, + errors: [ + { + column: 11, + endColumn: 13, + endLine: 6, + line: 3, + messageId: 'noMapSpreadInObject', + }, + ], + }, + { + code: ` + const map = new Map([ + ['test-1', 1], + ['test-2', 2], + ]); + + const o = { ...map }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 7, + messageId: 'noMapSpreadInObject', + }, + ], + }, + { + code: ` + declare const map: Map; + const o = { ...map }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noMapSpreadInObject', + }, + ], + }, + { + code: ` + declare const map: ReadonlyMap; + const o = { ...map }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noMapSpreadInObject', + }, + ], + }, + { + code: ` + declare const map: WeakMap<{ a: number }, string>; + const o = { ...map }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noMapSpreadInObject', + }, + ], + }, + { + code: ` + declare const map: Map | { a: number }; + const o = { ...map }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noMapSpreadInObject', + }, + ], + }, + { + code: ` + declare function getMap(): Map; + const o = { ...getMap() }; + `, + errors: [ + { + column: 21, + endColumn: 32, + line: 3, + messageId: 'noMapSpreadInObject', + }, + ], + }, + { + code: ` + declare const a: Map & Set; + const o = { ...a }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 3, + messageId: 'noMapSpreadInObject', + }, + ], + }, + { + code: ` + const ref = new WeakRef({ a: 1 }); + const o = { ...ref }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 3, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + const promise = new Promise(() => {}); + const o = { ...promise }; + `, + errors: [ + { + column: 21, + endColumn: 31, + line: 3, + messageId: 'noPromiseSpreadInObject', + }, + ], + }, + { + code: ` + function withPromise

>(promise: P) { + return { ...promise }; + } + `, + errors: [ + { + column: 20, + endColumn: 30, + line: 3, + messageId: 'noPromiseSpreadInObject', + }, + ], + }, + { + code: ` + declare const maybePromise: Promise | { a: number }; + const o = { ...maybePromise }; + `, + errors: [ + { + column: 21, + endColumn: 36, + line: 3, + messageId: 'noPromiseSpreadInObject', + }, + ], + }, + { + code: ` + declare const promise: Promise & { a: number }; + const o = { ...promise }; + `, + errors: [ + { + column: 21, + endColumn: 31, + line: 3, + messageId: 'noPromiseSpreadInObject', + }, + ], + }, + { + code: ` + declare function getPromise(): Promise; + const o = { ...getPromise() }; + `, + errors: [ + { + column: 21, + endColumn: 36, + line: 3, + messageId: 'noPromiseSpreadInObject', + }, + ], + }, + { + code: ` + declare function getPromise>(arg: T): T; + const o = { ...getPromise() }; + `, + errors: [ + { + column: 21, + endColumn: 36, + line: 3, + messageId: 'noPromiseSpreadInObject', + }, + ], + }, + { + code: ` + function f() {} + + const o = { ...f }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 4, + messageId: 'noFunctionSpreadInObject', + }, + ], + }, + { + code: ` + interface FunctionWithProps { + (): string; + prop: boolean; + } + + type FunctionWithoutProps = () => string; + + declare const obj: FunctionWithProps | FunctionWithoutProps | object; + + const o = { ...obj }; + `, + errors: [ + { + column: 21, + endColumn: 27, + line: 11, + messageId: 'noFunctionSpreadInObject', + }, + ], + }, + { + code: ` + const f = () => {}; + + const o = { ...f }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 4, + messageId: 'noFunctionSpreadInObject', + }, + ], + }, + { + code: ` + declare function f(): void; + + const o = { ...f }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 4, + messageId: 'noFunctionSpreadInObject', + }, + ], + }, + { + code: ` + declare function getFunction(): () => void; + + const o = { ...getFunction() }; + `, + errors: [ + { + column: 21, + endColumn: 37, + line: 4, + messageId: 'noFunctionSpreadInObject', + }, + ], + }, + { + code: ` + declare const f: () => void; + + const o = { ...f }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 4, + messageId: 'noFunctionSpreadInObject', + }, + ], + }, + { + code: ` + declare const f: () => void | { a: number }; + + const o = { ...f }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 4, + messageId: 'noFunctionSpreadInObject', + }, + ], + }, + { + code: ` + function* generator() {} + + const o = { ...generator }; + `, + errors: [ + { + column: 21, + endColumn: 33, + line: 4, + messageId: 'noFunctionSpreadInObject', + }, + ], + }, + { + code: ` + const iterator = { + *[Symbol.iterator]() { + yield 'test'; + }, + }; + + const o = { ...iterator }; + `, + errors: [ + { + column: 21, + endColumn: 32, + line: 8, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + type CustomIterable = { + [Symbol.iterator]: () => Generator; + }; + + const iterator: CustomIterable = { + *[Symbol.iterator]() { + yield 'test'; + }, + }; + + const a = { ...iterator }; + `, + errors: [ + { + column: 21, + endColumn: 32, + line: 12, + messageId: 'noIterableSpreadInObject', + }, + ], + options: [{ allow: ['AnotherIterable'] }], + }, + { + code: ` + declare module 'module' { + export type CustomIterable = { + [Symbol.iterator]: () => string; + }; + } + + import { CustomIterable } from 'module'; + + declare const iterator: CustomIterable; + + const a = { ...iterator }; + `, + errors: [ + { + column: 21, + endColumn: 32, + line: 12, + messageId: 'noIterableSpreadInObject', + }, + ], + options: [ + { + allow: [{ from: 'package', name: 'Nothing', package: 'module' }], + }, + ], + }, + { + code: ` + declare const iterator: Iterable; + + const o = { ...iterator }; + `, + errors: [ + { + column: 21, + endColumn: 32, + line: 4, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + declare const iterator: Iterable | { a: number }; + + const o = { ...iterator }; + `, + errors: [ + { + column: 21, + endColumn: 32, + line: 4, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + declare function getIterable(): Iterable; + + const o = { ...getIterable() }; + `, + errors: [ + { + column: 21, + endColumn: 37, + line: 4, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + class A { + [Symbol.iterator]() { + return { + next() { + return { done: true, value: undefined }; + }, + }; + } + } + + const a = { ...new A() }; + `, + errors: [ + { + column: 21, + endColumn: 31, + line: 12, + messageId: 'noIterableSpreadInObject', + }, + ], + }, + { + code: ` + const o = { ...new Date() }; + `, + errors: [ + { + column: 21, + endColumn: 34, + line: 2, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + declare class HTMLElementLike {} + declare const element: HTMLElementLike; + const o = { ...element }; + `, + errors: [ + { + column: 21, + endColumn: 31, + line: 4, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + declare const regex: RegExp; + const o = { ...regex }; + `, + errors: [ + { + column: 21, + endColumn: 29, + line: 3, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + class A { + a = 1; + public b = 2; + private c = 3; + protected d = 4; + static e = 5; + } + + const o = { ...new A() }; + `, + errors: [ + { + column: 21, + endColumn: 31, + line: 10, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + class A { + a = 1; + } + + const a = new A(); + + const o = { ...a }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 8, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + class A { + a = 1; + } + + declare const a: A; + + const o = { ...a }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 8, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + class A { + a = 1; + } + + declare function getA(): A; + + const o = { ...getA() }; + `, + errors: [ + { + column: 21, + endColumn: 30, + line: 8, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + class A { + a = 1; + } + + declare function getA(arg: T): T; + + const o = { ...getA() }; + `, + errors: [ + { + column: 21, + endColumn: 30, + line: 8, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + class A { + a = 1; + } + + class B extends A {} + + const o = { ...new B() }; + `, + errors: [ + { + column: 21, + endColumn: 31, + line: 8, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + class A { + a = 1; + } + + declare const a: A | { b: string }; + + const o = { ...a }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 8, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + class A { + a = 1; + } + + declare const a: A & { b: string }; + + const o = { ...a }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 8, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + { + code: ` + class A {} + + const o = { ...A }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 4, + messageId: 'noClassDeclarationSpreadInObject', + }, + ], + }, + { + code: ` + const A = class {}; + + const o = { ...A }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 4, + messageId: 'noClassDeclarationSpreadInObject', + }, + ], + }, + { + code: ` + class Declaration { + declaration?: boolean; + } + const Expression = class { + expression?: boolean; + }; + + declare const either: typeof Declaration | typeof Expression; + + const o = { ...either }; + `, + errors: [ + { + column: 21, + endColumn: 30, + line: 11, + messageId: 'noClassDeclarationSpreadInObject', + }, + ], + }, + { + code: ` + const A = Set; + + const o = { ...A }; + `, + errors: [ + { + column: 21, + endColumn: 25, + line: 4, + messageId: 'noClassDeclarationSpreadInObject', + }, + ], + }, + { + code: ` + const a = { + ...class A { + static value = 1; + nonStatic = 2; + }, + }; + `, + errors: [ + { + column: 11, + endColumn: 12, + endLine: 6, + line: 3, + messageId: 'noClassDeclarationSpreadInObject', + }, + ], + }, + { + code: noFormat` + const a = { ...(class A { static value = 1 }) } + `, + errors: [ + { + column: 21, + endColumn: 54, + line: 2, + messageId: 'noClassDeclarationSpreadInObject', + }, + ], + }, + { + code: noFormat` + const a = { ...new (class A { static value = 1; })() }; + `, + errors: [ + { + column: 21, + endColumn: 61, + line: 2, + messageId: 'noClassInstanceSpreadInObject', + }, + ], + }, + ], +}); 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..09a7e195ef81 --- /dev/null +++ b/packages/eslint-plugin/tests/schema-snapshots/no-misused-spread.shot @@ -0,0 +1,137 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Rule schemas should be convertible to TS types for documentation purposes no-misused-spread 1`] = ` +" +# SCHEMA: + +[ + { + "additionalProperties": false, + "properties": { + "allow": { + "description": "An array of type specifiers that are known to be safe to spread.", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "from": { + "enum": ["file"], + "type": "string" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + ] + }, + "path": { + "type": "string" + } + }, + "required": ["from", "name"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "from": { + "enum": ["lib"], + "type": "string" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + ] + } + }, + "required": ["from", "name"], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "from": { + "enum": ["package"], + "type": "string" + }, + "name": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + ] + }, + "package": { + "type": "string" + } + }, + "required": ["from", "name", "package"], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "type": "object" + } +] + + +# TYPES: + +type Options = [ + { + /** An array of type specifiers that are known to be safe to spread. */ + allow?: ( + | { + from: 'file'; + name: [string, ...string[]] | string; + path?: string; + } + | { + from: 'lib'; + name: [string, ...string[]] | string; + } + | { + from: 'package'; + name: [string, ...string[]] | string; + package: string; + } + | string + )[]; + }, +]; +" +`; diff --git a/packages/rule-tester/tests/flat-config-schema.test.ts b/packages/rule-tester/tests/flat-config-schema.test.ts index 86356f99b4e8..b0131260d5ed 100644 --- a/packages/rule-tester/tests/flat-config-schema.test.ts +++ b/packages/rule-tester/tests/flat-config-schema.test.ts @@ -16,7 +16,7 @@ describe('merge', () => { expect(result).toEqual({ ...first, ...second }); }); - it('returns an emtpy object if both values are undefined', () => { + it('returns an empty object if both values are undefined', () => { const result = merge(undefined, undefined); expect(result).toEqual({}); diff --git a/packages/typescript-eslint/src/configs/all.ts b/packages/typescript-eslint/src/configs/all.ts index cd16389445a5..4bf1a3b4c5d4 100644 --- a/packages/typescript-eslint/src/configs/all.ts +++ b/packages/typescript-eslint/src/configs/all.ts @@ -87,6 +87,7 @@ export default ( '@typescript-eslint/no-meaningless-void-operator': 'error', '@typescript-eslint/no-misused-new': 'error', '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/no-misused-spread': 'error', '@typescript-eslint/no-mixed-enums': 'error', '@typescript-eslint/no-namespace': 'error', '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', diff --git a/packages/typescript-eslint/src/configs/disable-type-checked.ts b/packages/typescript-eslint/src/configs/disable-type-checked.ts index 8ab7a8df71f6..838f00e62c29 100644 --- a/packages/typescript-eslint/src/configs/disable-type-checked.ts +++ b/packages/typescript-eslint/src/configs/disable-type-checked.ts @@ -32,6 +32,7 @@ export default ( '@typescript-eslint/no-implied-eval': 'off', '@typescript-eslint/no-meaningless-void-operator': 'off', '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/no-misused-spread': 'off', '@typescript-eslint/no-mixed-enums': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', diff --git a/packages/typescript-eslint/src/configs/strict-type-checked-only.ts b/packages/typescript-eslint/src/configs/strict-type-checked-only.ts index ef29e1006bc4..4d424ec0968f 100644 --- a/packages/typescript-eslint/src/configs/strict-type-checked-only.ts +++ b/packages/typescript-eslint/src/configs/strict-type-checked-only.ts @@ -35,6 +35,7 @@ export default ( '@typescript-eslint/no-implied-eval': 'error', '@typescript-eslint/no-meaningless-void-operator': 'error', '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/no-misused-spread': 'error', '@typescript-eslint/no-mixed-enums': 'error', '@typescript-eslint/no-redundant-type-constituents': 'error', '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'error', diff --git a/packages/typescript-eslint/src/configs/strict-type-checked.ts b/packages/typescript-eslint/src/configs/strict-type-checked.ts index e5c7c4c22062..8753687ce006 100644 --- a/packages/typescript-eslint/src/configs/strict-type-checked.ts +++ b/packages/typescript-eslint/src/configs/strict-type-checked.ts @@ -49,6 +49,7 @@ export default ( '@typescript-eslint/no-meaningless-void-operator': 'error', '@typescript-eslint/no-misused-new': 'error', '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/no-misused-spread': 'error', '@typescript-eslint/no-mixed-enums': 'error', '@typescript-eslint/no-namespace': 'error', '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', diff --git a/packages/website/src/theme/NotFound/Content/index.tsx b/packages/website/src/theme/NotFound/Content/index.tsx index 26990fa82a88..cbb83e4bdcc9 100644 --- a/packages/website/src/theme/NotFound/Content/index.tsx +++ b/packages/website/src/theme/NotFound/Content/index.tsx @@ -6,6 +6,10 @@ import styles from './styles.module.css'; export default function NotFound(): React.JSX.Element { const location = useLocation(); + // https://github.com/sindresorhus/eslint-plugin-unicorn/issues/2521 + // eslint-disable-next-line @typescript-eslint/no-misused-spread + const pathNameQuoted = [...`'${location.pathname}'`]; + return (

@@ -13,7 +17,7 @@ export default function NotFound(): React.JSX.Element {

$ npx eslint .
- {[...`'${location.pathname}'`].map((letter, i) => ( + {pathNameQuoted.map((letter, i) => ( {letter}