wandres.dev
ERRORES PROPIOS · thiserror y anyhow

Tu propio enum de error, y Box dyn Error para lo heterogéneo

Un enum con una variante por causa da al llamador un error tipado sobre el que hacer match. Box dyn Error hace lo contrario: borra el tipo y acepta cualquier error con cero ceremonia. Dos formas de decir de-uno-de-varios, y cuándo elegir cada una.

⏱ 18 min

Ya sabes firmar el contrato Error para un tipo. Pero una función real falla de varias maneras a la vez: lee un fichero (puede fallar la E/S), lo parsea (puede fallar el formato), valida (puede fallar la regla de negocio). ¿Cómo reúnes esos fallos dispares en un único tipo de retorno? Rust ofrece dos respuestas opuestas y ambas válidas. La primera es un enum propio, una variante por causa: un tipo cerrado que el llamador puede diseccionar con match. La segunda es Box<dyn Error>, un tipo borrado que traga cualquier error sin que escribas una línea de conversión. Elegir entre ambas es elegir cuánto le debes a quien te llama.

🎯 Al terminar esta lección sabrás
  • Modelar un dominio con un enum de error de una variante por causa.
  • Implementar Display y source sobre cada variante que envuelve otro error.
  • Usar Box<dyn Error> para unificar errores heterogéneos con el operador ?.
  • Sopesar el coste del borrado de tipo frente a un enum cerrado y recuperar el tipo con downcast_ref.

Un enum con una variante por causa

La forma canónica de un error de dominio es un enum donde cada variante nombra un modo de fallo distinto, con los datos que lo describen:

use std::error::Error;
use std::fmt;

#[derive(Debug)]
enum ErrorPedido {
    Vacio,
    CantidadInvalida { pedida: u32, maxima: u32 },
    SinStock(String),
}

impl fmt::Display for ErrorPedido {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ErrorPedido::Vacio => write!(f, "el pedido no contiene articulos"),
            ErrorPedido::CantidadInvalida { pedida, maxima } =>
                write!(f, "cantidad {pedida} invalida, el maximo es {maxima}"),
            ErrorPedido::SinStock(producto) =>
                write!(f, "sin stock del producto '{producto}'"),
        }
    }
}

impl Error for ErrorPedido {} // source por defecto: None

Fíjate en el impl Error vacío: como ninguna variante envuelve otro error, source no tiene nada que exponer y el cuerpo por defecto (None) sirve tal cual. El valor de este diseño es que quien llame recibe un tipo exhaustivo: puede escribir un match sobre ErrorPedido y el compilador le exigirá cubrir cada modo de fallo. El error es autodocumentado: su firma enumera todo lo que puede ir mal.

Cuando una variante sí envuelve otro error, la conectas al hilo de causas redefiniendo source:

#[derive(Debug)]
enum ErrorCarga {
    Lectura(std::io::Error),
    Formato(std::num::ParseIntError),
}

impl fmt::Display for ErrorCarga {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ErrorCarga::Lectura(_) => write!(f, "fallo al leer el fichero"),
            ErrorCarga::Formato(_) => write!(f, "el contenido no es un numero"),
        }
    }
}

impl Error for ErrorCarga {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            ErrorCarga::Lectura(e) => Some(e),
            ErrorCarga::Formato(e) => Some(e),
        }
    }
}

Esto funciona y es correcto, pero repara en la ceremonia: un match para Display, otro match para source, y —si quieres que ? convierta hacia este tipo— tendrías que añadir un impl From por cada variante envolvente. Es exactamente este boilerplate el que thiserror eliminará en la lección siguiente. Escríbelo a mano una vez para saber qué te ahorra la macro.

Box dyn Error: un tipo para todos los errores

A veces no quieres enumerar nada: solo propagar. Box<dyn Error> es un objeto de trait que guarda cualquier error en el heap detrás de un puntero, olvidando su tipo concreto. Su superpoder es que compone con ? sin una sola conversión escrita:

use std::error::Error;

fn cargar_puerto(ruta: &str) -> Result<u16, Box<dyn Error>> {
    let texto = std::fs::read_to_string(ruta)?; // io::Error   -> Box<dyn Error>
    let puerto: u16 = texto.trim().parse()?;     // ParseIntError -> Box<dyn Error>
    Ok(puerto)
}

Los dos ? propagan errores de tipos distintos y ambos aterrizan en el mismo Box<dyn Error> sin que escribas nada. La costura invisible es una implementación general de la biblioteca estándar: impl<E: Error + 'static> From<E> for Box<dyn Error>. Como cualquier error concreto implementa Error, cualquier error concreto se auto-empaqueta en la caja al pasar por ?.

Es cómodo teclear un alias para no repetir el tipo:

type Resultado<T> = std::result::Result<T, Box<dyn std::error::Error>>;
ℹ️
Send y Sync para cruzar hilos

Box<dyn Error> a secas no es Send ni Sync, así que no puede viajar entre hilos ni lo aceptan muchas APIs asíncronas. La forma que verás por todas partes añade esas cotas: Box<dyn Error + Send + Sync + 'static>. Es también la que anyhow usa por dentro. Si tu error va a moverse entre tareas, pide Send + Sync desde el principio.

El precio del borrado de tipo

Box<dyn Error> regala ergonomía, pero cobra un peaje: borra el tipo. Puedes pedirle su Display, su Debug y su source, pero no puedes hacer match sobre qué error es, porque en tiempo de compilación ya no se sabe. Si necesitas recuperar el tipo concreto, has de pedirlo explícitamente con downcast_ref, que devuelve un Option:

let err: Box<dyn Error> = cargar_puerto("cfg.txt").unwrap_err();
if let Some(io) = err.downcast_ref::<std::io::Error>() {
    eprintln!("fue un fallo de E/S concreto: {io}");
}

Aquí está la disyuntiva central del nivel, y no tiene una respuesta única:

  • Un enum propio es un conjunto cerrado: enumera de antemano todo lo que puede fallar. Cuesta boilerplate, pero el llamador hace match exhaustivo y el compilador vela por él. Es lo que le debes a quien construye lógica sobre tus errores.
  • Box<dyn Error> es un conjunto abierto: acepta cualquier error presente o futuro, sin ceremonia, pero opaco. El llamador solo puede imprimirlo o intentar un downcast. Es lo ideal cuando nadie aguas abajo va a ramificar según el error.

La regla de dedo: enum tipado en la API pública de una librería; Box<dyn Error> en el código de aplicación, prototipos y main. Esa frontera es el corazón de las tres lecciones que siguen.

flowchart TD
E[Una funcion puede fallar de varias formas] --> Q[Como lo expreso]
Q -->|enum propio| T[Tipo cerrado y enumerable]
Q -->|Box dyn Error| O[Tipo borrado y abierto]
T --> TM[El llamador hace match exhaustivo]
O --> OM[El llamador imprime o hace downcast]
style T fill:#a6e3a1,color:#11111b
style O fill:#f38ba8,color:#11111b
style TM fill:#89b4fa,color:#11111b
style OM fill:#cba6f7,color:#11111b
Enum o objeto de trait: la misma dualidad que gobierna todo Rust

Detente en la pregunta “¿cómo digo que mi función devuelve uno de varios errores?”, porque su respuesta es la misma bifurcación que ya viste en el despacho estático frente al dinámico, ahora vestida de manejo de errores. Un enum es la respuesta cerrada: declaras por adelantado el conjunto entero de posibilidades, el compilador conoce cada variante, y el llamador puede razonar exhaustivamente porque el universo de fallos es finito y está a la vista. Box<dyn Error> es la respuesta abierta: renuncias a enumerar, aceptas que el conjunto de errores es potencialmente infinito y desconocido, y a cambio de esa apertura pagas con el borrado del tipo, que solo un downcast en tiempo de ejecución puede deshacer parcialmente. Ninguna es superior en abstracto; cada una codifica una afirmación distinta sobre el mundo. El enum afirma “el conjunto de cosas que pueden fallar aquí es cerrado, conocido y digno de ser nombrado” —una promesa que un autor de librería debe a sus usuarios, porque ellos escribirán código que depende de esa forma—. El objeto de trait afirma “el conjunto es abierto y no merece la pena enumerarlo” —una comodidad que un autor de aplicación puede permitirse, porque nadie construye sobre sus errores internos—. La decisión de diseño no es técnica sino social: ¿quién está aguas abajo de este error, y necesita decidir en función de él, o solo saber que ocurrió? Responde eso y el tipo de retorno se elige solo. Todo el debate thiserror contra anyhow que viene a continuación no es más que esta misma pregunta, industrializada.

📝
Lo esencial de enums y Box dyn Error

Un enum de error, con una variante por causa, da un tipo cerrado y exhaustivo: el llamador hace match, a cambio de boilerplate en Display, source y From. Box<dyn Error> borra el tipo y absorbe cualquier error vía ? gracias a la implementación general From<E> for Box<dyn Error>, pero el llamador solo puede imprimir o hacer downcast_ref. Usa Send + Sync + 'static si cruza hilos. Tipado para librerías; borrado para binarios.

⚔️ Cierra el conjunto, o ábrelo
  1. Define enum ErrorValidacion con tres variantes (una unit, una con campos con nombre, una que envuelva un std::num::ParseIntError). Implementa Display y, para la variante envolvente, source.
  2. Escribe una función que devuelva Result<_, ErrorValidacion> y, en el llamador, un match exhaustivo sobre las tres variantes.
  3. Reescribe una función parecida para que devuelva Box<dyn Error> usando ? sobre dos errores de tipos distintos. Cuenta cuántas líneas de conversión has tenido que escribir (cero).
  4. Sobre el Box<dyn Error> resultante, intenta un match por variante y observa que no compila; luego recupera el tipo con downcast_ref y explica por qué es necesario.
  5. Para cada caso decide enum o Box<dyn Error> y justifícalo: una librería de parseo de fechas; el main de una herramienta de línea de comandos; un módulo interno cuyo error nadie fuera del crate llega a ver.