anyhow y thiserror: error handling moderno en Rust sin Results anidados

Rust no tiene excepciones. Cuando algo puede fallar, la función devuelve Result<T, E>: o el valor que esperabas, o un error. El operador ? propaga ese error hacia arriba automáticamente, así que puedes encadenar operaciones falibles sin escribir un match cada vez.

Hasta aquí todo bien. El problema aparece cuando una función hace varias cosas distintas y cada una puede fallar de forma diferente. Abrir un archivo, parsear su contenido, llamar a una API, escribir en base de datos... Cada operación tiene su propio tipo de error. Y Result<T, E> solo acepta un tipo E.

Sin herramientas externas, la solución es definir un enum que agrupe todos los posibles errores e implementar From<IoError>, From<ParseError>, etc. para cada variante. Funciona, pero si tienes cinco tipos de error distintos, ya tienes un buen puñado de código repetitivo antes de escribir ni una línea de lógica real.

Aquí entran anyhow y thiserror, dos crates creadas por David Tolnay (dtolnay) que se han convertido en la forma estándar de manejar errores en Rust.

La regla que lo aclara todo: thiserror para librerías, anyhow para aplicaciones

Antes de ver el código, conviene tener clara la división de trabajo entre estas dos crates, porque no compiten entre sí.

  • thiserror te ayuda a definir tus propios tipos de error con mensajes claros y sin escribir boilerplate. Es lo que usas cuando estás construyendo una librería que otros van a consumir: quieres que los errores que expones sean tipados y descriptivos.
  • anyhow borra el tipo de error y lo convierte en anyhow::Error, un tipo opaco que puede contener cualquier error. Es lo que usas en aplicaciones finales o binarios donde lo importante es que el error llegue al usuario con contexto suficiente para entender qué pasó.

No tienes que elegir uno. Lo habitual es usar thiserror en la capa de librería y anyhow en el código de la aplicación que la llama. Las dos trabajan bien juntas.

thiserror: define tu tipo de error sin repetirte

El punto de entrada es el derive #[derive(Error, Debug)] sobre un enum. A partir de ahí, el atributo #[error("...")] genera la implementación de Display automáticamente, y #[from] genera la implementación de From para el tipo que envuelve cada variante.

use thiserror::Error;

#[derive(Error, Debug)]
pub enum AppError {
    #[error("error de red al conectar con {host}: {source}")]
    Network {
        host: String,
        #[source]
        source: std::io::Error,
    },

    #[error("fallo al parsear la configuración")]
    Parse(#[from] serde_json::Error),

    #[error("base de datos no disponible")]
    Database(#[from] sqlx::Error),

    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

El atributo #[error(transparent)] en la última variante delega Display y source() al error interno, sin añadir ni quitar nada. Es útil para la variante cajón de sastre cuando no quieres envolver errores que ya tienen buen mensaje.

Con esto, cualquier función que devuelva Result<T, AppError> puede usar ? directamente sobre io::Error, serde_json::Error o sqlx::Error: la conversión la genera thiserror, no la escribes tú.

anyhow: olvídate del tipo de error

Con anyhow, en lugar de definir un tipo de error para cada función, pones esto al principio del archivo:

use anyhow::Result;

Eso redefine Result como Result<T, anyhow::Error>. A partir de ahí, cualquier función que devuelva Result<T> acepta ? sobre cualquier error que implemente std::error::Error, sin conversiones manuales.

use anyhow::{Context, Result};
use std::fs;

fn leer_config(ruta: &str) -> Result<Config> {
    let contenido = fs::read_to_string(ruta)
        .with_context(|| format!("al leer el archivo {}", ruta))?;

    let config: Config = serde_json::from_str(&contenido)
        .context("al parsear el JSON de configuración")?;

    Ok(config)
}

Si algo falla, el error que llega al usuario no es solo "No such file or directory (os error 2)". Es algo así:

al leer el archivo config.json: No such file or directory (os error 2)

Mucho más útil para depurar.

.context() y .with_context(): añade contexto a cualquier error

Estas dos son probablemente las partes de anyhow que más usarás en el día a día.

  • .context("descripción"): añade un mensaje estático al error. Se evalúa siempre, tanto si hay error como si no.
  • .with_context(|| format!("al procesar {}", nombre)): igual, pero el mensaje es un closure que solo se evalúa si hay error. Usa este cuando el mensaje requiere formatear strings o hacer operaciones con coste.
use anyhow::{Context, Result};

fn procesar_archivo(nombre: &str) -> Result<()> {
    let datos = fs::read(nombre)
        .with_context(|| format!("no se pudo abrir '{}'", nombre))?;

    let texto = String::from_utf8(datos)
        .with_context(|| format!("'{}' no es UTF-8 válido", nombre))?;

    println!("{}", texto);
    Ok(())
}

Los contextos se apilan: si una función añade contexto y la función que la llama también añade el suyo, el mensaje final muestra toda la cadena. Cuando activas RUST_BACKTRACE=1, anyhow también captura el backtrace automáticamente.

bail! y ensure!: atajos para salir con error

Cuando necesitas devolver un error sin que haya venido de otra función, tienes dos macros:

  • bail!: devuelve Err inmediatamente con el mensaje que le pases. Es equivalente a return Err(anyhow!(...)), pero más corto.
  • ensure!: comprueba una condición y hace bail si no se cumple. Como assert!, pero en lugar de hacer panic devuelve un error.
use anyhow::{bail, ensure, Result};

fn validar_puerto(puerto: u32) -> Result<()> {
    ensure!(puerto < 65536, "el puerto {} no es válido (debe ser menor que 65536)", puerto);
    ensure!(puerto > 0, "el puerto no puede ser 0");
    Ok(())
}

fn conectar(host: &str, puerto: u32) -> Result<()> {
    if host.is_empty() {
        bail!("el host no puede estar vacío");
    }
    validar_puerto(puerto)?;
    // ... lógica de conexión
    Ok(())
}

Son pequeños detalles, pero el código queda mucho más limpio que andar escribiendo return Err(anyhow::anyhow!("...")) cada vez.

anyhow::Error vs Box<dyn std::error::Error>

Si ya conoces Box<dyn std::error::Error> como forma de borrar el tipo de error, puede que te preguntes qué aporta anyhow encima de eso. La diferencia es práctica:

  • anyhow::Error mantiene un stack de contexto que puedes ir ampliando con .context(). Box<dyn Error> no tiene eso.
  • Con RUST_BACKTRACE=1, anyhow captura el backtrace en el punto donde se creó el error, no donde se propagó. Muy útil cuando el error viaja por varios niveles de funciones.
  • Si necesitas recuperar el tipo original, puedes usar error.downcast_ref::<IoError>(). El tipo no desaparece del todo, solo está envuelto.
  • La API es más ergonómica: use anyhow::Result y ya, sin anotar el tipo de error en ningún sitio.

Ejemplo completo: una CLI con thiserror en la librería y anyhow en main

Para ver las dos crates trabajando juntas, imagina una CLI sencilla que lee un archivo de configuración y hace algo con él. La lógica de parseo está en un módulo separado que define su propio tipo de error con thiserror, y main() usa anyhow para no tener que gestionar ese tipo explícitamente.

// src/config.rs — parte de librería, usa thiserror
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("no se encontró el archivo de configuración en '{ruta}'")]
    ArchivoNoEncontrado { ruta: String },

    #[error("el formato del archivo no es JSON válido")]
    FormatoInvalido(#[from] serde_json::Error),

    #[error("falta el campo obligatorio '{campo}'")]
    CampoFaltante { campo: String },
}

pub fn cargar(ruta: &str) -> Result<Config, ConfigError> {
    let contenido = std::fs::read_to_string(ruta)
        .map_err(|_| ConfigError::ArchivoNoEncontrado { ruta: ruta.to_string() })?;

    let config: Config = serde_json::from_str(&contenido)?;

    if config.host.is_empty() {
        return Err(ConfigError::CampoFaltante { campo: "host".to_string() });
    }

    Ok(config)
}
// src/main.rs — aplicación final, usa anyhow
use anyhow::{Context, Result};

fn main() -> Result<()> {
    let ruta = std::env::args().nth(1)
        .unwrap_or_else(|| "config.json".to_string());

    let config = config::cargar(&ruta)
        .with_context(|| format!("al cargar la configuración desde '{}'", ruta))?;

    println!("Conectando a {}:{}", config.host, config.port);
    // ... resto de la aplicación

    Ok(())
}

Si el archivo no existe, el usuario ve:

Error: al cargar la configuración desde 'config.json'

Caused by:
    no se encontró el archivo de configuración en 'config.json'

main() devuelve anyhow::Result<()>, que es un azúcar sintáctico soportado por Rust desde la edición 2018. Si main() devuelve Err, Rust imprime el error en stderr y termina con código de salida 1.

Cuándo no usar anyhow

Si estás escribiendo una librería que otros van a usar, no expongas anyhow::Error en la API pública. Los consumidores de tu crate no pueden inspeccionar ese tipo para hacer match y tratar cada caso de forma diferente. Para eso está thiserror: defines un enum con variantes claras y dejas que quien te llame decida qué hacer con cada una.

La regla sencilla: si el código termina en un binario o una CLI, anyhow. Si el código es una crate que otros importan, thiserror en la interfaz pública.

Para profundizar en el lenguaje y ver más herramientas del entorno Rust, puedes revisar Rust en proyectos reales: herramientas clave y Rust 1.95 y las mejoras recientes.

Imagen: Pexels / Markus Spiske

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP