En la primera parte sobre programación asíncrona vimos en que consistía el paradigma, como lograr ejecutar contextos asíncronos con callbacks y como controlar dicha ejecución mediante eventos. En este capítulo veremos como lograr lo mismo mediante el uso de promises.

¿Qué son las promises?

En Javascript se conoce como promise (o promesa) al objeto devuelto por una función del cual aún no se conoce el resultado. ¿Cómo? ¿Y qué utilidad tiene no saber el resultado de algo? Las promises no devuelven un resultado directamente, pero nos proveen una manera de conseguir dicho resultado cuando se produzca. ¿Y no es lo mismo que una callback? ¿Para qué necesito más entonces?

El uso de callbacks resulta cómodo y simple para ejecutar una tarea tras un evento, pero la cosa se complica cuando queremos controlar varios eventos en un orden concreto y de forma secuencial. Si quisiéramos hacer esto mediante callbacks tendríamos que anidarlas una dentro de otra y podríamos caer en lo que comúnmente se llama "callback hell".

myasync1(function callback1() {  
  doSomething();
  myasync2(function callback2() {
    doSomething();
    mysasync3(function callback3() {
       ...
    })
  })
})

Imaginad ahora si además cada una de esas callback puede generar una excepción.

Las promises no solo nos permiten controlar el orden de ejecución de las callbacks sin tener que anidarlas sino además prever dichas excepciones. Por ello siempre se garantiza un resultado, bien se resuelva de forma satisfactoria o en un error.

¿Cómo creo una promise?

Hay dos maneras, bien creando un nuevo objeto Promise o bien llamando a una función que ya nos provea una. ¿Cual es la diferencia? Realmente ninguna, sólo usaremos el prototipo Promise cuando necesitemos usar funciones que no devuelvan promises.

Un ejemplo es la nueva API fetch que se incluye en ES6 pero que no todos los navegadores incluyen aún. Esta API nos permite hacer peticiones HTTP y devolver una promise en lugar de usar callbacks con la antigua API XMLHttpRequest que usamos en el capítulo anterior. Podríamos crearnos nuestro propio pollyfill (bastante básico) de dicha función usando un objeto Promise.

const fetch = (url) => {  
  return new Promise((resolve, reject) => {
    const req = new XMLHttpRequest()
    req.open('GET', url, true);
    req.onreadystatechange = (event) => {
      if (req.readyState == 4) {
        if(req.status == 200) {
          resolve(req.responseText);
        } else {
          reject(new Error("Error loading page"));
        }
      }
    };
  };
};

Al prototipo Promise es necesario pasarle una función que acepte a su vez dos parámetros: resolve y reject. Dichos parámetros serán dos funciones con las que le diremos al promise que debe resolverse de un modo satisfactorio o no.

Como vemos en el ejemplo, tras asumir que hemos recibido una respuesta válida resolvemos la promise con dicha respuesta usando resolve. Si por el contrario no es válida lo haremos con reject y lanzaremos una excepción.

Pongamos otro ejemplo mas simple. Crearemos una función que devuelva una promise y se resuelva/rechace en unos segundos en función del número que le pasemos.

const myFunction = (number) => {  
  return new Promise((resolve), (reject) => {
    setTimeout(() => {
      if (number <= 5) {
        resolve(number * 1000);
      } else {
        reject(new Error("Invalid number"));
      }
    }, 2000);
  });
};

La función se ejecuta de forma asíncrona tras 2 segundos. Si el número es menor o igual a 5 se resolverá con ese número multiplicado por 1000, si no se rechazará con un error. Simple ¿no? Sin embargo la función lo que nos devuelve es una promise y no su resultado. ¿Cómo accedemos al resultado?

¿Cómo uso una promise?

Un objeto promise dispone de dos métodos principales: then y catch. Con cada uno de ellos le diremos a la promise que debe hacer tras resolver su estado, y a su vez nos devolverán una nueva promise que será resuelta con el valor que hayamos devuelto. ¿Vaya lío no? ¿Seguro que no era mas fácil con las callbacks? Ya veremos como no, pero expliquemos primero como funcionan then y catch.

then acepta como parámetro una callback que recibirá el valor resuelto por la promise. Opcionalmente podemos pasarle un segundo parámetro que será la callback que capture el error en caso de que la promise sea rechazada. En cualquiera de los dos casos, la promise devuelta por then será resuelta con el valor que devolvamos.

myPromise = myPromisedFunction()  
  .then(
    // Resolved
    (value) => {
      console.log(value);
      return value;
    },
    // Rejected
    (err) => {
      console.error(err);
      return 0;
    }
  )
;

¿Qué pasa si a then no le pasamos una función que capture el error o si las funciones que le pasamos provocan nuevos errores? Más abajo lo veremos.

catch recibe como parámetro una función que se encargará de capturar el error que pueda producir la promise. ¿Y no es lo mismo que el segundo parámetro de "then"? Si. De hecho sería lo mismo que usar then(undefined, myCallback). Sin embargo resulta cómoda y visual para usarla al final de una cadena y asegurarnos de capturar un error que se haya producido en cualquier punto de la misma. Veamos un ejemplo.

Supongamos que queremos consultar una API que nos devuelve datos de distintas personas formateados en JSON. Entre dichos datos queremos filtrar las personas mayores de edad y al final imprimir la lista ordenada por edad.

fetch('http://api.example.com/')  
  .then((data) => JSON.parse(data))      // Parseamos el JSON
  .then((people) => people
    .filter((p) => p.age >= 18)          // Filtramos
    .sort((p1, p2) => p1.age - p2.age)   // Ordenamos
  )
  .then((people) => console.log(people)) // Imprimimos la lista
;

Como vemos, podemos encadenar distintas operaciones usando then. Cada nueva promise devuelta es encadenada y le pasaremos el valor devuelto por la anterior. Sin embargo no estamos capturando posibles errores. ¿Qué pasaría si fetch falla? ¿Y si una de las personas no tiene el atributo age? ¿Ó JSON.parse no puede parsear el código? Bastaría con colocar un catch al final.

fetch('http://api.example.com/')  
  .then((data) => JSON.parse(data))      // Parseamos el JSON
  .then((people) => people
    .filter((p) => p.age >= 18)          // Filtramos
    .sort((p1, p2) => p1.age - p2.age)   // Ordenamos
  )
  .then((people) => console.log(people)) // Imprimimos la lista
  .catch((err) => console.log(err))      // Imprimimos el error
;

El error se propagaría por todas las promise hasta que una de ellas lo capture. En este caso al capturarlo al final nos aseguramos de capturar cualquier error que se produzca en una de las promise anteriores. Esto solucionaría el problema de capturar errores, pero no provee una alternativa al mismo. ¿Y si quisiéramos usar valores predefinidos en caso de que fetch falle? Usemos el segundo parámetro de then.

const myData = [  
  { name: 'John', age: 21 },
  { name: 'Mary', age: 16 },
  { name: 'Pete', age: 32 },
];

fetch('http://myapi.address.com/')  
  .then(
    (data) => JSON.parse(data)           // Parseamos el JSON
    (err) => {
      console.log(err);                  // Si no, imprimimos el error
      return myData;                     // y usamos los datos predefinidos
    }
  ),
  .then((people) => people
    .filter((p) => p.age >= 18)          // Filtramos
    .sort((p1, p2) => p1.age - p2.age)   // Ordenamos
  )
  .then((people) => console.log(people)) // Imprimimos la lista
  .catch((err) => console.log(err))      // Imprimimos el error
;

Vamos a complicarlo más aún. Supongamos que cada persona tiene además un ID con el que podemos acceder a datos mas específicos de cada uno a través de otra dirección. En este caso tendríamos que usar de nuevo fetch por cada uno de ellos, lo cual nos devolvería una lista de promises. ¿Cómo resolvemos una lista de promises? Aquí entra una nueva función: Promise.all.

Promise.all devolverá una nueva promise que se resolverá cuando todas las promise de la lista sean resueltas, o en su defecto alguna de ellas falle. Usando el ejemplo anterior podemos verlo mas claro.

const myData = [  
  { id: 1, name: 'John', age: 21 },
  { id: 2, name: 'Mary', age: 16 },
  { id: 3, name: 'Pete', age: 32 },
];

fetch('http://myapi.address.com/')  
  .then(
    (data) => JSON.parse(data)           // Parseamos el JSON
    (err) => {
      console.log(err);                  // Si no, imprimimos el error
      return myData;                     // y usamos los datos predefinidos
    }
  ),
  .then((people) => people
    .filter(p => p.age >= 18)            // Filtramos
    .sort((p1, p2) => p1.age - p2.age)   // Ordenamos
    // Generamos una nueva promise por cada uno
    .map(p => fetch(`http://myapi.address.com/${p.id}/`))
  )
  // Resolvemos todas las promise
  .then(peoplePromises => Promise.all(peoplePromises))
  .then(dataList => dataList
    .map(data => JSON.parse(data))       // Parseamos el JSON de cada persona
    .forEach(p => console.log(p))        // Imprimimos los datos
  )
  .catch((err) => console.log(err))      // Imprimimos el error
;

El código resulta simple, claro y fácil de visualizar. Además está a prueba de errores y garantizamos que siempre produzca un resultado con tan solo un par de catch. Imaginad ahora como resolver el mismo ejemplo usando sólo callbacks y bloques que capturaran todas los posibles errores. Mejor no pensarlo...

Conclusión

Las promises nos ahorran mucho código y nos facilitan enormemente poder controlar ejecuciones asíncronas de forma secuencial encadenándolas una tras otra, pudiendo asegurar siempre que vamos a devolver algo que la siguiente podrá manejar.

"Prometo que volveré por ti. Prometo que nunca te dejaré." - The English Patient (1996)