Promises - Escape from the Planet of the Callbacks



Promises - Escape from the Planet of the Callbacks

0 2


promise-talk

Presentation for my talk on JavaScript Promises

On Github georgeh / promise-talk

Promises - Escape from the Planet of the Callbacks

George Hotelling - Progressive Leasing

New Music Friday Bot

  • Load New Music Friday Playlist
  • Post Playlist
  • Make Playlist Sticky
  • Post Tracks as Comments
  • Announce Success

Callbacks

spotifyApi.clientCredentialsGrant(function(err, credentials) {
  if (err) return err;

  spotifyApi.setAccessToken(credentials.body['access_token']);
  return spotifyApi.getPlaylist('spotify', 'NewMusicFriday', function postPlaylist(err, spotifyRes) {
    if (err) return err;

    return reddit('/api/submit').post({
      'api_type': 'json',
      'kind': 'link',
      'resubmit': true, 
      'sendreplies': false,
      'sr': config.reddit.subreddit,
      'title': _.get(spotifyRes, 'body.name') + ' - ' + moment().format('MMMM Do, YYYY'),
      'url': _.get(spotifyRes, 'body.external_urls.spotify')
    }, function(err, redditPostRes) {
      if (err) return err;

      return reddit('/api/set_subreddit_sticky').post({
        id: redditPostRes.json.data.name,
        num: 1,
        state: true
      }, function(err) {
        if (err) return err;

        var trackPosted = [].fill(false, 0, spotifyRes.body.tracks.items.length - 1);

        for (var i=0; i < spotifyRes.body.tracks.items.length; i++) {
          var item = spotifyRes.body.tracks.items[i];
          var artists = item.track.artists.map((a) => a.name).join(', ');
          var title = artists + ' - ' + item.track.name;

          reddit('/api/comment').post({
            'api_type': 'json',
            'text': '[' + title + '](' + _.get(item, 'track.external_urls.spotify') + ')',
            'thing_id': spotifyRes.reddit.json.data.name
          }, function(err) {
            if (err) return err;
            console.log('Posted ' + title);
            trackPosted[i] = true;
            if (trackPosted.every((trackDone) => trackDone)) {
              console.log('DONE!');
            }
          });
        }
      });
    });
  });
});
This code is for illustration purposes only and probably wouldn't work even if you could find an API that supported callbacks.

OK, Better Callbacks

spotifyApi.clientCredentialsGrant(function (err, credentials) {
    if (err) return err;

    spotifyApi.setAccessToken(credentials.body['access_token']);
    return spotifyApi.getPlaylist('spotify', 'NewMusicFriday', postPlaylist);
});

var spotifyRes;

function postPlaylist(err, spotifyResArg) {
  if (err) return err;

  spotifyRes = spotifyResArg;

  return reddit('/api/submit').post({
    'api_type': 'json',
    'kind': 'link',
    'resubmit': true,
    'sendreplies': false,
    'sr': config.reddit.subreddit,
    'title': _.get(spotifyRes, 'body.name') + ' - ' + moment().format('MMMM Do, YYYY'),
    'url': _.get(spotifyRes, 'body.external_urls.spotify')
  }, stickyPlaylist);
}

function stickyPlaylist(err, redditPostRes) {
  if (err) return err;

  return reddit('/api/set_subreddit_sticky').post({
    id: redditPostRes.json.data.name,
    num: 1,
    state: true
  }, postTracks);
}

var trackPosted;
function postTracks(err) {
  if (err) return err;

  trackPosted = [].fill(false, 0, spotifyRes.body.tracks.items.length - 1);

  for (var i = 0; i < spotifyRes.body.tracks.items.length; i++) {
    var item = spotifyRes.body.tracks.items[i];
    var artists = item.track.artists.map((a) => a.name).join(', ');
    var title = artists + ' - ' + item.track.name;

    reddit('/api/comment').post({
      'api_type': 'json',
      'text': '[' + title + '](' + _.get(item, 'track.external_urls.spotify') + ')',
      'thing_id': spotifyRes.reddit.json.data.name
    }, onTrackPosted(title, i));
  }
}

function onTrackPosted(title, i) {
  return function (err) {
    if (err) return err;
    console.log('Posted ' + title);
    trackPosted[i] = true;
    if (trackPosted.every((trackDone) => trackDone)) {
      console.log('DONE!');
    }
  }
}
Still bad code, just not as ugly. Highly coupled, brittle.

Promises!

Promises

What is a Promise?

It is a Promise for a value. Eventually.

For when you want to pass around a value you don't have yet.

Promises

States

  • Pending
  • Resolved → .then()
  • Rejected → .catch()
If you have a Promise and you want to know its state you're Doing It Wrong. It means you're trying to deal with async code in a synchronous manner

How to create Promises

var promise = new Promise(function(resolve, reject) {
  try {
    …
    resolve(value);
  } catch(e) {
    reject(e);
  }
}));

Promise Superpower #1: Promise.resolve()

var resolvedPromise = Promise.resolve(foo);

Promise Superpower #2: Promise.reject()

var rejectededPromise = Promise.reject(bar);

Browser Support Dec. 2015

New Music Friday Bot

Load New Music Friday Playlist

spotifyApi.clientCredentialsGrant()
  .then(function(credentials) {
    spotifyApi.setAccessToken(credentials.body['access_token']);
    spotifyApi.getPlaylist('spotify', 'NewMusicFriday');
  })

New Music Friday Bot

Post Playlist

spotifyApi.clientCredentialsGrant()
  .then(function(credentials) {
    spotifyApi.setAccessToken(credentials.body['access_token']);
    spotifyApi.getPlaylist('spotify', 'NewMusicFriday')
      .then(function (playlist) {
        reddit('/api/submit').post({
          'api_type': 'json',
          'kind': 'link',
          'resubmit': true,
          'sendreplies': false,
          'sr': 'NewMusicFriday',
          'title': _.get(playlist, 'body.name') + ' - ' + moment().format('MMMM Do, YYYY'),
          'url': _.get(playlist, 'body.external_urls.spotify')
        });
     });
  });
You can see the Pyramid of Doom growing

Promise Anti-Pattern #1

Calling .then() inside .then()

New Music Friday Bot

Post Playlist

var playlistPromise = new Promise(function(resolve, reject) {
  spotifyApi.clientCredentialsGrant()
    .then(function(credentials) {
      spotifyApi.setAccessToken(credentials.body['access_token']);
      spotifyApi.getPlaylist('spotify', 'NewMusicFriday')
        .then(resolve)
    })
});

playlistPromise.then(function(playlist) {
  reddit('/api/submit').post({
      …
      'sr': 'NewMusicFriday',
      'title': _.get(playlist, 'body.name') + ' - ' + moment().format('MMMM Do, YYYY'),
      'url': _.get(playlist, 'body.external_urls.spotify')
  })
});

Promise Anti-Pattern #2

You (almost) never need to create a new, pending Promise!

Promise.resolve() and Promise.reject() are OK

Exception is when you are converting non-Promise async code to Promises

.then() Rule #1

If you return a Promise from a .then() the outer Promise becomes the inner Promise

That means that the chain will become unresolved and will wait to call .then()

New Music Friday Bot

Load New Music Friday Playlist

spotifyApi.clientCredentialsGrant()
  .then(function(credentials) {
    spotifyApi.setAccessToken(credentials.body['access_token']);
    return spotifyApi.getPlaylist('spotify', 'NewMusicFriday');
  })
  .then(function(playlist) {
    reddit('/api/submit').post({
        'api_type': 'json',
        'kind': 'link',
        'resubmit': true,
        'sendreplies': false,
        'sr': 'NewMusicFriday',
        'title': _.get(playlist, 'body.name') + ' - ' + moment().format('MMMM Do, YYYY'),
        'url': _.get(playlist, 'body.external_urls.spotify')
    })
  })

New Music Friday Bot

Load New Music Friday Playlist

var credentialPromise = spotifyApi.clientCredentialsGrant();
var playlistPromise = credentialPromise.then(function(credentials) {
  spotifyApi.setAccessToken(credentials.body['access_token']);
  return spotifyApi.getPlaylist('spotify', 'NewMusicFriday');
});

var redditPostPromise = playlistPromise.then(function(playlist) {
  reddit('/api/submit').post({
      'api_type': 'json',
      'kind': 'link',
      'resubmit': true,
      'sendreplies': false,
      'sr': 'NewMusicFriday',
      'title': _.get(playlist, 'body.name') + ' - ' + moment().format('MMMM Do, YYYY'),
      'url': _.get(playlist, 'body.external_urls.spotify')
  })
});
This is the same code as the last slide with the explicit Promises broken out.

.then() Rule #2

If you return nothing from a .then(), the Promise value stays the same.

New Music Friday Bot

Refactor!

spotifyApi.clientCredentialsGrant()
  .then(saveAccessToken)
  .then(getPlaylist)
  .then(postPlaylist)
  
function saveAccessToken(credentials) {
  spotifyApi.setAccessToken(credentials.body['access_token']);
}

function getPlaylist() {
  return spotifyApi.getPlaylist('spotify', 'NewMusicFriday');
}

function postPlaylist(playlist) {
  return reddit('/api/submit').post({…});
}

New Music Friday Bot

Sticky Playlist

spotifyApi.clientCredentialsGrant()
  .then(saveAccessToken)
  .then(getPlaylist)
  .then(postPlaylist)
  .then(stickyPlaylist)
…
function stickyPlaylist(redditPostRes) {
  return reddit('/api/set_subreddit_sticky').post({
    id: redditPostRes.json.data.name,
    num: 1,
    state: true
  });
}

New Music Friday Bot

Post Tracks

spotifyApi.clientCredentialsGrant()
  .then(saveAccessToken)
  .then(getPlaylist)
  .then(postPlaylist)
  .then(stickyPlaylist)
  .then(postTracks)
…
function postTracks(???) {

}
What are the arguments here? We need the result of getPlaylist() and postPlaylist()!

Promise Superpower #3: Promise.all()

  • Takes an array of Promise and non-Promise values
  • Waits for them all to be resolved
  • Calls .then() with all resolved values

Refactor!

spotifyApi.clientCredentialsGrant()
  .then(saveAccessToken)
  .then(getPlaylist)
  .then(postPlaylist)
  .then(stickyPlaylist)
…
function postPlaylist(playlist) {
  return Promise.all([playlist, reddit('/api/submit').post({…})]);
}

function stickyPlaylist(playlist, redditPost) {
  return Promise.all([playlist, redditPost, reddit('/api/set_subreddit_sticky').post({…})]);
}
We are now using Promise.all() to collect the results we need

New Music Friday Bot

Post Tracks

spotifyApi.clientCredentialsGrant()
  .then(saveAccessToken)
  .then(getPlaylist)
  .then(postPlaylist)
  .then(stickyPlaylist)
  .then(postTracks)
…
function postTracks(playlist, redditPost) {
    return Promise.all(playlist.body.tracks.items.map(function(item) {
        var artists = item.track.artists.map((a) => a.name).join(', ');
        var title = artists + ' - ' + item.track.name;

        return reddit('/api/comment').post({
                'api_type': 'json',
                'text': '[' + title + '](' + _.get(item, 'track.external_urls.spotify') + ')',
                'thing_id': redditPost.json.data.name
            })
            .then(function() {
                console.log('Posted ' + title);
            });
    }));
}
Remember Anti-Pattern #1 - calling a .then() from inside .then()? Not applicable here because waves hands and trails off Real reason - we could attach another method after postTracks(), or can consider the Track -> Promise map to be its own Promise chain.

Error Handling

spotifyApi.clientCredentialsGrant(function (err, credentials) {
    if (err) return err;
    …
});
function postPlaylist(err, spotifyResArg) {
    if (err) return err;
…
}

function stickyPlaylist(err, redditPostRes) {
    if (err) return err;
…
}

function postTracks(err) {
    if (err) return err;
…
}

function onTrackPosted(title, i) {
    return function (err) {
        if (err) return err;
…
        }
    }
}

Error Handling

spotifyApi.clientCredentialsGrant()
  .then(saveAccessToken)
  .then(getPlaylist)
  .then(postPlaylist)
  .then(stickyPlaylist)
  .then(postTracks)
  .then(console.log.bind(console, 'DONE!'))
  .catch(console.error.bind(console))

Anti-Bieber Code

function postTracks(playlist, redditPost) {
  return Promise.all(playlist.body.tracks.items.map(function(item) {
    var artists = item.track.artists.map((a) => a.name).join(', ');
    if (/bieber/i.test(artists)) {
      throw 'Not a Belieber!';
    }
    var title = artists + ' - ' + item.track.name;
    return reddit('/api/comment').post({
      'api_type': 'json',
      'text': '[' + title + '](' + _.get(item, 'track.external_urls.spotify') + ')',
      'thing_id': redditPost.json.data.name
    })
    .then(function() {
      console.log('Posted ' + title);
    });
  }));
}
This is the Promise version of a throw statement

Promise Anti-Pattern #3

Not having a .catch() statement.

The Future!

🔮

The Future!

async

await

The Future!

try {
  let credentials = await spotifyApi.clientCredentialsGrant();
  spotifyApi.setAccessToken(credentials.body['access_token']);
  let playlist = await getPlaylist();
  let redditPost = await postPlaylist(playlist);
  await Promise.all([
    stickyPlaylist(playlist, redditPost),
    postTracks(playlist, redditPost)
  ]);
  console.log('DONE!');
} catch (err) {
  console.error(err);
}

async function postPlaylist(playlist) {
  return reddit('/api/submit').post({…});
}
Note the use of Promise.all() - it's important to understand that these are all Promises under the hood

Closing

.then() Rules:

If you return a Promise from a .then() the outer Promise becomes the inner Promise If you return nothing from a .then(), the Promise value stays the same. If you return a non-Promise value from a .then(), the Promise value becomes that value

Promise Superpowers

Promise.resovle() Promise.reject() Promise.all() Promise.race() - first value in array to resolve

Promise Anti-Patterns

Calling .then() inside .then() You (almost) never need to create a new, pending Promise! Not having a .catch() statement.

Resources

Questions?

Slides up on https://github.com/georgeh/

Ask in the #javascript room in Slack

1/41
Promises - Escape from the Planet of the Callbacks George Hotelling - Progressive Leasing