diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index 1ec6df21604..6474ff6cf72 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -96,6 +96,7 @@ Install [`eslint-config-prettier`](https://github.com/prettier/eslint-config-pre | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -------- | | [`@typescript-eslint/adjacent-overload-signatures`](./docs/rules/adjacent-overload-signatures.md) | Require that member overloads be consecutive (`adjacent-overload-signatures` from TSLint) | :heavy_check_mark: | | | [`@typescript-eslint/array-type`](./docs/rules/array-type.md) | Requires using either `T[]` or `Array` for arrays (`array-type` from TSLint) | :heavy_check_mark: | :wrench: | +| [`@typescript-eslint/await-promise`](./docs/rules/await-promise.md) | Disallow awaiting a value that is not a Promise (`await-promise` from TSLint) | :heavy_check_mark: | :wrench: | | [`@typescript-eslint/ban-types`](./docs/rules/ban-types.md) | Enforces that types will not to be used (`ban-types` from TSLint) | :heavy_check_mark: | :wrench: | | [`@typescript-eslint/camelcase`](./docs/rules/camelcase.md) | Enforce camelCase naming convention | :heavy_check_mark: | | | [`@typescript-eslint/class-name-casing`](./docs/rules/class-name-casing.md) | Require PascalCased class and interface names (`class-name` from TSLint) | :heavy_check_mark: | | diff --git a/packages/eslint-plugin/ROADMAP.md b/packages/eslint-plugin/ROADMAP.md index 378a3140ffb..49b7a296775 100644 --- a/packages/eslint-plugin/ROADMAP.md +++ b/packages/eslint-plugin/ROADMAP.md @@ -1,10 +1,10 @@ # Roadmap -✅ (28) = done -🌟 (79) = in ESLint core -🔌 (33) = in another plugin -🌓 (16) = implementations differ or ESLint version is missing functionality -🛑 (70) = unimplemented +✅ (28) = done +🌟 (79) = in ESLint core +🔌 (33) = in another plugin +🌓 (17) = implementations differ or ESLint version is missing functionality +🛑 (69) = unimplemented ## TSLint rules @@ -39,7 +39,7 @@ | TSLint rule | | ESLint rule | | ------------------------------------ | :-: | --------------------------------------------------------------------- | -| [`await-promise`] | 🛑 | N/A | +| [`await-promise`] | ✅ | [`@typescript-eslint/no-misused-new`] | | [`ban-comma-operator`] | 🌟 | [`no-sequences`][no-sequences] | | [`ban`] | 🌟 | [`no-restricted-properties`][no-restricted-properties] | | [`curly`] | 🌟 | [`curly`][curly] | @@ -96,7 +96,7 @@ | [`use-default-type-parameter`] | 🛑 | N/A | | [`use-isnan`] | 🌟 | [`use-isnan`][use-isnan] | -[1] The ESLint rule also supports silencing with an extra set of parens (`if ((foo = bar)) {}`) +[1] The ESLint rule also supports silencing with an extra set of parens (`if ((foo = bar)) {}`) [2] Missing private class member support. [`@typescript-eslint/no-unused-vars`] adds support for some TS-specific features. ### Maintainability @@ -120,7 +120,7 @@ | [`prefer-readonly`] | 🛑 | N/A | | [`trailing-comma`] | 🌓 | [`comma-dangle`][comma-dangle] or [Prettier] | -[1] Only warns when importing deprecated symbols +[1] Only warns when importing deprecated symbols [2] Missing support for blank-line-delimited sections ### Style @@ -179,7 +179,7 @@ | [`variable-name`] | 🌟 | [2] | | [`whitespace`] | 🔌 | Use [Prettier] | -[1] Recommended config: `["error", { blankLine: "always", prev: "*", next: "return" }]` +[1] Recommended config: `["error", { blankLine: "always", prev: "*", next: "return" }]` [2] [`camelcase`][camelcase], [`no-underscore-dangle`][no-underscore-dangle], [`id-blacklist`][id-blacklist], and/or [`id-match`][id-match] ## tslint-microsoft-contrib rules @@ -245,10 +245,10 @@ Relevant plugins: [`chai-expect-keywords`](https://github.com/gavinaiken/eslint- | `use-named-parameter` | 🛑 | N/A | | `use-simple-attributes` | 🛑 | N/A | -[1] Enforces blank lines both at the beginning and end of a block -[2] Recommended config: `["error", "ForInStatement"]` -[3] Recommended config: `["error", "declaration", { "allowArrowFunctions": true }]` -[4] Recommended config: `["error", { "terms": ["BUG", "HACK", "FIXME", "LATER", "LATER2", "TODO"], "location": "anywhere" }]` +[1] Enforces blank lines both at the beginning and end of a block +[2] Recommended config: `["error", "ForInStatement"]` +[3] Recommended config: `["error", "declaration", { "allowArrowFunctions": true }]` +[4] Recommended config: `["error", { "terms": ["BUG", "HACK", "FIXME", "LATER", "LATER2", "TODO"], "location": "anywhere" }]` [5] Does not check class fields. [insecure-random]: https://github.com/desktop/desktop/blob/master/eslint-rules/insecure-random.js @@ -310,7 +310,7 @@ Relevant plugins: [`chai-expect-keywords`](https://github.com/gavinaiken/eslint- | `react-a11y-titles` | 🛑 | N/A | | `react-anchor-blank-noopener` | 🛑 | N/A | -[1] TSLint rule is more strict +[1] TSLint rule is more strict [2] ESLint rule only reports for click handlers [prettier]: https://prettier.io @@ -558,6 +558,7 @@ Relevant plugins: [`chai-expect-keywords`](https://github.com/gavinaiken/eslint- [`@typescript-eslint/adjacent-overload-signatures`]: https://github.com/bradzacher/@typescript-eslint/eslint-plugin/blob/master/docs/rules/adjacent-overload-signatures.md +[`@typescript-eslint/await-promise`]: https://github.com/bradzacher/@typescript-eslint/eslint-plugin/blob/master/docs/rules/await-promise.md [`@typescript-eslint/ban-types`]: https://github.com/bradzacher/@typescript-eslint/eslint-plugin/blob/master/docs/rules/ban-types.md [`@typescript-eslint/explicit-member-accessibility`]: https://github.com/bradzacher/@typescript-eslint/eslint-plugin/blob/master/docs/rules/explicit-member-accessibility.md [`@typescript-eslint/member-ordering`]: https://github.com/bradzacher/@typescript-eslint/eslint-plugin/blob/master/docs/rules/member-ordering.md diff --git a/packages/eslint-plugin/docs/rules/await-promise.md b/packages/eslint-plugin/docs/rules/await-promise.md new file mode 100644 index 00000000000..3deb893e04c --- /dev/null +++ b/packages/eslint-plugin/docs/rules/await-promise.md @@ -0,0 +1,83 @@ +# Disallows awaiting a value that is not a Promise (await-promise) + +This rule disallows awaiting a value that is not a Promise. +While it is valid JavaScript to await a non-`Promise`-like value (it will resolve immediately), this pattern is often a programmer error, such as forgetting to add parenthesis to call a function that returns a Promise. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```ts +await 'value'; + +const createValue = () => 'value'; +await createValue(); +``` + +```ts +// An array of Promises is not the same as an AsyncIterable +async function incorrect(arrayOfPromises: Array) { + for await (const element of arrayOfPromises) {} +} +``` + +Examples of **correct** code for this rule: + +```ts +await Promise.resolve('value'); + +const createValue = (async() = 'value'); +await createValue(); +``` + +```ts +async function overIterable(iterable: AsyncIterable) { + for await (const element of iterable) { + } +} + +async function overIterableIterator(iterable: AsyncIterableIterator) { + for await (const element of iterable) { + } +} +``` + +## Options + +The rule accepts an options object with the following property: + +- `allowedPromiseNames` any extra names of classes or interfaces to be considered "awaitable" in `await` statements. + +Classes named `Promise` may always be awaited. +`allowedPromiseNames` does not affect `for-await-of` statements. + +### allowedPromiseNames + +Examples of **incorrect** code for this rule with `{ allowedPromiseNames: ["Thenable"] }`: + +```ts +class Thenable { + /* ... */ +} + +await new Thenable(); +``` + +Examples of **incorrect** code for this rule with `{ allowedPromiseNames: ["Thenable"] }`: + +```ts +class OtherClass { + /* ... */ +} + +await new OtherClass(); +``` + +## When Not To Use It + +If you want to allow code to `await` non-Promise values. +This is generally not preferred, but can sometimes be useful for visual consistency. + +## Related to + +- TSLint: ['await-promise'](https://palantir.github.io/tslint/rules/await-promise) diff --git a/packages/eslint-plugin/lib/rules/await-promise.js b/packages/eslint-plugin/lib/rules/await-promise.js new file mode 100644 index 00000000000..08f46cd37e6 --- /dev/null +++ b/packages/eslint-plugin/lib/rules/await-promise.js @@ -0,0 +1,124 @@ +/** + * @fileoverview Disallows awaiting a value that is not a Promise + * @author Josh Goldberg + */ +'use strict'; +const tsutils = require('tsutils'); +const ts = require('typescript'); +const util = require('../util'); + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +const defaultOptions = [ + { + allowedPromiseNames: [] + } +]; + +/** + * @type {import("eslint").Rule.RuleModule} + */ +module.exports = { + meta: { + docs: { + description: 'Disallows awaiting a value that is not a Promise', + category: 'Functionality', + recommended: 'error', + extraDescription: [util.tslintRule('await-promise')], + url: util.metaDocsUrl('await-promise') + }, + fixable: null, + messages: { + await: 'Invalid `await` of a non-Promise value.', + forOf: 'Invalid `for-await-of` of a non-AsyncIterable value.' + }, + schema: [ + { + type: 'object', + properties: { + allowedPromiseNames: { + type: 'array', + items: { + type: 'string' + } + } + }, + additionalProperties: false + } + ], + type: 'problem' + }, + + create(context) { + const options = util.applyDefault(defaultOptions, context.options)[0]; + + const allowedAsyncIterableNames = new Set([ + 'AsyncIterable', + 'AsyncIterableIterator' + ]); + + const allowedPromiseNames = new Set([ + 'Promise', + ...options.allowedPromiseNames + ]); + + const parserServices = util.getParserServices(context); + const checker = parserServices.program.getTypeChecker(); + + function validateNode(node, allowedSymbolNames, messageId) { + const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node); + const type = checker.getTypeAtLocation(originalNode.expression); + + if (!containsType(type, allowedSymbolNames)) { + context.report({ + messageId, + node + }); + } + } + + return { + AwaitExpression(node) { + validateNode(node, allowedPromiseNames, 'await'); + }, + ForOfStatement(node) { + if (node.await) { + validateNode(node, allowedAsyncIterableNames, 'forOf'); + } + } + }; + } +}; + +/** + * @param {string} type Type being awaited upon. + * @param {Set} allowedNames Symbol names being checked for. + */ +function containsType(type, allowedNames) { + if (tsutils.isTypeFlagSet(type, ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { + return true; + } + + if (tsutils.isTypeReference(type)) { + type = type.target; + } + + if ( + typeof type.symbol !== 'undefined' && + allowedNames.has(type.symbol.name) + ) { + return true; + } + + if (tsutils.isUnionOrIntersectionType(type)) { + return type.types.some(t => containsType(t, allowedNames)); + } + + const bases = type.getBaseTypes(); + return ( + typeof bases !== 'undefined' && + bases.some(t => containsType(t, allowedNames)) + ); +} diff --git a/packages/eslint-plugin/tests/lib/rules/await-promise.js b/packages/eslint-plugin/tests/lib/rules/await-promise.js new file mode 100644 index 00000000000..7c70c92ed9c --- /dev/null +++ b/packages/eslint-plugin/tests/lib/rules/await-promise.js @@ -0,0 +1,167 @@ +/** + * @fileoverview Disallows awaiting a value that is not a Promise + * @author Josh Goldberg + */ +'use strict'; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/await-promise'), + RuleTester = require('eslint').RuleTester, + path = require('path'); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const rootDir = path.join(process.cwd(), 'tests/fixtures/'); +const parserOptions = { + ecmaVersion: 2018, + tsconfigRootDir: rootDir, + project: './tsconfig.json' +}; +const ruleTester = new RuleTester({ + parserOptions, + parser: '@typescript-eslint/parser' +}); +const messages = { + await: 'Invalid `await` of a non-Promise value.', + forOf: 'Invalid `for-await-of` of a non-AsyncIterable value.' +}; + +ruleTester.run('await-promise', rule, { + valid: [ + ` +async function test() { + await Promise.resolve("value"); + await Promise.reject(new Error("message")); + + await (async () => true)(); + + function returnsPromise() { + return Promise.resolve("value"); + } + await returnsPromise(); + + async function returnsPromiseAsync() {} + await returnsPromiseAsync(); + + let anyValue: any; + await anyValue; + + let unknownValue: unknown; + await unknownValue; + + const numberPromise: Promise; + await numberPromise; + + class Foo extends Promise {} + const foo: Foo = Foo.resolve(2); + await foo; + + class Bar extends Foo {} + const bar: Bar = Bar.resolve(2); + await bar; + + await (Math.random() > 0.5 ? numberPromise : 0); + await (Math.random() > 0.5 ? foo : 0); + await (Math.random() > 0.5 ? bar : 0); + + const intersectionPromise: Promise & number; + await intersectionPromise; +} +`, + { + code: ` +class AllowedCustomClass { } + +async function test() { + await new AllowedCustomClass(); +} +`, + options: [ + { + allowedPromiseNames: ['AllowedCustomClass'] + } + ] + }, + ` +async function correct(foo: AsyncIterableIterator) { + for await (const element of foo) {} +} + +async function correct2() { + for await (const element of asyncGenerator()) {} +} + +async function correct(foo: AsyncIterable) { + for await (const element of foo) {} +} + +async function correct3(foo: AsyncIterableIterator | AsyncIterableIterator) { + for await (const element of foo) {} +} + ` + ], + + invalid: [ + { + code: ` +async function test() { + await 0; + await "value"; + + await (Math.random() > 0.5 ? "" : 0); + + class NonPromise extends Array {} + await new NonPromise(); +} +`, + errors: [ + { + line: 3, + message: messages.await, + type: 'AwaitExpression' + }, + { + line: 4, + message: messages.await, + type: 'AwaitExpression' + }, + { + line: 6, + message: messages.await, + type: 'AwaitExpression' + }, + { + line: 9, + message: messages.await, + type: 'AwaitExpression' + } + ] + }, + { + code: ` +class NotAllowedCustomClass { } + +function test() { + await new NotAllowedCustomClass(); +} +`, + errors: [ + { + line: 5, + message: messages.await, + type: 'AwaitExpression' + } + ], + options: [ + { + allowedPromiseNames: ['AllowedCustomClass'] + } + ] + } + ] +});