Promises made a bit easier

Promises made a bit easier

I’ve noticed that some developers, especially the ones without an extensive Javascript (JS) background, can struggle a bit with understanding promises. I decided to do a write-up that explains what they are, how they work, how you can use them, and some pitfalls and tricks.

What’s a promise?

A promise represents the outcome of a certain asynchronous operation, even when that operation has not yet finished. It is quite literally a promise that once the operation is done, a function will be called with the outcome. That outcome can either be a success (which means the promise will be resolved), or it can be a failure (which means the promise will be rejected).

Each of those outcomes will call a different function, that you pass to .then() or .catch():

// Method #1:
myAsynchronousFunction().then(successHandler, errorHandler)
// Method #2:
myAsynchronousFunction().then(successHandler).catch(errorHandler)

There are subtle differences between these two notations. Practically speaking,“Method #2 is preferred.

What’s the difference between promises and callbacks?

Before promises, Node.js relied on using callback functions to handle the results of asynchronous operations. Semantically, they work similar to promises: a function will be called when the operation has completed. Syntactically, they look very different:

// Promise
function myDelayFunction(ms) {
  return new Promise(function(resolve, reject) {
    setTimeout(function() {
      resolve();
    }, ms);
  });
}
myDelayFunction(1000).then(function() {
  console.log('1000ms have passed!');
})

// Callback
function myDelayFunction(ms, callback) {
  setTimeout(function() {
    callback();
  }, ms);
}
myDelayFunction(1000, function() {
  console.log('1000ms have passed!');
})

If you have the option to choose between promises or callbacks, use promises. Especially combined with async/await, they will make your code much more readable and maintainable.

async/await

I’m sure that you’re familiar with async/await, or at least have seen it used. How is that related to promises?

Actually, async/await is syntactic sugar to deal with promises:

  • await waits for a promise to be resolved or rejected before continuing.
    In other words, these two examples do the same:

    // Using `async/await`
    async function myAsynchronousFunction() {
      const value = await myOtherAsynchronousFunction();
      console.log('The value was:', value);
    }
    
    // Using "regular" promises
    function myAsynchronousFunction() {
      myOtherAsynchronousFunction().then(function(value) {
        console.log('The value was:', value);
      })
    }
    
  • a function marked async will always implicitly return a promise:

    async function myAsynchronousFunction() {
      return 123; // return just a regular numerical value
    }
    myAsynchronousFunction().then(function(value) {
      console.log('The value was:', value);
    })
    // Outputs: "The value was: 123"
    

    This also means that you don’t have to explicitly return a promise:

    async function myAsynchronousFunction() {
      return Promise.resolve(123); // unnecessary use of `Promise.resolve()`
    }
    
  • throwing an error inside an async function will return a rejected promise:

    async function myAsynchronousFunction() {
      throw Error('ohnoes');
    }
    myAsynchronousFunction().catch(function(err) {
      console.log('Oops, caught an error:', err.message);
    });
    // Outputs: "Oops, caught an error: ohnoes"
    

Instead of using .then/.catch, using async/await is preferred because it makes reading and writing your code much easier. A typical problem with .then/.catch is the so-called “Promise Pyramid of Doom”:

readDirectory().then(function(files) {
  readFile(files[0]).then(function(contents) {
    writeFile(files[0], contents + "EXTRA DATA").then(function() {
      verifyFile(files[0]).then(function(result) {
        console.log('all done!', result);
      })
    })
  })
})

Even though this can be mitigated by “promise chaining”, where instead of calling the .then() on the promise returned by the “next” step, you return that promise:

readDirectory().then(function(files) {
  readFile(files[0]).then(function(contents) {
    return writeFile(files[0], contents + "EXTRA DATA");
  }).then(function() {
    return verifyFile(files[0]);
  }).then(function(result) {
    console.log('all done!', result);
  })
})

But still, using async/await makes the code much more readable:

const files    = await readDirectory();
const contents = await readFile(files[0]);

await writeFile(files[0], contents + "EXTRA DATA");

const result = await verifyFile(files[0]);
console.log('all done!', result);

Caveats

  • await only works in functions that are marked async, and this does not propagate down. In certain situations, for instance when dealing with event handlers, this can be confusing:

    async function myEventHandler() {
      eventEmitter.on('some-event', function(data) {
        const result = await myAsynchronousFunction(data); // FAIL
      });
    }
    

    Even though myEventHandler is marked async, using await will still yield a SyntaxError. That’s because the event handler function isn’t marked async. To fix:

    async function myEventHandler() {
      eventEmitter.on('some-event', async function(data) {  // needs to be `async`
        const result = await myAsynchronousFunction(data); // SUCCESS
      });
    }
    

    In fact, if this is all that myEventHandler does, it doesn’t need to be async itself:

    function myEventHandler() { // Doesn't need to be `async`
      eventEmitter.on('some-event', async function(data) {
        const result = await myAsynchronousFunction(data);
      });
    }
    

When to create a new promise

It’s becoming less and less common to have to create promises yourself. Most of Homey’s SDK is based on promises (with callbacks being offered for backward compatibility for existing code), and most external modules also use promises.

One example that still needs manual promise creation is to “promisify” setTimeout:

function delay(ms) {
  return new Promise(function(resolve) {
    setTimeout(function() {
      resolve();
    }, ms)
  })
}

// Usage:
async function myAsynchronousFunction() {
  // delay for 5 seconds before we continue
  await delay(5000);
  ...
}

And another example that I sometimes use in Homey drivers (because, oddly enough, Driver.ready is a method that only accepts a callback, and doesn’t return a promise):

class MyDriver extends Homey.Driver {
  ...
  waitForReady() {
    const driver = this;
    return new Promise(function(resolve) {
      driver.ready(resolve);
    })
  }
}

// Usage, for instance inside a device:
class MyDevice extends Homey.Device {
  async onInit() {
    // Wait for driver to become ready before we continue.
    await this.getDriver().waitForReady();
    ...
  }
}

Promise caveats

  • a common anti-pattern is to wrap a promise chain inside a new Promise:

    function myAsynchronousFunction() {
      return new Promise(function(resolve, reject) {
        myOtherAsynchronousFunction().then(function(result) {
          resolve(result);
        }).catch(function(err) {
          reject(err);
        })
      })
    }
    

    Promise chains always return a promise, which means that the code above can be rewritten to this:

    function myAsynchronousFunction() {
      return myOtherAsynchronousFunction();
    }
    

    Yes, that does exactly the same. Only faster and with less code :sunglasses:

  • if an asynchronous function offers the option of using promises or callbacks, pick one. Don’t use both:

    myAsynchronousFunction(function(result) {
      // callback
    }).then(function(result) {
      // promise handler
    })
    

Various tidbits

try/catch

Using try/catch in JS can be confusing. For instance, this doesn’t work as expected:

try {
  myAsynchronousFunction().then(function() {
    throw Error('ohnoes');
  })
} catch(err) {
    ...
}

This will actually cause an UnhandledPromiseRejectionWarning, meaning that the exception wasn’t caught by the catch. So why is that?

The JS interpreter will run the statements inside of a try block, and if any of those statements result in an immediate exception, the catch block gets triggered. But in the example code above, the exception isn’t thrown immediately: it’s thrown when the promise is resolved. But this is done asynchronously, meaning “somewhere in the future”.

It doesn’t matter if myAsynchronousFunction doesn’t actually do anything besides returning a promise: it is inherent to promises (and any asynchronous function) that their handling will be queued and postponed (specifically, until the call stack is empty). This is too late for the catch to get involved.

So try/catch cannot be used for asynchronous code? Well, it can, but only when combined with async/await. So this will work as expected:

try {
  await myAsynchronousFunction();
  throw Error('ohnoes')
} catch(err) {
  ...
}

Rule of thumb: don’t use try/catch for asynchronous code, except for when you’re using async/await.

Arrow function notation

I have refrained from using arrow function notation in all of the examples above, for clarity reasons. This doesn’t mean that you shouldn’t use them, and, in fact, I would recommend using them.

Arrow functions are great if your code looks like this:

function someMethod() {
  const instance = this:
  someFunction().then(function(result) {
    instance.setResult(result);
  });
}

The problem here is that the value of this inside the promise success handler is typically useless, so if you want to access the this from before the call to someFunction, you have to store it in a temporary variable instance.

Using arrow functions will solve this (pun intended) by making the value of this inside the function the same as it was outside the function:

function someMethod() {
  someFunction().then(result => {
    this.setResult(result);
  });
}

Technically speaking, you can do the same with “regular” function expressions too, using Function.prototype.bind:

function someMethod() {
  someFunction().then(function(result) {
    instance.setResult(result);
  }.bind(this));
}

But it’s much more preferable to use arrow function notation.

As always, there are subtleties involved, and using arrow function notation in some situations may cause problems. Typically, this happens when you’re using external modules that specifically set this (much like the .bind example above).

Resolving multiple promises in parallel

Sometimes you need to perform various asynchronous functions at the same time. You can, of course, use this:

const result1 = await myAsynchronousFunction1();
const result2 = await myAsynchronousFunction2();
const result3 = await myAsynchronousFunction3();

This will run each asynchronous function sequentially, one after the other. This isn’t necessarily very efficient, because Node.js is very good at handling concurrent asynchronos functions. So if the functions are independent of each other (that is, the functions don’t depend on the results of the other functions), you can run them concurrently, in parallel, using Promise.all:

const [ result1, result2, result3 ] = await Promise.all([
  await myAsynchronousFunction1(),
  await myAsynchronousFunction2(),
  await myAsynchronousFunction3()
]);

One caveat is that if any of the functions fails due to an exception, Promise.all will fail too.

49 Likes

Superb! Thanks for taking the time to write this.

Thanks for this great explanation about Promise.

But in 2020, every developer should use async/await instead of plain promises in my opinion.

  1. async functions return a promise.
  2. async functions use an implicit Promise to return results. Even if you don’t return a promise explicitly, the async function makes sure that your code is passed through a promise.
  3. await is always for a single Promise .
  4. Promise creation starts the execution of asynchronous functionality.
  5. await only blocks the code execution within the async function. It only makes sure that the next line is executed when the promise resolves. So, if an asynchronous activity has already started, await will not have any effect on it.

I hope this helps.