18 de julio del 2023

Explorando maneras de hacer llamadas asíncronas en Javascript.

Revisando el código de un proyecto al que acabo de entrar, me encontré con una particular forma de escribir código asíncrono, en principio me pareció definitivamente mal, luego decidí analizar detenidamente como se comporta javascript en estos casos. El código era parecido a lo que se muestra a continuación:

const process = async () => {
  try {
    const created = await asyncFunction()
      .then((response) => {
        return response
      })
      .catch((error) => {
        console.log('Error', error)
      })

    if (created)
      return created
  } catch (error) {
    console.error('Ha ocurrido un error', error)
  }
}

Se puede ver como innecesariamente se utiliza async/await y then/catch para la llamada asincrona. Lo primero que pensé fue en cual sería la forma correcta de hacerlo y tenemos al menos 3 formas:

// Async/Await

const process = async () => {
  try {
    return await asyncFunction()
  } catch (error) {
    console.error('Ha ocurrido un error', error)
  }
}

await process()
// Then/Catch

const process = () => {
  return asyncFunction()
    .then((response) => response)
    .catch((error) => error)
};

process()
  .then(console.log)
  .catch(console.error)
// Callback

const process = (callback) => {
  asyncFunction()
    .then((response) => {
      callback(response, null);
    })
    .catch((error) => callback(null, `Ha ocurrido un error. ${error}`));
};

process((response, error) => {
  if (error) {
    console.error(error)
  } else {
    console.log(response)
  }
})

Actualmente la forma recomendada es utilizar async/await principalmente por la simpleza de la implementación, no obstante, siempre se debe considerar el contexto y seleccionar la manera que más se adecúe al problema que se quiere resolver.

Las razones reales por las cuales se escribió el código utilizando ambos métodos probablente sea por desconocimiento, a pesar de esto el código funciona, por lo tanto quise analizar como javascript lo interpreta.

A continuación se muestra el código de la prueba de concepto realizada:

const asyncFunction = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log('Dentro de la función Timeout')
      resolve(42)
    }, 500)
  })
}

(async () => {
  try {
    console.log('Antes de llamar')
    const response = await asyncFunction()
      .then((data) => {
        console.log(`Respuesta then: ${data}`)
        return data
      })
      .catch((error) => console.log(error))
    console.log(`Respuesta de await: ${response}`)
    console.log('Después de la llamada')
  } catch (error) {
    console.log(`Error catch: ${error}`)
  }
})()

// Salida
/*
  [0]      Antes de llamar
  [514]    Dentro de la función Timeout
  [514]    Respuesta then: 42
  [514]    Respuesta de await: 42
  [514]    Después de la llamada
*/

En este caso el código funciona, al llamar la función con .then este retorna el resultado que viene en el parámetro data, pero esta respuesta a su vez es una promesa, por lo tanto, await esperará a que se resuelva y devolverá el resultado deseado.

En el caso que la función retorne error y entre en el catch deberiamos esperar el mismo resultado, siempre y cuando se estuviera devolviendo una promesa. En este caso solamente se llama a un console.log por lo tanto el await no levantará una excepción y no será controlada por el catch exterior. A continuación se muestra la salida en caso de error:

// Salida con reject
/*
  [0]      Antes de llamar
  [508]    Dentro de la función Timeout
  [508]    Error
  [508]    Respuesta de await: undefined
  [508]    Después de la llamada
*/

En principio todo funciona correctamente, de todas maneras no es para nada aconsejable hacerlo de este modo, sobre todo porque no queda claro de manera rápida quien controlará el error, además queda un código frágil que puede facilmente ser victima de bugs indeseados al momento de hacer un cambio en estos fragmentos de código.

No conforme con esta conclusión decidí hacer una rápida búsqueda en Google y ver si podía encontrar algo más, me encontré con una pregunta en StackOverflow. En general llegan a la misma conclusión a la que llegué yo, pero me pareció interesante como explican otras maneras de utilizar esta combinación de métodos.

const resultado = await asyncFunction().catch(console.error);
console.log(resultado);

En el caso anterior no es necesario utilizar un try/catch ya que al resolver correctamente se retorna el valor deseado y se asigna a la variable resultado, por otro lado, cuando existe un error simplemente se ejecuta la función dentro del catch y se asigna a la variable resultado el valor de undefined.

const resultado = await asyncFunction().catch(() => 0);
console.log(resultado);

En este nuevo caso simplemente se devuelve un valor por defecto en caso que ocurra un error, de esta forma la variable resultado siempre tendrá un valor distinto a undefined cuando ocurra un error.

Esta forma de manejar funciones asíncronas puede disminuir el código necesario para manejar errores, ya que no es necesario envolver todo el código en un try/catch, tambien deja mucho más claro que acción se tomará en caso de error y por último permite, devolver un valor por defecto y continuar con la ejecución del programa. Pero, no se debe utilizar este método en caso que el error que se quiera controlar deba detener el flujo normal, en este caso sigue siendo útil utilizar try/catch para controlar este escenario.