Ir al contenido principal

Sintaxis de async/await

Introducción

Tras conocer el concepto de funciones asíncronas, es momento de profundizar en la sintaxis específica de async/await, la cual ha revolucionado la forma de escribir código asíncrono en JavaScript. Esta combinación de palabras clave proporciona una manera elegante de trabajar con promesas, haciendo que el código asíncrono parezca y se comporte como código síncrono tradicional.

async/await no es una alternativa a las promesas, sino una capa sintáctica construida sobre ellas que nos permite expresar el mismo comportamiento asíncrono de manera más clara y concisa. En este artículo, exploraremos a fondo la sintaxis de async/await, sus reglas, patrones y mejores prácticas para incorporarla efectivamente en nuestros proyectos.

La palabra clave await

La palabra clave await es el corazón de la sintaxis async/await. Su función principal es pausar la ejecución de una función asíncrona hasta que una promesa se resuelva o se rechace. Cuando utilizamos await antes de una expresión que devuelve una promesa, la función asíncrona se pausa en esa línea hasta que la promesa se complete.

Sintaxis básica

La sintaxis básica de await es la siguiente:

// Dentro de una función async
const resultado = await promesa;

Cuando se ejecuta esta línea:

  1. La ejecución de la función asíncrona se pausa
  2. JavaScript espera a que la promesa se resuelva o rechace
  3. Si la promesa se resuelve, await devuelve el valor de resolución
  4. Si la promesa se rechaza, await lanza una excepción con el valor de rechazo

Veamos un ejemplo práctico:

function obtenerDatos() {
  // Esta función devuelve una promesa
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: 1, nombre: "Producto ejemplo" });
    }, 2000); // Simulamos un retraso de 2 segundos
  });
}

async function mostrarDatos() {
  console.log("Solicitando datos...");
  
  // La ejecución se pausa aquí hasta que la promesa se resuelva
  const datos = await obtenerDatos();
  
  // Esta línea solo se ejecuta cuando la promesa se ha resuelto
  console.log("Datos recibidos:", datos);
  
  return datos; // Esta función devuelve una promesa que se resuelve con datos
}

// Llamamos a la función asíncrona
mostrarDatos()
  .then(() => console.log("Proceso completado"))
  .catch(error => console.error("Error:", error));

// La consola mostrará:
// "Solicitando datos..."
// (2 segundos después)
// "Datos recibidos: {id: 1, nombre: "Producto ejemplo"}"
// "Proceso completado"

En este ejemplo, cuando llegamos a la línea const datos = await obtenerDatos(), la función mostrarDatos se pausa durante 2 segundos mientras espera la resolución de la promesa, y luego continúa con la ejecución del resto del código.

Espera de promesas

Lo que hace que await sea tan poderoso es que nos permite trabajar con valores asincrónicos como si fueran síncronos. Cuando usamos await con una promesa, el valor que se devuelve es el valor de resolución de la promesa, no la promesa en sí.

async function ejemplo() {
  // Sin await: resultado1 es una promesa
  const resultado1 = Promise.resolve(42);
  console.log(resultado1); // Promise {<fulfilled>: 42}
  
  // Con await: resultado2 es el valor resuelto (42)
  const resultado2 = await Promise.resolve(42);
  console.log(resultado2); // 42
}

ejemplo();

Esto nos permite encadenar operaciones asíncronas de forma muy natural:

async function procesoCompleto() {
  // Cada paso espera al anterior
  const datosUsuario = await obtenerUsuario(123);
  const permisos = await obtenerPermisos(datosUsuario.id);
  const recursos = await obtenerRecursos(permisos);
  
  return {
    usuario: datosUsuario,
    permisos: permisos,
    recursos: recursos
  };
}

Este código es mucho más legible que su equivalente utilizando promesas encadenadas:

function procesoCompletoConPromesas() {
  return obtenerUsuario(123)
    .then(datosUsuario => {
      return obtenerPermisos(datosUsuario.id)
        .then(permisos => {
          return obtenerRecursos(permisos)
            .then(recursos => {
              return {
                usuario: datosUsuario,
                permisos: permisos,
                recursos: recursos
              };
            });
        });
    });
}

Alternativa al encadenamiento then()

Como vimos en el ejemplo anterior, async/await ofrece una alternativa más limpia al encadenamiento de métodos .then(). En lugar de crear una cadena de callbacks anidados, podemos escribir código que sigue un flujo más natural y lineal.

Comparemos un ejemplo más de ambos enfoques:

Con encadenamiento .then():

function cargarYProcesarDatos() {
  return cargarDatos()
    .then(datos => {
      return procesarDatos(datos);
    })
    .then(datosProcesados => {
      return guardarResultados(datosProcesados);
    })
    .then(informe => {
      return enviarNotificacion(informe);
    })
    .catch(error => {
      console.error("Error en el proceso:", error);
      return manejarError(error);
    });
}

Con async/await:

async function cargarYProcesarDatos() {
  try {
    const datos = await cargarDatos();
    const datosProcesados = await procesarDatos(datos);
    const informe = await guardarResultados(datosProcesados);
    return await enviarNotificacion(informe);
  } catch (error) {
    console.error("Error en el proceso:", error);
    return manejarError(error);
  }
}

Las ventajas de la versión con async/await incluyen:

  1. Mayor claridad en el flujo de ejecución
  2. Menos anidación y complejidad visual
  3. Manejo de errores más familiar a través de bloques try/catch
  4. Variables intermedias con mayor alcance (no están limitadas a bloques then)

Legibilidad del código asíncrono

Uno de los mayores beneficios de async/await es la mejora en la legibilidad del código. Veamos cómo transforma un ejemplo más complejo:

Antes (con promesas):

function obtenerDatosCompletos() {
  let datosUsuario;
  
  return autenticar()
    .then(token => {
      return obtenerPerfil(token);
    })
    .then(perfil => {
      datosUsuario = perfil;
      return obtenerAmigos(perfil.id);
    })
    .then(amigos => {
      datosUsuario.amigos = amigos;
      
      const promesasPublicaciones = amigos.map(amigo => {
        return obtenerPublicaciones(amigo.id)
          .then(publicaciones => {
            amigo.publicaciones = publicaciones;
            return amigo;
          });
      });
      
      return Promise.all(promesasPublicaciones);
    })
    .then(() => {
      return obtenerNotificaciones(datosUsuario.id);
    })
    .then(notificaciones => {
      datosUsuario.notificaciones = notificaciones;
      return datosUsuario;
    })
    .catch(error => {
      console.error("Error obteniendo datos:", error);
      throw new Error("No se pudieron cargar los datos completos");
    });
}

Después (con async/await):

async function obtenerDatosCompletos() {
  try {
    // Autenticación y perfil de usuario
    const token = await autenticar();
    const datosUsuario = await obtenerPerfil(token);
    
    // Obtenemos amigos
    const amigos = await obtenerAmigos(datosUsuario.id);
    datosUsuario.amigos = amigos;
    
    // Para cada amigo, obtenemos sus publicaciones
    const promesasPublicaciones = amigos.map(async amigo => {
      amigo.publicaciones = await obtenerPublicaciones(amigo.id);
      return amigo;
    });
    
    // Esperamos a que todas las promesas de publicaciones se resuelvan
    await Promise.all(promesasPublicaciones);
    
    // Obtenemos notificaciones
    datosUsuario.notificaciones = await obtenerNotificaciones(datosUsuario.id);
    
    return datosUsuario;
  } catch (error) {
    console.error("Error obteniendo datos:", error);
    throw new Error("No se pudieron cargar los datos completos");
  }
}

La versión con async/await es considerablemente más fácil de leer y seguir. El flujo de control se asemeja al código síncrono tradicional, lo que facilita entender qué está sucediendo en cada paso.

Limitaciones (solo en funciones async)

Es importante destacar que la palabra clave await solo puede utilizarse dentro de funciones declaradas con async. No se puede usar directamente en el ámbito global ni dentro de funciones regulares.

// ❌ Incorrecto: await fuera de una función async
const datos = await obtenerDatos(); // Esto generará un error de sintaxis

// ❌ Incorrecto: await en una función regular
function obtenerDatosAhora() {
  const datos = await obtenerDatos(); // Esto también generará un error
  return datos;
}

// ✅ Correcto: await dentro de una función async
async function obtenerDatosAhora() {
  const datos = await obtenerDatos(); // Esto funciona correctamente
  return datos;
}

Esta restricción puede presentar desafíos en ciertos contextos, como en el nivel superior de un script. Para estos casos, podemos usar una IIFE (Expresión de Función Inmediatamente Invocada) asíncrona:

// Función asíncrona auto-ejecutada para código de nivel superior
(async function() {
  try {
    const datos = await obtenerDatos();
    console.log(datos);
  } catch (error) {
    console.error("Error:", error);
  }
})();

En entornos modernos, algunos contextos de JavaScript ahora permiten await de nivel superior, como en módulos ES o en la consola de desarrollador de los navegadores, pero esto no es universalmente compatible.

Uso con expresiones

await puede utilizarse con cualquier expresión que devuelva una promesa, no solo con llamadas a funciones. Esto incluye:

  1. Llamadas a métodos que devuelven promesas:
const respuesta = await fetch('https://api.ejemplo.com/datos');
  1. Objetos Promise directamente:
const valor = await Promise.resolve(42);
  1. Expresiones que devuelven promesas:
const primerResultado = await (condicion ? promesa1() : promesa2());
  1. Constructores de Promise:
const resultado = await new Promise((resolve) => {
  setTimeout(() => resolve('completado'), 1000);
});
  1. Operadores ternarios con promesas:
const datos = await (existeEnCache ? obtenerDeCache() : obtenerDeAPI());

Esta flexibilidad permite integrar await de manera natural en diferentes patrones de código.

Comportamiento con promesas rechazadas

Cuando utilizamos await con una promesa que es rechazada, el comportamiento es similar a lanzar una excepción. El valor de rechazo se convierte en una excepción que puede capturarse mediante un bloque try/catch:

async function ejemplo() {
  try {
    // Esta promesa será rechazada
    const resultado = await Promise.reject(new Error('Algo salió mal'));
    
    // Este código nunca se ejecutará
    console.log(resultado);
  } catch (error) {
    // El error es capturado aquí
    console.error('Error capturado:', error.message);
  }
}

ejemplo(); // Muestra: "Error capturado: Algo salió mal"

Si no capturamos la excepción con try/catch, la función asíncrona devolverá una promesa rechazada con el valor de rechazo:

async function ejemploSinCaptura() {
  // Esta promesa será rechazada y no la capturamos
  const resultado = await Promise.reject(new Error('Sin capturar'));
  
  // Este código nunca se ejecutará
  return 'Éxito';
}

ejemploSinCaptura()
  .then(valor => console.log(valor))
  .catch(error => console.error('Error en promesa:', error.message));
// Muestra: "Error en promesa: Sin capturar"

Manejo de múltiples promesas

Uno de los desafíos con async/await es cómo manejar múltiples promesas de manera eficiente. Hay varias formas de hacerlo:

1. Secuencial (una después de otra)

async function secuencial() {
  const resultado1 = await promesa1();
  const resultado2 = await promesa2();
  const resultado3 = await promesa3();
  
  return [resultado1, resultado2, resultado3];
}

Esta aproximación ejecuta las promesas una después de otra, lo que puede ser ineficiente si las operaciones son independientes.

2. Paralelo (todas a la vez)

Para ejecutar promesas en paralelo, podemos usar Promise.all():

async function paralelo() {
  const [resultado1, resultado2, resultado3] = await Promise.all([
    promesa1(),
    promesa2(),
    promesa3()
  ]);
  
  return [resultado1, resultado2, resultado3];
}

Esta aproximación ejecuta todas las promesas simultáneamente y espera a que todas se completen, lo cual es más eficiente para operaciones independientes.

3. Iniciar promesas antes de await

Otra técnica es iniciar las promesas antes de esperar por ellas:

async function optimizado() {
  // Iniciamos todas las promesas inmediatamente
  const promesaResultado1 = promesa1();
  const promesaResultado2 = promesa2();
  const promesaResultado3 = promesa3();
  
  // Ahora esperamos por los resultados
  const resultado1 = await promesaResultado1;
  const resultado2 = await promesaResultado2;
  const resultado3 = await promesaResultado3;
  
  return [resultado1, resultado2, resultado3];
}

Esta técnica inicia todas las operaciones en paralelo pero luego espera por cada resultado en orden, lo que puede ser útil cuando necesitamos resultados en un orden específico.

Mejores prácticas de sintaxis

Para aprovechar al máximo async/await, aquí hay algunas mejores prácticas de sintaxis:

1. Siempre usa try/catch para manejar errores

async function ejemploRobusto() {
  try {
    const datos = await operacionAsincrona();
    return procesarDatos(datos);
  } catch (error) {
    console.error("Error en la operación:", error);
    // Podemos manejar el error de varias formas:
    // 1. Retornar un valor por defecto
    return valorPorDefecto;
    // 2. Relanzar el error
    // throw error;
    // 3. Lanzar un error personalizado
    // throw new Error(`Error en el procesamiento: ${error.message}`);
  }
}

2. Evita mezclar await con .then() o .catch() en el mismo nivel

// ❌ No recomendado: mezcla confusa de paradigmas
async function ejemploConfuso() {
  const datos = await operacion1();
  return operacion2(datos).then(resultado => {
    return resultado.valor;
  });
}

// ✅ Recomendado: usa await consistentemente
async function ejemploClaro() {
  const datos = await operacion1();
  const resultado = await operacion2(datos);
  return resultado.valor;
}

3. Usa await en expresiones de retorno cuando sea apropiado

// Versión concisa con await en return
async function ejemploConciso() {
  return await operacionFinal();
}

// Si no hay procesamiento adicional, puedes omitir await en el return
async function ejemploDirecto() {
  // Esta función también devuelve una promesa
  return operacionFinal();
}

Nota: incluir await en el return puede ser útil si deseas capturar excepciones dentro de la función actual.

4. Aprovecha la desestructuración con await

async function ejemploDesestructuracion() {
  // Desestructuración simple
  const { id, nombre } = await obtenerUsuario(123);
  
  // Desestructuración con Promise.all
  const [usuario, permisos, preferencias] = await Promise.all([
    obtenerUsuario(123),
    obtenerPermisos(123),
    obtenerPreferencias(123)
  ]);
  
  return { usuario, permisos, preferencias };
}

5. Usa expresiones IIFE asíncronas para contextos no asíncronos

// Cuando necesites código asíncrono en un contexto síncrono
document.getElementById('boton').addEventListener('click', function() {
  // No podemos usar await directamente aquí, así que usamos IIFE
  (async function() {
    try {
      const resultado = await procesarClick();
      mostrarResultado(resultado);
    } catch (error) {
      mostrarError(error);
    }
  })();
});

6. Evita bloques try/catch anidados excesivos

// ❌ No recomendado: try/catch anidados
async function ejemploAnidacionExcesiva() {
  try {
    const datosA = await operacionA();
    try {
      const datosB = await operacionB(datosA);
      try {
        const datosC = await operacionC(datosB);
        return datosC;
      } catch (errorC) {
        console.error("Error en C:", errorC);
      }
    } catch (errorB) {
      console.error("Error en B:", errorB);
    }
  } catch (errorA) {
    console.error("Error en A:", errorA);
  }
}

// ✅ Recomendado: separar en funciones más pequeñas
async function procesarABC() {
  try {
    const datosA = await operacionA();
    const datosB = await operacionB(datosA);
    const datosC = await operacionC(datosB);
    return datosC;
  } catch (error) {
    console.error("Error en el proceso:", error);
    throw error; // Relanzamos para manejo en nivel superior
  }
}

Comportamiento con promesas rechazadas

Cuando usamos await con una promesa que se rechaza, el comportamiento es similar a lanzar una excepción. Esto significa que podemos usar un bloque try/catch para manejar el error:

async function ejemploConError() {
  try {
    const resultado = await funcionQueRechazaPromesa();
    // Este código no se ejecutará si la promesa es rechazada
    console.log("Resultado:", resultado);
  } catch (error) {
    // Este código se ejecutará si la promesa es rechazada
    console.error("Error capturado:", error);
  }
}

Si no capturamos el error con try/catch, la función asíncrona devolverá una promesa rechazada:

async function sinCapturarError() {
  // Si esta promesa se rechaza, la función devolverá una promesa rechazada
  const resultado = await funcionQueRechazaPromesa();
  return resultado;
}

// Necesitamos manejar el rechazo al llamar a la función
sinCapturarError()
  .then(resultado => console.log("Éxito:", resultado))
  .catch(error => console.error("Error en la promesa:", error));

Resumen

La sintaxis de async/await ha transformado la forma en que escribimos código asíncrono en JavaScript, proporcionando una estructura más clara, legible y fácil de mantener. Sus principales características incluyen:

  • La palabra clave await pausa la ejecución de una función asíncrona hasta que una promesa se resuelve o rechaza
  • Permite trabajar con valores asincrónicos como si fueran síncronos
  • Ofrece una alternativa más limpia al encadenamiento de métodos .then()
  • Mejora significativamente la legibilidad del código asíncrono
  • Solo puede utilizarse dentro de funciones declaradas con async
  • Funciona con cualquier expresión que devuelva una promesa
  • Transforma los rechazos de promesas en excepciones que pueden capturarse con try/catch
  • Permite manejar múltiples promesas de manera secuencial o paralela

Al dominar la sintaxis de async/await, podemos escribir código asíncrono que es más intuitivo, más fácil de depurar y más resistente a errores. Esta combinación de palabras clave es una de las mejoras más significativas en JavaScript moderno y una herramienta esencial en el arsenal de cualquier desarrollador.