Async: Treat functions without a callback as promises

Created on 9 Jul 2019  ·  12Comments  ·  Source: caolan/async

The current behavior is very surprising:

await async.parallel({foo: async()=>Promise.all([1,2])})
// { foo: [ 1, 2 ] }
await async.parallel({foo: ()=>Promise.all([1,2])})
// waits forever because non-async function expects a ballback

Because for short functions, it's tempting to just write foo: () => doSomething(). It's very easy to forget the async keyword there

But currently this won't work, it needs to be written as foo: async () => doSomething()

Could we rather check if a callback is passed or not, instead of checking if the function is an AsyncFunction?

wont fix

Most helpful comment

Even with this issue closed, as of the linked issue above, I'd like to re-emphasize on @caub statements here.
The current behaviour confused me a lot, and while I (kind of) understand the technical limitations discussed here, I believe that the documentation is simply not in line with the current behaviour.
For e.g. mapLimit it says "Returns: a promise, if no callback is passed" - I started with the callback version, and then basically left out the callback and was surprised that I don't get the Promise (because I didn't have the 'async' keyword).
also, the 'callback' statement in the iteratee function isn't needed then, instead you need to 'return' a value.
So, either the behaviour should be changed (preferred) or at least documentation and examples should be aligned in my point of view to avoid confusion.

All 12 comments

We have no way of being able to detect reliably whether a function returns a promise or not, and whether we need to pass a callback to it or not beforehand. (Function.length or parsing Function.toString() isn't good enough) This is what asyncify is about -- converting promise-returning functions to functions that take a callback. Since we can detect async functions since they have a different type, we can automatically asyncify them (which is how this works internally). But for ordinary functions that happen to return a promise, we have no way to tell before Async calls them.

@aearly hmm right, so maybe something simpler like detecting the callback at the top-level?

async function parallel(o, cb) {
  if (!cb) { // promise behavior
    return Object.fromEntries(
      await Promise.all(Object.entries(o).map(async ([k, v]) => [k, await v()]))
    )
  }
  // else do the callback behavior
}

We already so something like that -- return a promise if you omit the callback to async.parallel. We don't assume the functions you pass to it return promises if you omit the callback either because we have no way to assume user intent. Maybe I'm misunderstanding what you mean?

Ok, let's be concrete: https://repl.it/@caub/async-async

// npm.im/async expects all inner functions to be async,
// even when using no outer callback (as 2nd arg of async.parallel)
console.log(
  await async.parallel({
    foo: async () => delay(1000).then(() => 1),
    bar: async (rs) => {
      return 2
    },
  })
);

// Doesn't work without those async,
// but I'd like this to resolve just like above instead of freezing
console.log(
  await async.parallel({
    foo: () => delay(1000).then(() => 1),
    bar: () => {
      return 2
    },
  })
);

I think, in a future next major version, it could be interesting to only support callback in a way similar to this:

await Promise.all([
  { then(resolve){ setTimeout(resolve, 50, 1) } }, // callback way
  2,
  delay(45).then(())=>3)
])
// [1, 2, 3]

removing the need for unnecessary async keywords in the inner functions

The advantage is more clarity. I understand your point about not assuming user intent, but you're still assuming people will not forget the async keyword when wanting to use Promise resolving

That would break backwards compatibility in a pretty huge way -- for example you couldn't do things like Async.map(fileNames, fs.readFile) any more.

Could you explain where it would break?

What currently break is:

await async.map(['server.js', 'package.json'], fs.readFile) // works
await async.map(['server.js', 'package.json'], fs.promises.readFile) // works
await async.map(['server.js', 'package.json'], s => fs.promises.readFile(s)) // doesn't work
await async.map(['server.js', 'package.json'], async s => fs.promises.readFile(s)) // works

The idea is to fix the 3rd one

I get those problems constantly, for example I wrote:

  // ...
  doc = await async.map(items, item => item.toDoc(currentUser, options));

somewhere, and again, timeout because I was missing a async item => item.toDoc..

If I'm alone in that case, it's not worth, but if more people are in this situation, I think it's worth to consider the proposed change

This example with async.map is also very representative, since you can see my point with how

  doc = await Promise.all(items.map(item => item.toDoc(currentUser, options)));

works without the need of an async item => item.toDoc..

There's no way to reliably fix the 3rd one, this has been discussed a lot in the past. We can't check the return value of a function and then retroactively not pass a callback to the function. The async keyword is as good as it gets.

@caub Shameless plug here, but I think asyncp has the ability to handle what you're looking for here.

Even with this issue closed, as of the linked issue above, I'd like to re-emphasize on @caub statements here.
The current behaviour confused me a lot, and while I (kind of) understand the technical limitations discussed here, I believe that the documentation is simply not in line with the current behaviour.
For e.g. mapLimit it says "Returns: a promise, if no callback is passed" - I started with the callback version, and then basically left out the callback and was surprised that I don't get the Promise (because I didn't have the 'async' keyword).
also, the 'callback' statement in the iteratee function isn't needed then, instead you need to 'return' a value.
So, either the behaviour should be changed (preferred) or at least documentation and examples should be aligned in my point of view to avoid confusion.

@caub This is the reason
https://github.com/caolan/async/blob/8aecf108b3922bc5211036706a0f6f75e02bd42b/lib/internal/wrapAsync.js#L3:L5

function isAsync(fn) {
    return fn[Symbol.toStringTag] === 'AsyncFunction';
}

This way, it's easier to check if the function returns a promise without actually calling it. But still agree with you that this is against the async/await conventions. As adding an async keyword without an internal await is considered a bad practice in most of cases actually.

from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function

The body of an async function can be thought of as being split by zero or more await expressions. Top-level code, up to and including the first await expression (if there is one), is run synchronously.

... and so it'll be useless and worst it will add some performance overhead if the code is transpiled by babel for example as it'll add more code and steps just because of the existence of the async keyword

For example, the following:

async function foo() {
   return 1
}

...is equivalent to:

function foo() {
   return Promise.resolve(1)
}

but with this library,

async.mapLimit([1], 1, async v => v)

and

async.mapLimit([1], 1, v => Promise.resolve(v)))

are not equivalent!

Was this page helpful?
0 / 5 - 0 ratings