4

I want to understand why using .flatMap() with async does not return a flatten array.

For example, for Typescript, this returns an array of numbers: I know Promisse.all and async are useless with a simple array of number, it is just easier for reproduction

const numbers = [[1], [2], [3]];
// typed as number[]
const numbersFlatten = await Promise.all(
    numbers.flatMap((number) => number),
);

When this, for Typescript, returns an array of array of numbers (just added an async) :

const numbers = [[1], [2], [3]];
// typed as number[][]
const numbersFlatten = await Promise.all(
    numbers.flatMap(async (number) => number),
);
John Kugelman
  • 349,597
  • 67
  • 533
  • 578
maximeSurmontSO
  • 189
  • 3
  • 16
  • 1
    Does it *actually return* a different result, or does something just statically infer the wrong type…?! – deceze Dec 30 '22 at 07:49

2 Answers2

9

All async functions implicitly return Promises. By making your .flatMap() callback async, it now returns a Promise that resolves to the number from your array. For .flatMap() to work correctly and for it to flatten its results, the callback should return an array, not a Promise. Below is an example of the problematic behaviour:

const numbers = [[1], [2], [3]];
const promiseAllArg = numbers.flatMap(async (number) => number); // same as `.flatMap(number => Promise.resolve(number))`, flatMap doesn't know how to flatten `Promise<number>` into a resulting array
console.log(promiseAllArg); // [Promise<[1]>, Promise<[2]>, Promise<[3]>]

Instead, you can use a regular .map() call to obtain a nested array of resolved values followed by a .flat() call:

(async () => {
  const numbers = [[1], [2], [3]];
  const awaitedNumbers = await Promise.all(
    numbers.map(async (number) => number) // some async code
  );
  const numbersFlattened = awaitedNumbers.flat() // flatten the resolved values
  console.log(numbersFlattened);
})();
Nick Parsons
  • 45,728
  • 6
  • 46
  • 64
  • That is what I did, but I do not understand why the Promises could not be flatten in an array ! – maximeSurmontSO Dec 30 '22 at 08:22
  • 2
    [^](https://stackoverflow.com/questions/74959270/why-does-using-flatmap-with-an-async-function-not-return-a-flatten-array#comment132279982_74959389) "_why the Promises could not be flatten in an array_": @maximeSurmontSO to **flatten** an array means to concatenate sub-array elements. Because promises are not arrays, they cannot be flattened. See: [`Array.prototype.flat()` - JavaScript | MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat) – jsejcksn Dec 30 '22 at 09:15
  • 2
    @maximeSurmontSO Also a promise doesn't necessarily have value in it. It's an object that could be in a pending state at the time `.flatMap()` creates its array (meaning it "holds" no value yet). It's only sometime in the future when the promise might change to a resolved state where it then holds a certain value. For `.flatMap()` to work correctly with Promises, it would internally need to wait for each to resolve and then merge the resolved values into its resulting array, which it doesn't do (as `.flatMap()` is not asynchronous). Internally, it only flattens arrays into its final result – Nick Parsons Dec 30 '22 at 09:23
  • I get it now, thanks ! It really "just" chains a `.map()` and a `.flat()`, so yeah `.flat()` is not working with an array of promises but with a promise of an array of promises, which it does not handle – maximeSurmontSO Dec 30 '22 at 14:19
0

I wanted to add that the fact Promise is eager also has performance implications.

const asyncFlatMap = <A, B>(arr: A[], f: (a: A) => Promise<B>) =>
    Promise.all(arr.map(f)).then(arr => arr.flat());

asyncFlatMap([[1], [2], [3]], async nums => nums); // Promise<number[]>

You can see in the code above that we need to map, then flat, with this additional round trip through the microtasks queue because of the then in-between. Doing this kind of round trip for the sake of playing Lego is wasteful.

Note that if you don't write an asyncFlatMap helper you can avoid that then of course. I'm making a general statement here.

If you happen to nest promises a lot you may be interested in Fluture.

Futures are lazy, so you can play Lego with them before anything is run.

import { resolve, parallel, promise, map } from 'fluture';
import { pipe } from 'fp-ts/function';

const numbersFlatten = await pipe(
    numbers.map(nums => resolve(nums)), // Future<number[]>[]
    parallel(10), // Future<number[][]>
    map(x => x.flat()), // Future<number[]>
    promise // Promise<number[]>
);

In the code above I converted the Future back to a Promise with promise in order to match your use case. It is only when this function is called that the computation is run. fork would also run the computation without ever involving promises.

In case you are not familiar with pipe: the first argument is the value which is threaded through the remaining function arguments.

The magic value 10 in parallel is just the maximum number of computations to run concurrently. It could have been anything else.

geoffrey
  • 2,080
  • 9
  • 13