standard: Remove no-return-await as it sets traps during code refactoring and causes inferior stack traces
What version of this package are you using? 14.3.1
What problem do you want to solve?
The no-return-await rule introduced in #695 is the only one with which I continue to disagree, and I think since the arrival of async stack traces in Node, there is now another reason to rethink it.
Therefore I want to open a discussion here. My point is that the no-return-await rule (which disallows “redundant” use of return await promise) has two negative effects:
1) It reduces the usefulness of error stack traces
Node now has zero-cost async stack traces with the --async-stack-traces flag. There, it makes a difference whether return promise or return await promise is used - the former omits a stack frame.
Example:
const delay = require('util').promisify(setTimeout)
const throws = async () => { await delay(100); throw new Error('oh noez') }
const a = async () => { return throws() }
const b = async () => { return await throws() }
async function main () {
try { await a() } catch (e) { console.error(e) }
try { await b() } catch (e) { console.error(e) }
}
main()
Result:
david@CHE-X1:~/z/Temp $ node --async-stack-traces example.js
Error: oh noez
at throws (/mnt/c/Users/david/Temp/example.js:2:54)
at async main (/mnt/c/Users/david/Temp/example.js:8:8)
Error: oh noez
at throws (/mnt/c/Users/david/Temp/example.js:2:54)
at async b (/mnt/c/Users/david/Temp/example.js:5:32)
at async main (/mnt/c/Users/david/Temp/example.js:9:8)
Note how there is no stack frame for a.
2) It lays out a trap for code refactoring by making it easy to overlook that an async function is being called
Example with subtle unintended behavior:
return somethingAsync()
// refactor to
try {
return somethingAsync()
} catch (e) {
// ...
}
// whoops, now suddenly it makes a difference - errors are caught only
// if they happen in the synchronous part of `somethingAsync` - hard to spot!
Example with more obvious unintended behavior:
return somethingAsync()
// refactor to
return somethingAsync() || null
// whoops, that did nothing, because the promise won't be null
Another example:
return somethingAsync()
// refactor to
const value = somethingAsync()
await moreStuff()
return value
// whoops now somethingAsync runs in parallel to moreStuff, which
// may not be immediately obvious and cause a race condition
Or even:
return somethingAsync()
// refactor to
return { value: somethingAsync() }
// whoops now the outer code gets a promise in an unexpected place
// and if it previously was a boolean, it may not even be noticed, but
// conditions will now get always a truthy value...!
There are many more examples like this which don’t immediately crash (it is easy to spot something like somethingAsync().toUpperCase() is not a function, but my examples above are more subtle). If I modify existing code, I may not be familiar with every function that is being called (and I may even use a refactor tool where I just select the returned value and extract to a variable for example). Usually, I’d easily recognize that await somethingAsync() should probably stay this way, but with return somethingAsync() I may not realize that in any place other than the return statement, I’d need an await in front (and even if it stays in a return, other things like the aforementioned try block may suddenly require a change here - and imagine that the return is somewhere inside a larger block of code which I’m now wrapping with try)!
I had all of these issues from my examples happen in real life.
To work around this issue, I manually mark return somethingAsync() with a comment, e.g. return somethingAsync() // async but that seems ugly and much less useful than having the obvious await there (plus, it doesn’t solve the try case).
What do you think?
What do you think is the correct solution to this problem?
Remove the no-return-await rule
Are you willing to submit a pull request to implement this change? I guess so…? (but I guess it would be easier for you)
About this issue
- Original URL
- State: closed
- Created 5 years ago
- Reactions: 9
- Comments: 43 (15 by maintainers)
Well, I have to remember that I need to add it inside the try, which is very easy to forget if the linter normally forces you to not add it.
I’ve actually seen this caught in code review more than once, people forgetting to add
awaitin thetry, and I think that this could be somewhat mitigated by having the mindset that async values should always be awaited.I still strongly feel that we should:
return awaitin the TypeScript partreturn awaitwhen you are trying to return aPromiseLike<_>from anasync function, thealwayssetting of the@typescript-eslint/return-awaitrule)I value your input, @CherryDT.
So, your claim is that the
no-return-awaitrule is bad practice and that the opposite is good practice.Your examples make sense to me. Personally, when I am using TypeScript, I am protected from that kind of refactoring errors. But that’s just a side note.
Is there discussion in ESLint about the usefulness of
no-return-await? Yup. Here it is.I feel that what @ilyavolodin suggested is reasonable and would allow us in Standard to choose the opposite. Rename the rule to allow the opposite or make a new rule that does the opposite. @CherryDT would you be interested in making that contribution? That would allow you to enjoy your preferred style regardless of what Standard ends up deciding.
And I would appreciate input from more users on this ❤️ .
Hmm, I don’t really see how it “incorrect”? and it isn’t really “redundant” since the behaviour is different.
This has been brought up in another thread about this, but this isn’t because of one JS engine. I don’t see how one engine could choose to add a function that isn’t running anymore to the stack trace?
Not really though, if you return a Promise from an async function it doesn’t wait for that result. It immediately returns that promise and the function is no no longer executing. This is why it would also be wrong for a specific engine to keep the function on the stack trace, and why
return awaitis necessary for that.I really don’t understand why this would give an incorrect understanding of the language. To me it seems like the root of the problem here is that Promises flat maps automatically when resolved with another promise.
This is convenient, but this is really what hides what is happening here. Without this feature return a promise without doing
awaiton it from anasyncfunction would actually return aPromise<Promise<...>>.In e.g. Rust this is very nice because you can see this very clearly, and thus it requires you to do their equivalent of
return awaitunless you want to return a future of another future.I really agree with this, an extra await is not going to be your performance bottleneck, and we are optimising for code readability and correctness firstly.
I really don’t think that it will confuse people though 🤔 As I said earlier, I feel that the thing confusing people is really the automagic-flat-mapping…
I still strongly feel that we should remove
no-return-awaitin Standard, and add@typescript-eslint/return-await=alwaysin Standard TS.Fixed in 14.3.4 :shipit:
Sure, i agree - and you should use
return await xwhen you need to await the result of x within the function, ie, inside a try block. Otherwise, you’re awaiting for no apparent purpose.Since we have no implementation of an alternative and since the rule seems to me to be more detriment than use, I would have it removed.
The better stack traces are a nice side effect but the major issue is that it covers up what the code is actually doing, namely waiting for another asynchronous result. Hence all my examples about how it hinders readability and adds traps for later modifications. My point is that it is bad practice to encourage that. I and my colleagues have wasted several hours of our lives due to traps created by this rule. That can’t be the goal…
There are many other rules following the philosophy of making the code “normalized” and easier to read and less likely to break on changes (to code or environment), so why should this one actually push into the opposite direction? For example, there is
no-path-concat, and I’m quite sure thatpath.joinis slower than+by at least an order of magnitude. Orno-throw-literal, I betthrow new Error('xyz')andif (e && e.message === 'xyz')are slower thanthrow 'xyz'andif (e === 'xyz'). Yet, these rules are worth the (small) speed difference, since they avoid trouble.(In the path case, it would break on another platform, and in the error case it would break when someone makes the assumption that errors have a property
stackormessagefor example - just like the assumption that turningreturn getCustomer()intoreturn getCustomer() || defaultCustomerwill work as expected…)Correct me if I’m wrong, but I thought the goal of StandardJS was to eventually save time developing, increase code clarity and avoid common pitfalls (such as these refactoring issues) - not micro-optimize code. For example, the extra parentheses in
if ((x = y))are also redundant for sure, yet they add an important hint to the reader and avoid human errors - just likereturn await. (I think the opposite rule should exist - always usereturn awaitin this case.)I quote:
(Empasis mine.)
Maybe I should edit this issue to reorder my points, since actually point 2 is the major issue, not point 1. I don’t understand why everyone is arguing about “stack traces for one engine” (here and at eslint) when the main problem is another one.
@CherryDT what i mean is,
return await, outside of atry, is redundant and incorrect and makes your code artificially slower. If someone wants to incur this cost in exchange for better stack traces in one JS engine, that’s fine! but they should have to disable the rule for that.