Manejo de errores en Rust con thiserror y anyhow

1. Descripción general

Rust te obliga a manejar errores de forma explícita a través de Result<T, E>. Este enfoque es seguro, pero escribir manualmente los tipos de error es verboso. Debes implementar manualmente Display, Error y From para cada caso.

Este artículo introduce las dos crates más populares para resolver esto. thiserror ayuda a definir tipos de error estructurados. anyhow ayuda a agrupar todos los errores en un solo tipo y agregar contexto. Después de leerlo, sabrás cuál crate usar en qué lugar, cómo combinarlos y cómo probar errores.

2. El problema de escribir errores manualmente

Supongamos que lees la cantidad de un pedido desde un archivo. Hay dos errores posibles: error de I/O y error de análisis. Si no usas una librería, tendrías que escribir algo como esto:

use std::fmt;

#[derive(Debug)]
pub enum OrderError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
}

impl fmt::Display for OrderError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            OrderError::Io(_) => write!(f, "no se pudo leer el archivo de pedido"),
            OrderError::Parse(_) => write!(f, "datos de pedido con formato incorrecto"),
        }
    }
}

impl std::error::Error for OrderError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            OrderError::Io(e) => Some(e),
            OrderError::Parse(e) => Some(e),
        }
    }
}

impl From<std::io::Error> for OrderError {
    fn from(e: std::io::Error) -> Self { OrderError::Io(e) }
}

impl From<std::num::ParseIntError> for OrderError {
    fn from(e: std::num::ParseIntError) -> Self { OrderError::Parse(e) }
}

Necesitas casi 30 líneas de código boilerplate solo para dos variantes. Cada vez que agregas una variante, tienes que modificarla en cuatro lugares. Esto es exactamente lo que thiserror hace por ti.

Otro atajo es usar Box<dyn std::error::Error>. Este enfoque es conciso, pero pierde información de tipos y no te permite agregar contexto. anyhow es una versión mejorada de este atajo.

3. Instalación

Agrega dos dependencias a Cargo.toml:

[dependencies]
thiserror = "2"
anyhow = "1"

Estas dos crates son independientes entre sí. Una librería generalmente solo necesita thiserror. Un binario suele usar anyhow, a veces ambos.

4. thiserror: tipos de error estructurados para librerías

4.1. Definir un enum de error

Con thiserror, el código de la sección 2 se simplifica a:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum OrderError {
    #[error("pedido #{0} no encontrado")]
    NotFound(u64),

    #[error("cantidad inválida: {quantity} (debe ser > 0)")]
    InvalidQuantity { quantity: i32 },

    #[error("no se pudo leer el archivo de pedido")]
    Io(#[from] std::io::Error),

    #[error("datos de pedido con formato incorrecto")]
    Parse(#[from] std::num::ParseIntError),
}

Cada parte tiene su propio rol:

  • #[derive(Error)] genera la implementación de std::error::Error.
  • #[error("...")] genera Display. {0} hace referencia al primer campo de la variante tuple. {quantity} hace referencia a un campo nombrado.
  • #[from] genera impl From<std::io::Error> for OrderError. Gracias a esto, el operador ? convierte automáticamente el error. El campo con #[from] también se convierte automáticamente en source().

Ahora escribamos una función que use este tipo de error:

use std::fs;

pub fn parse_quantity(raw: &str) -> Result<i32, OrderError> {
    let quantity: i32 = raw.trim().parse()?;
    if quantity <= 0 {
        return Err(OrderError::InvalidQuantity { quantity });
    }
    Ok(quantity)
}

pub fn load_quantity(path: &str) -> Result<i32, OrderError> {
    let content = fs::read_to_string(path)?;
    parse_quantity(&content)
}

La función fs::read_to_string devuelve io::Error. El operador ? llama a From para convertirlo a OrderError::Io. Del mismo modo, ParseIntError se convierte a OrderError::Parse. El llamador puede usar match en cada variante para manejarla por separado.

4.2. Recorrer la cadena de causas con #[source]

A veces quieres mantener el error original pero no quieres una conversión automática con From. En ese caso, usas #[source] en lugar de #[from]:

#[derive(Debug, Error)]
pub enum InventoryError {
    #[error("no se pudo actualizar el inventario del producto {sku}")]
    UpdateFailed {
        sku: String,
        #[source]
        cause: std::io::Error,
    },
}

Esta variante tiene un campo sku adicional, por lo que From no puede generarse automáticamente. En este caso, #[source] solo declara que cause es la causa. Puedes recorrer la cadena de causas así:

use std::error::Error as _;

fn print_chain(err: &dyn std::error::Error) {
    eprintln!("Error: {err}");
    let mut current = err.source();
    while let Some(cause) = current {
        eprintln!("  causado por: {cause}");
        current = cause.source();
    }
}

Cuando llamas a print_chain con un UpdateFailed que envuelve el error “permission denied”, verás:

Error: no se pudo actualizar el inventario del producto SKU-42
  causado por: Permission denied (os error 13)

4.3. #[error(transparent)]

transparent reenvía directamente Display y source() al error interno. Lo usas cuando la variante es solo un envoltorio y no agrega información:

#[derive(Debug, Error)]
pub enum PaymentError {
    #[error("tarjeta rechazada")]
    CardDeclined,

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

La variante Other actúa como “error restante”. Los errores específicos que el llamador necesita manejar tienen sus propias variantes. Todos los demás errores se agrupan en Other.

5. anyhow: un solo tipo de error para toda la aplicación

En la capa de aplicación, rara vez usas match en cada tipo de error. Generalmente solo necesitas registrar el error o mostrarlo al usuario, con suficiente información para saber qué sucedió. anyhow::Error satisface exactamente esta necesidad.

5.1. anyhow::Result y contexto

use anyhow::{Context, Result};

fn read_port(path: &str) -> Result<u16> {
    let raw = std::fs::read_to_string(path)
        .with_context(|| format!("no se pudo leer el archivo de configuración {path}"))?;
    let port: u16 = raw
        .trim()
        .parse()
        .context("el puerto debe ser un número entero entre 0 y 65535")?;
    Ok(port)
}

fn main() -> Result<()> {
    let port = read_port("app.conf")?;
    println!("Servidor ejecutándose en el puerto {port}");
    Ok(())
}

El código funciona así:

  • anyhow::Result<T> es un alias para Result<T, anyhow::Error>.
  • Cualquier tipo que implemente std::error::Error + Send + Sync + 'static se puede convertir a anyhow::Error mediante ?.
  • .context(...) envuelve el error original en un nuevo mensaje, y el error original se conserva como la causa.
  • .with_context(|| ...) toma un closure. El closure solo se ejecuta si hay un error, así evitas llamadas innecesarias a format! en la ruta feliz.

Cuando main devuelve un Err porque el archivo no existe, el programa imprime:

Error: no se pudo leer el archivo de configuración app.conf

Caused by:
    No such file or directory (os error 2)

Tienes tres formas de formatear un anyhow::Error:

  • {} solo imprime el mensaje más externo.
  • {:#} imprime toda la cadena en una línea, separada por : .
  • {:?} imprime en formato multilínea como se muestra arriba.

Usar {:#} al registrar en una sola línea es lo más conveniente.

Context también funciona con Option. Esta es una forma concisa de convertir None en un error significativo:

use anyhow::{Context, Result};
use std::collections::HashMap;

fn find_email(customers: &HashMap<u64, String>, id: u64) -> Result<&String> {
    customers.get(&id).with_context(|| format!("no hay cliente #{id}"))
}

5.2. bail!, ensure! y anyhow!

Estas tres macros te ayudan a crear errores ad-hoc sin definir un tipo:

use anyhow::{anyhow, bail, ensure, Result};

fn apply_discount(total: u64, percent: u8) -> Result<u64> {
    ensure!(percent <= 100, "porcentaje de descuento inválido: {percent}");
    if total == 0 {
        bail!("no se puede aplicar descuento a un pedido de 0");
    }
    total
        .checked_sub(total * percent as u64 / 100)
        .ok_or_else(|| anyhow!("desbordamiento al calcular el descuento"))
}
  • ensure!(cond, ...) devuelve un error si la condición es falsa.
  • bail!(...) es equivalente a return Err(anyhow!(...)).
  • anyhow!(...) crea un valor anyhow::Error sin retornar.

Por ejemplo, apply_discount(500_000, 120) devuelve un error porcentaje de descuento inválido: 120.

5.3. Downcast cuando aún necesitas clasificar errores

anyhow::Error aún conserva el tipo original. Puedes hacer downcast para manejar un caso especial:

fn handle(err: &anyhow::Error) {
    match err.downcast_ref::<OrderError>() {
        Some(OrderError::NotFound(id)) => println!("Mostrar página 404 para el pedido #{id}"),
        Some(other) => println!("Error de pedido: {other}"),
        None => println!("Error del sistema: {err:#}"),
    }
}

downcast_ref aún funciona cuando el error ha sido envuelto con .context(...). Sin embargo, si haces downcast frecuentemente, es una señal de que deberías usar un enum thiserror en lugar de anyhow.

6. Combinación: thiserror en la capa inferior, anyhow en la capa superior

Una regla simple y efectiva:

  • Librería o módulo de dominio usa thiserror. El llamador necesita saber exactamente qué error es para decidir qué hacer.
  • Binary, CLI, handler en la capa más externa usa anyhow. Aquí principalmente reportas errores.
use anyhow::{Context, Result};

fn process_order_file(path: &str) -> Result<()> {
    let quantity = load_quantity(path)
        .with_context(|| format!("falló el procesamiento del archivo de pedido {path}"))?;
    println!("El pedido tiene {quantity} productos");
    Ok(())
}

La función load_quantity devuelve un OrderError estructurado. La capa superior agrega contexto sobre el archivo que se está procesando. Cuando ejecutas con un archivo que contiene -3, verás:

Error: falló el procesamiento del archivo de pedido orders/1001.txt

Caused by:
    cantidad inválida: -3 (debe ser > 0)

7. Probar el comportamiento de errores

Los tipos de error estructurados hacen que las pruebas sean precisas. Puedes verificar la variante correcta, en lugar de comparar strings de forma vaga:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn negative_quantity_returns_invalid_quantity() {
        let err = parse_quantity("-3").unwrap_err();

        assert!(matches!(err, OrderError::InvalidQuantity { quantity: -3 }));
        assert_eq!(err.to_string(), "cantidad inválida: -3 (debe ser > 0)");
    }

    #[test]
    fn non_numeric_input_returns_parse_error() {
        let err = parse_quantity("abc").unwrap_err();
        assert!(matches!(err, OrderError::Parse(_)));
    }

    #[test]
    fn missing_config_keeps_io_error_as_root_cause() {
        let err = read_port("/khong/ton/tai.conf").unwrap_err();

        assert_eq!(
            err.to_string(),
            "no se pudo leer el archivo de configuración /khong/ton/tai.conf"
        );
        assert!(err.root_cause().downcast_ref::<std::io::Error>().is_some());
    }
}

Ejecuta las pruebas con el comando:

cargo test
running 3 tests
test tests::negative_quantity_returns_invalid_quantity ... ok
test tests::non_numeric_input_returns_parse_error ... ok
test tests::missing_config_keeps_io_error_as_root_cause ... ok

test result: ok. 3 passed; 0 failed

La tercera prueba muestra que root_cause() va al error más profundo de la cadena. Así confirmas que el contexto no pierde el error original.

8. Errores comunes y cuándo no usar

Nota: No repitas el mensaje del error original en #[error]. Si escribes #[error("io error: {0}")] Io(#[from] io::Error), el mensaje original aparecerá dos veces cuando imprimas toda la cadena con {:?} o {:#}. Elige una de las dos formas: incrustar {0} sin declarar source, o declarar source y mantener el mensaje conciso.

Nota: No uses anyhow::Error en la API pública de tu librería. El llamador no podrá usar match en el error y se verá obligado a hacer downcast. Expone un enum thiserror en su lugar.

Otros errores que puedes encontrar:

  • Dos variantes con el mismo #[from] para un tipo de origen. Por ejemplo, dos variantes que envuelven io::Error generarán dos impl From duplicados y causarán un error de compilación. Usa #[source] combinado con map_err en al menos una variante.
  • Abusar de #[from]. Si cada io::Error se convierte en OrderError::Io, pierdes información sobre qué operación falló. Una variante con campos de contexto, como UpdateFailed { sku, cause }, suele ser más útil.
  • Olvidar que anyhow::Error no implementa std::error::Error. Realiza dereferencia a dyn Error pero no implementa ese trait. No puedes pasarlo a un lugar que necesite E: std::error::Error. Si es necesario, envuélvelo con una variante #[error(transparent)].
  • Llamar a .context(format!(...)). La cadena se formatea incluso cuando no hay error. Usa .with_context(|| ...).
  • Código extremadamente sensible al rendimiento o no_std. anyhow::Error asigna en el heap. Para rutas críticas o entornos embebidos, un enum compacto es la mejor opción.

Si quieres un backtrace, establece la variable de entorno RUST_BACKTRACE=1 cuando ejecutes. anyhow imprimirá el backtrace junto con el error.

9. Conclusión

thiserror y anyhow resuelven dos necesidades diferentes. thiserror elimina el código boilerplate al definir tipos de error, apropiado para librerías y la capa de dominio, donde el llamador necesita clasificar errores. anyhow agrupa todos los errores en un solo tipo, permitiendo agregar contexto en cada paso, apropiado para binarios y la capa más externa.

Deberías combinar ambos: define errores estructurados en la capa inferior, envuelve contexto en la capa superior. Finalmente, mantén los mensajes concisos, no repitas el error original, y escribe pruebas que verifiquen la variante correcta en lugar de solo comparar strings.

rusterror-handlingthiserroranyhow