Trees of Promises in ES6

[2016-04-17] dev, javascript, esnext, async, promises
(Ad, please don’t block)

This blog post shows how to handle trees of ES6 Promises, via an example where the contents of a directory are listed asynchronously.

The challenge  

We’d like to implement a Promise-based asynchronous function listFile(dir) whose result is an Array with the paths of the files in the directory dir.

As an example, consider the following invocation:

listFiles('/tmp/dir')
.then(files => {
    console.log(files.join('\n'));
});

One possible output is:

/tmp/dir/bar.txt
/tmp/dir/foo.txt
/tmp/dir/subdir/baz.txt

The solution  

For our solution, we create Promise-based versions of the two Node.js functions fs.readdir() and fs.stat():

readdirAsync(dirpath) : Promise<Array<string>>
statAsync(filepath) : Promise<Stats>

We do so via the library function denodify:

import denodeify from 'denodeify';

import {readdir,stat} from 'fs';
const readdirAsync = denodeify(readdir);
const statAsync = denodeify(stat);

Additionally, we need path.resolve(p0, p1, p2, ···) which starts with the path p0 and resolves p1 relatively to it to produce a new path. Then it continues with resolving p2 relatively to the new path. Et cetera.

import {resolve} from 'path';

listFiles() is implemented as follows:

function listFiles(filepath) {
    return statAsync(filepath) // (A)
    .then(stats => {
        if (stats.isDirectory()) { // (B)
            return readdirAsync(filepath) // (C)
            // Ensure result is deterministic:
            .then(childNames => childNames.sort())
            .then(sortedNames =>
                Promise.all( // (D)
                    sortedNames.map(childName => // (E)
                        listFiles(resolve(filepath, childName)) ) ) )
            .then(subtrees => {
                // Concatenate the elements of `subtrees`
                // into a single Array (explained later)
                return flatten(subtrees); // (F)
            });
        } else {
            return [ filepath ];
        }
    });    
}

Two invocations of Promise-based functions are relatively straightforward:

  • statAsync() (line A) returns an instance of Stats
  • readdirAsync() (line C) returns an Array with filenames.

The interesting part is when listFiles() calls itself, recursively, leading to an actual tree of Promises. It does so in several steps:

  • First, it maps the names of the child files to Promises that fulfill with Arrays of grandchild paths (line E).

  • It uses Promise.all() to wait until all results are in (line D).

  • Once all results are in, it flattens the Array of Arrays of paths into an Array (line F). That Array fulfills the last Promise of the chain that starts in line C.

Note that synchronous programming constructs are used to compose Promises:

  • The if statement in line B decides how to continue the asynchronous computation.
  • The map() method in line E is used to make recursive calls.

Helper function flatten()  

The tool function flatten(arr) concatenates all the elements of arr into a single Array (one-level flattening). For example:

> flatten([[0], [], [1, [2]]])
[ 0, 1, [ 2 ] ]

It can be implemented like this:

function flatten(arr) {
    return [].concat(...arr);
}

Further reading