diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index c4507267c6f..a0b38fead9c 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -119,6 +119,7 @@ Pro Tip: For larger codebases you may want to consider splitting our linting int | [`@typescript-eslint/no-for-in-array`](./docs/rules/no-for-in-array.md) | Disallow iterating over an array with a for-in loop | :heavy_check_mark: | | :thought_balloon: | | [`@typescript-eslint/no-implied-eval`](./docs/rules/no-implied-eval.md) | Disallow the use of `eval()`-like methods | | | :thought_balloon: | | [`@typescript-eslint/no-inferrable-types`](./docs/rules/no-inferrable-types.md) | Disallows explicit type declarations for variables or parameters initialized to a number, string, or boolean | :heavy_check_mark: | :wrench: | | +| [`@typescript-eslint/no-invalid-void-type`](./docs/rules/no-invalid-void-type.md) | Disallows usage of `void` type outside of generic or return types | | | | | [`@typescript-eslint/no-misused-new`](./docs/rules/no-misused-new.md) | Enforce valid definition of `new` and `constructor` | :heavy_check_mark: | | | | [`@typescript-eslint/no-misused-promises`](./docs/rules/no-misused-promises.md) | Avoid using promises in places not designed to handle them | :heavy_check_mark: | | :thought_balloon: | | [`@typescript-eslint/no-namespace`](./docs/rules/no-namespace.md) | Disallow the use of custom TypeScript modules and namespaces | :heavy_check_mark: | | | diff --git a/packages/eslint-plugin/ROADMAP.md b/packages/eslint-plugin/ROADMAP.md index c6348ab2658..ea2837727b3 100644 --- a/packages/eslint-plugin/ROADMAP.md +++ b/packages/eslint-plugin/ROADMAP.md @@ -18,7 +18,7 @@ It lists all TSLint rules along side rules from the ESLint ecosystem that are th | [`adjacent-overload-signatures`] | ✅ | [`@typescript-eslint/adjacent-overload-signatures`] | | [`ban-ts-ignore`] | ✅ | [`@typescript-eslint/ban-ts-ignore`] | | [`ban-types`] | 🌓 | [`@typescript-eslint/ban-types`][1] | -| [`invalid-void`] | 🛑 | N/A | +| [`invalid-void`] | ✅ | [`@typescript-eslint/no-invalid-void-type`] | | [`member-access`] | ✅ | [`@typescript-eslint/explicit-member-accessibility`] | | [`member-ordering`] | ✅ | [`@typescript-eslint/member-ordering`] | | [`no-any`] | ✅ | [`@typescript-eslint/no-explicit-any`] | @@ -623,6 +623,7 @@ Relevant plugins: [`chai-expect-keywords`](https://github.com/gavinaiken/eslint- [`@typescript-eslint/restrict-plus-operands`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/restrict-plus-operands.md [`@typescript-eslint/strict-boolean-expressions`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/strict-boolean-expressions.md [`@typescript-eslint/indent`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/indent.md +[`@typescript-eslint/no-invalid-void-type`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-invalid-void-type.md [`@typescript-eslint/no-require-imports`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-require-imports.md [`@typescript-eslint/array-type`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/array-type.md [`@typescript-eslint/class-name-casing`]: https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/class-name-casing.md diff --git a/packages/eslint-plugin/docs/rules/no-invalid-void-type.md b/packages/eslint-plugin/docs/rules/no-invalid-void-type.md new file mode 100644 index 00000000000..9cc9808852f --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-invalid-void-type.md @@ -0,0 +1,101 @@ +# Disallows usage of `void` type outside of generic or return types (`no-invalid-void-type`) + +Disallows usage of `void` type outside of return types or generic type arguments. +If `void` is used as return type, it shouldn’t be a part of intersection/union type. + +## Rationale + +The `void` type means “nothing” or that a function does not return any value, +in contrast with implicit `undefined` type which means that a function returns a value `undefined`. +So “nothing” cannot be mixed with any other types. If you need this - use the `undefined` type instead. + +## Rule Details + +This rule aims to ensure that the `void` type is only used in valid places. + +The following patterns are considered warnings: + +```ts +type PossibleValues = string | number | void; +type MorePossibleValues = string | ((number & any) | (string | void)); + +function logSomething(thing: void) {} +function printArg(arg: T) {} + +logAndReturn(undefined); + +interface Interface { + lambda: () => void; + prop: void; +} + +class MyClass { + private readonly propName: void; +} +``` + +The following patterns are not considered warnings: + +```ts +type NoOp = () => void; + +function noop(): void {} + +let trulyUndefined = void 0; + +async function promiseMeSomething(): Promise {} +``` + +### Options + +```ts +interface Options { + allowInGenericTypeArguments?: boolean | string[]; +} + +const defaultOptions: Options = { + allowInGenericTypeArguments: true, +}; +``` + +#### `allowInGenericTypeArguments` + +This option lets you control if `void` can be used as a valid value for generic type parameters. + +Alternatively, you can provide an array of strings which whitelist which types may accept `void` as a generic type parameter. + +This option is `true` by default. + +The following patterns are considered warnings with `{ allowInGenericTypeArguments: false }`: + +```ts +logAndReturn(undefined); + +let voidPromise: Promise = new Promise(() => {}); +let voidMap: Map = new Map(); +``` + +The following patterns are considered warnings with `{ allowInGenericTypeArguments: ['Ex.Mx.Tx'] }`: + +```ts +logAndReturn(undefined); + +type NotAllowedVoid1 = Mx.Tx; +type NotAllowedVoid2 = Tx; +type NotAllowedVoid3 = Promise; +``` + +The following patterns are not considered warnings with `{ allowInGenericTypeArguments: ['Ex.Mx.Tx'] }`: + +```ts +type AllowedVoid = Ex.MX.Tx; +``` + +## When Not To Use It + +If you don't care about if `void` is used with other types, +or in invalid places, then you don't need this rule. + +## Compatibility + +- TSLint: [invalid-void](https://palantir.github.io/tslint/rules/invalid-void/) diff --git a/packages/eslint-plugin/src/configs/all.json b/packages/eslint-plugin/src/configs/all.json index 99e6060d670..7a6a217140a 100644 --- a/packages/eslint-plugin/src/configs/all.json +++ b/packages/eslint-plugin/src/configs/all.json @@ -50,6 +50,7 @@ "@typescript-eslint/no-for-in-array": "error", "@typescript-eslint/no-implied-eval": "error", "@typescript-eslint/no-inferrable-types": "error", + "@typescript-eslint/no-invalid-void-type": "error", "no-magic-numbers": "off", "@typescript-eslint/no-magic-numbers": "error", "@typescript-eslint/no-misused-new": "error", diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index cbc3b8c68f9..b6652f79eb4 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -40,6 +40,7 @@ import noFloatingPromises from './no-floating-promises'; import noForInArray from './no-for-in-array'; import noImpliedEval from './no-implied-eval'; import noInferrableTypes from './no-inferrable-types'; +import noInvalidVoidType from './no-invalid-void-type'; import noMagicNumbers from './no-magic-numbers'; import noMisusedNew from './no-misused-new'; import noMisusedPromises from './no-misused-promises'; @@ -142,6 +143,7 @@ export default { 'no-for-in-array': noForInArray, 'no-implied-eval': noImpliedEval, 'no-inferrable-types': noInferrableTypes, + 'no-invalid-void-type': noInvalidVoidType, 'no-magic-numbers': noMagicNumbers, 'no-misused-new': noMisusedNew, 'no-misused-promises': noMisusedPromises, diff --git a/packages/eslint-plugin/src/rules/no-invalid-void-type.ts b/packages/eslint-plugin/src/rules/no-invalid-void-type.ts new file mode 100644 index 00000000000..7d5dea30033 --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-invalid-void-type.ts @@ -0,0 +1,116 @@ +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import * as util from '../util'; + +interface Options { + allowInGenericTypeArguments: boolean | string[]; +} + +type MessageIds = + | 'invalidVoidForGeneric' + | 'invalidVoidNotReturnOrGeneric' + | 'invalidVoidNotReturn'; + +export default util.createRule<[Options], MessageIds>({ + name: 'no-invalid-void-type', + meta: { + type: 'problem', + docs: { + description: + 'Disallows usage of `void` type outside of generic or return types', + category: 'Best Practices', + recommended: false, + }, + messages: { + invalidVoidForGeneric: + '{{ generic }} may not have void as a type variable', + invalidVoidNotReturnOrGeneric: + 'void is only valid as a return type or generic type variable', + invalidVoidNotReturn: 'void is only valid as a return type', + }, + schema: [ + { + type: 'object', + properties: { + allowInGenericTypeArguments: { + oneOf: [ + { type: 'boolean' }, + { + type: 'array', + items: { type: 'string' }, + minLength: 1, + }, + ], + }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [{ allowInGenericTypeArguments: true }], + create(context, [{ allowInGenericTypeArguments }]) { + const validParents: AST_NODE_TYPES[] = [ + AST_NODE_TYPES.TSTypeAnnotation, // + ]; + const invalidGrandParents: AST_NODE_TYPES[] = [ + AST_NODE_TYPES.TSPropertySignature, + AST_NODE_TYPES.CallExpression, + AST_NODE_TYPES.ClassProperty, + AST_NODE_TYPES.Identifier, + ]; + + if (allowInGenericTypeArguments === true) { + validParents.push(AST_NODE_TYPES.TSTypeParameterInstantiation); + } + + return { + TSVoidKeyword(node: TSESTree.TSVoidKeyword): void { + /* istanbul ignore next */ + if (!node.parent?.parent) { + return; + } + + if ( + validParents.includes(node.parent.type) && + !invalidGrandParents.includes(node.parent.parent.type) + ) { + return; + } + + if ( + node.parent.type === AST_NODE_TYPES.TSTypeParameterInstantiation && + node.parent.parent.type === AST_NODE_TYPES.TSTypeReference && + Array.isArray(allowInGenericTypeArguments) + ) { + const sourceCode = context.getSourceCode(); + const fullyQualifiedName = sourceCode + .getText(node.parent.parent.typeName) + .replace(/ /gu, ''); + + if ( + !allowInGenericTypeArguments + .map(s => s.replace(/ /gu, '')) + .includes(fullyQualifiedName) + ) { + context.report({ + messageId: 'invalidVoidForGeneric', + data: { generic: fullyQualifiedName }, + node, + }); + } + + return; + } + + context.report({ + messageId: allowInGenericTypeArguments + ? 'invalidVoidNotReturnOrGeneric' + : 'invalidVoidNotReturn', + node, + }); + }, + }; + }, +}); diff --git a/packages/eslint-plugin/tests/rules/no-invalid-void-type.test.ts b/packages/eslint-plugin/tests/rules/no-invalid-void-type.test.ts new file mode 100644 index 00000000000..057da9b7e84 --- /dev/null +++ b/packages/eslint-plugin/tests/rules/no-invalid-void-type.test.ts @@ -0,0 +1,476 @@ +import rule from '../../src/rules/no-invalid-void-type'; +import { RuleTester } from '../RuleTester'; + +const ruleTester = new RuleTester({ + parser: '@typescript-eslint/parser', +}); + +ruleTester.run('allowInGenericTypeArguments: false', rule, { + valid: [ + { + code: 'type Generic = [T];', + options: [{ allowInGenericTypeArguments: false }], + }, + ], + invalid: [ + { + code: 'type GenericVoid = Generic;', + options: [{ allowInGenericTypeArguments: false }], + errors: [ + { + messageId: 'invalidVoidNotReturn', + line: 1, + column: 28, + }, + ], + }, + { + code: 'function takeVoid(thing: void) {}', + options: [{ allowInGenericTypeArguments: false }], + errors: [ + { + messageId: 'invalidVoidNotReturn', + line: 1, + column: 26, + }, + ], + }, + { + code: 'let voidPromise: Promise = new Promise(() => {});', + options: [{ allowInGenericTypeArguments: false }], + errors: [ + { + messageId: 'invalidVoidNotReturn', + line: 1, + column: 26, + }, + { + messageId: 'invalidVoidNotReturn', + line: 1, + column: 46, + }, + ], + }, + { + code: 'let voidMap: Map = new Map();', + options: [{ allowInGenericTypeArguments: false }], + errors: [ + { + messageId: 'invalidVoidNotReturn', + line: 1, + column: 26, + }, + { + messageId: 'invalidVoidNotReturn', + line: 1, + column: 50, + }, + ], + }, + ], +}); + +ruleTester.run('allowInGenericTypeArguments: true', rule, { + valid: [ + 'function func(): void {}', + 'type NormalType = () => void;', + 'let normalArrow = (): void => {};', + 'let ughThisThing = void 0;', + 'function takeThing(thing: undefined) {}', + 'takeThing(void 0);', + 'let voidPromise: Promise = new Promise(() => {});', + 'let voidMap: Map = new Map();', + ` + function returnsVoidPromiseDirectly(): Promise { + return Promise.resolve(); + } + `, + 'async function returnsVoidPromiseAsync(): Promise {}', + 'type UnionType = string | number;', + 'type GenericVoid = Generic;', + 'type Generic = [T];', + ], + invalid: [ + { + code: 'function takeVoid(thing: void) {}', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 26, + }, + ], + }, + { + code: 'const arrowGeneric = (arg: T) => {};', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 33, + }, + ], + }, + { + code: 'const arrowGeneric1 = (arg: T) => {};', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 28, + }, + ], + }, + { + code: 'const arrowGeneric2 = (arg: T) => {};', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 34, + }, + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 41, + }, + ], + }, + { + code: 'function functionGeneric(arg: T) {}', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 36, + }, + ], + }, + { + code: 'function functionGeneric1(arg: T) {}', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 31, + }, + ], + }, + { + code: 'function functionGeneric2(arg: T) {}', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 37, + }, + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 44, + }, + ], + }, + { + code: + 'declare function functionDeclaration(arg: T): void;', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 48, + }, + ], + }, + { + code: 'declare function functionDeclaration1(arg: T): void;', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 43, + }, + ], + }, + { + code: + 'declare function functionDeclaration2(arg: T): void;', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 49, + }, + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 56, + }, + ], + }, + { + code: 'functionGeneric(undefined);', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 17, + }, + ], + }, + { + code: 'declare function voidArray(args: void[]): void[];', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 34, + }, + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 43, + }, + ], + }, + { + code: 'let value = undefined as void;', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 26, + }, + ], + }, + { + code: 'let value = undefined;', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 14, + }, + ], + }, + { + code: 'function takesThings(...things: void[]): void {}', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 33, + }, + ], + }, + { + code: 'type KeyofVoid = keyof void;', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 24, + }, + ], + }, + { + code: ` + interface Interface { + lambda: () => void; + voidProp: void; + } + `, + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 4, + column: 21, + }, + ], + }, + { + code: ` + class ClassName { + private readonly propName: void; + } + `, + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 3, + column: 38, + }, + ], + }, + { + code: 'let letVoid: void;', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 14, + }, + ], + }, + { + code: ` + type VoidType = void; + + class OtherClassName { + private propName: VoidType; + } + `, + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 2, + column: 25, + }, + ], + }, + { + code: 'type UnionType2 = string | number | void;', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 37, + }, + ], + }, + { + code: 'type UnionType3 = string | ((number & any) | (string | void));', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 56, + }, + ], + }, + { + code: 'type IntersectionType = string & number & void;', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 43, + }, + ], + }, + { + code: ` + type MappedType = { + [K in keyof T]: void; + }; + `, + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 3, + column: 27, + }, + ], + }, + { + code: ` + type ConditionalType = { + [K in keyof T]: T[K] extends string ? void : string; + }; + `, + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 3, + column: 49, + }, + ], + }, + { + code: 'type ManyVoid = readonly void[];', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 26, + }, + ], + }, + { + code: 'function foo(arr: readonly void[]) {}', + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 28, + }, + ], + }, + ], +}); + +ruleTester.run('allowInGenericTypeArguments: whitelist', rule, { + valid: [ + 'type Allowed = [T];', + 'type Banned = [T];', + { + code: 'type AllowedVoid = Allowed;', + options: [{ allowInGenericTypeArguments: ['Allowed'] }], + }, + { + code: 'type AllowedVoid = Ex.Mx.Tx;', + options: [{ allowInGenericTypeArguments: ['Ex.Mx.Tx'] }], + }, + { + // eslint-disable-next-line @typescript-eslint/internal/plugin-test-formatting + code: 'type AllowedVoid = Ex . Mx . Tx;', + options: [{ allowInGenericTypeArguments: ['Ex.Mx.Tx'] }], + }, + { + // eslint-disable-next-line @typescript-eslint/internal/plugin-test-formatting + code: 'type AllowedVoid = Ex . Mx . Tx;', + options: [{ allowInGenericTypeArguments: ['Ex.Mx . Tx'] }], + }, + { + code: 'type AllowedVoid = Ex.Mx.Tx;', + options: [{ allowInGenericTypeArguments: ['Ex . Mx . Tx'] }], + }, + ], + invalid: [ + { + code: 'type BannedVoid = Banned;', + options: [{ allowInGenericTypeArguments: ['Allowed'] }], + errors: [ + { + messageId: 'invalidVoidForGeneric', + data: { generic: 'Banned' }, + line: 1, + column: 26, + }, + ], + }, + { + code: 'type BannedVoid = Ex.Mx.Tx;', + options: [{ allowInGenericTypeArguments: ['Tx'] }], + errors: [ + { + messageId: 'invalidVoidForGeneric', + data: { generic: 'Ex.Mx.Tx' }, + line: 1, + column: 28, + }, + ], + }, + { + code: 'function takeVoid(thing: void) {}', + options: [{ allowInGenericTypeArguments: ['Allowed'] }], + errors: [ + { + messageId: 'invalidVoidNotReturnOrGeneric', + line: 1, + column: 26, + }, + ], + }, + ], +});