Understanding Promises in Angular

Many of us were saved from callback hell and arrived in the Promised Land. Our code was flattened, control logic flowed gracefully, and our errors were tamed. But alas, there was still confusion to be found in this land of enlightenment.

For those of us who found ourselves working in Angular, the way the $q Promise library works can be a bit surprising. This post aims to help get rid of these surprises.

$rootScope Integration

Usually, you'd expect then to be invoked immediately once the promise has been resolved. That's not necessarily the case with $q promises. Anything that executes on the next turn of the event loop needs to be wrapped in a $scope.$apply, including promise resolutions.

If you're interested in learning more about $scope.$apply and Angular's digest cycle, I recommend these links here.

Often times, this isn't something you need to really worry about, as services like $http and $timeout take care of wrapping things within $scope.$apply on their own. If you're manually creating promises for some reason, however, you'll need to handle this yourself. When defining a service, the only scope you really have a reference to is the $rootScope, so you'll need to use that there.

Here's a contrived example with setTimeout (in practice, you would just use $timeout):

angular.module('appName')
.factory('deferredDemo', function($rootScope, $q) {
  return {
    greet: function(name, delay) {
      var deferred = $q.defer()
      setTimeout(function() {
        // If you don't have this $rootScope.$apply here, it won't work!
        $rootScope.$apply(function() {
          if (name) {
            deferred.resolve('Hello, name!')
          } else {
            deferred.reject(new Error('No name specified for greeting'))
          }
        })
      }, delay)
      return deferred.promise
    }
  }
})

An area of particular importance here will be in your tests. You will often need to include either $rootScope.$apply or $rootScope.$digest at the end of them if you're testing things that return $q promises.

Rejecting Promises

In most promise libraries, you can simply throw an object to yield a rejection to the next entry in the chain. If you throw when working with $q, your application will actually end up throwing an error.

Instead, the $q service has a method called reject that takes a single argument and returns a promise that is rejected with the value given. Instead of throwing your errors, you simply return this rejected promise.

promise.then(function(res) {
  if (!res.someValue) {
    return $q.reject(new Error('Property "someValue" not set'))
  }
  return res
})
.catch(function(err) {
  $log.error(err.message)
})

The $http Service

The $http service returns HttpPromises. These have all the same methods as regular $q promises, but with two additional methods: success and error. These exist as convenience methods that spread out arguments to callbacks into multiple arguments. There's no actual spread method in $q, so that's why they're used.

A common gotcha here is that success and error do not return new promises based on the return value of the callback. Instead, they both simply return the original promise from the $http call. This means you can chain success and error if you're not doing anything too fancy, but you'll probably want to stick to then and catch for more advanced use cases.

IE8 Support

If you can get away without needing to worry about IE8, then more power to you. For everyone else, you need to be aware that usage of .catch and .finally will throw errors in IE8, as it forbids the use of reserved words as properties.

Some other libraries have aliases such as fail and lastly available, but you'll find nothing like that in Angular. Instead, you'll need to use bracket notation for these methods.

Instead of:

promise.then(function(res) {
  // do something
})
.catch(function(err) {
  // do something
})
.finally(function() {
  // do something
})

You'll write:

promise.then(function(res) {
  // do something
})
['catch'](function(err) {
  // do something
})
['finally'](function() {
  // do something
})

Yeah, it looks funky. If you're using JSHint, you'll probably want to set the sub option to true to supress warnings about not using dot notation.

Limited Extensibility

The Promise prototype is pretty well encapsulated, so you can't really extend it.

As for the $q and $http services themselves, you can extend them by creating decorators. This sounds complicated, but all it really means is that you inject $provide into a config block and call the decorator method. It takes a service name and a callback to which it passes the original service instance. With that, you can add new methods, override existing ones, or even replace services entirely. The service will be set to whatever you return from the callback of $provide.decorator.

Here's an example of overriding $q.all to be a variadic function that accepts multiple arguments.

angular.module('appName')
.config(function($provide) {
  $provide.decorator('$q', function($delegate) {
    // The $delegate will be a reference to the $q service

    // Here we get reference to original $q.all method
    var originalAllMethod = $delegate.all

    // This is where we modify the definition of $q.all
    $delegate.all = function() {

      // If there are 0 or 1 arguments, just delegate to the original $q.all
      if (arguments.length <= 1) {
        return originalAllMethod(arguments[0])
      }

      // Otherwise, convert the arguments object to an array and pass that along
      return originalAllMethod(Array.prototype.slice.call(arguments))
    }

    // Return the modified $q service
    return $delegate
  })
})

Note that this works well for an example, but it's probably best not to actually use this, since it doesn't do any flattening and the like.

Using Other Promise Libraries

The focus of $q is to provide a byte-concious, minimal API for dealing with async control flows. If you decide $q isn't doing enough, you can always bring an a full-fledged promise library.

The important thing to remember is that you will need to take care of calling $scope.$apply everywhere yourself. It's up to you to decide if that is an acceptable tradeoff to make.

comments powered by Disqus

This work is licensed under a Creative Commons Attribution 3.0 Unported License.