wandres.dev
ERRORES PROPIOS · thiserror y anyhow

El trait Error: la firma de un tipo que dice ser un fallo

Implementar std::error::Error no es un adorno, es un contrato. Exige Display para el usuario, Debug para el programador y source para encadenar la causa subyacente. Qué significa cada pieza y por qué la biblioteca estándar pide justo esas tres.

⏱ 17 min

Cualquier tipo puede viajar dentro de un Err: un String, un entero, un enum cualquiera. Pero para que tu error se integre en el ecosistema —que lo acepte Box<dyn Error>, que anyhow lo absorba, que las herramientas impriman su cadena de causas— tiene que firmar un contrato: el trait std::error::Error. Ese contrato es sorprendentemente pequeño, apenas un método con cuerpo por defecto, pero sus dos supertraits obligatorios y su método source codifican una teoría completa de qué es un error y para quién habla. Entender qué implica implementarlo es entender qué separa “un valor que resultó estar en un Err” de “un fallo de primera clase”.

🎯 Al terminar esta lección sabrás
  • Leer la definición del trait Error y el papel de sus supertraits Display y Debug.
  • Distinguir el mensaje para el usuario (Display) del volcado para el programador (Debug).
  • Implementar source para exponer la causa subyacente y encadenar errores.
  • Recorrer la cadena de causas de un error hasta su raíz con un bucle.

Un contrato de tres piezas

La definición del trait, despojada de métodos obsoletos y de APIs aún inestables, cabe en unas líneas:

use std::fmt::{Debug, Display};

pub trait Error: Debug + Display {
    // La causa subyacente, si existe. Por defecto, ninguna.
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        None
    }
}

Lo primero que sorprende es que Error no añade casi nada: no exige un método mensaje(), no impone un código numérico, no fuerza una jerarquía de clases. Lo que hace es componer requisitos que ya existían. Al escribir trait Error: Debug + Display, declara dos supertraits: ningún tipo puede implementar Error sin haber implementado antes Display y Debug. Esa es la carga real del contrato, y no es caprichosa.

  • Display es la cara del error para el usuario: una frase legible, sin jerga interna, que explica qué salió mal. Es lo que el programa imprime cuando informa de un fallo.
  • Debug es la cara para el programador: el volcado estructural que aparece en un panic! o en un log de diagnóstico. Casi siempre se obtiene con #[derive(Debug)].

Un error, por definición en Rust, es un valor que sabe presentarse a dos públicos. Si no puede hacerlo, no es un error: es solo un dato que casualmente metiste en un Err.

source: el hilo que une las causas

El único método propio del trait es source, y su tipo de retorno merece cada símbolo: Option<&(dyn Error + 'static)>. Devuelve opcionalmente una referencia a otro Error, el que provocó a este. Un error de “no se pudo cargar la configuración” tiene como source el error de “fichero no encontrado” que lo originó; ese, a su vez, podría tener el suyo. source es el eslabón que convierte errores sueltos en una cadena de causalidad.

El cuerpo por defecto devuelve None: un error sin causa subyacente, una hoja del árbol, no necesita hacer nada. Solo implementas source cuando tu error envuelve a otro y quieres que esa procedencia siga siendo accesible.

El detalle fino es el contrato tácito entre Display y source: cada capa describe solo su propio nivel. El Display del error externo dice “no se pudo cargar la configuración”, y no repite el mensaje del fichero no encontrado. Ese mensaje inferior vive en el source, y quien quiera la historia completa la recompone recorriendo la cadena. Duplicar el mensaje de la causa dentro del Display del padre es el error de novato que produce informes como “fallo: fallo: fallo: fichero no encontrado”.

Implementar Error a mano

Reunámoslo todo en un tipo que envuelve un std::io::Error:

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

#[derive(Debug)]
struct ErrorConfig {
    ruta: String,
    causa: std::io::Error,
}

impl fmt::Display for ErrorConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // Solo su propio nivel: la causa la aporta source, no este mensaje.
        write!(f, "no se pudo leer la configuracion en '{}'", self.ruta)
    }
}

impl Error for ErrorConfig {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(&self.causa) // expone el io::Error subyacente
    }
}

Tres bloques: derive(Debug) cubre un supertrait; el impl Display cubre el otro y redacta el mensaje humano; el impl Error solo redefine source para exponer la causa. Con esto, ErrorConfig ya es un ciudadano de pleno derecho del ecosistema de errores.

Recorrer la cadena es entonces un bucle sobre source:

fn informar(e: &dyn Error) {
    eprintln!("error: {e}");
    let mut actual = e.source();
    while let Some(causa) = actual {
        eprintln!("  causado por: {causa}");
        actual = causa.source();
    }
}

Cada vuelta imprime el Display de un nivel y desciende al siguiente con source, hasta que uno devuelve None. Así se genera el clásico informe “error … causado por … causado por …” que verás producir a anyhow en las lecciones siguientes: no es magia, es este bucle.

flowchart LR
A[ErrorConfig con Display propio] -->|source| B[io Error fichero no encontrado]
B -->|source| C[None fin de la cadena]
A --> D[Display para el usuario]
A --> E[Debug para el programador]
style A fill:#cba6f7,color:#11111b
style B fill:#f38ba8,color:#11111b
style C fill:#89b4fa,color:#11111b
style D fill:#a6e3a1,color:#11111b
style E fill:#a6e3a1,color:#11111b

El porqué del ’static

En dyn Error + 'static, la cota 'static desconcierta al principio. No significa “vive toda la ejecución”: significa que el tipo no contiene referencias prestadas de duración limitada, que es dueño de todo lo que lleva dentro. Esa autonomía es la que permite guardar el error en un Box, moverlo por la pila de llamadas y, sobre todo, aplicarle downcast para recuperar su tipo concreto más tarde. Un error que tomara prestado un fragmento de otro dato no podría sobrevivir al retorno de la función que lo creó; por eso el trait pide errores que no dependan de nadie.

💡
Deriva Debug siempre; a mano solo lo justo

De los dos supertraits, Debug casi nunca se escribe: #[derive(Debug)] lo resuelve y basta. El único que redactas de verdad es Display, porque solo tú sabes qué frase merece leer el usuario. Si te descubres implementando Debug a mano en un error, suele ser para redactar un campo sensible (un token, una contraseña); en cualquier otro caso, derivarlo es lo correcto y lo idiomático.

Un error no es un dato: es un dato que sabe rendir cuentas

El trait Error es una tesis sobre qué significa que algo sea un fallo de primera clase en un lenguaje. En muchos lenguajes cualquier objeto puede lanzarse; “error” no es una categoría, es un uso circunstancial. Rust invierte la relación: ser un error es implementar un contrato, y ese contrato encierra una teoría en dos partes. Primero, un error tiene dos audiencias irreductibles —el usuario, que necesita una frase, y el programador, que necesita la estructura— y por eso Display y Debug no son opcionales sino supertraits: no puedes ser un error sin saber hablarles a ambos. Segundo, un error rara vez nace solo; brota de otro, que brotó de otro, y esa genealogía es información diagnóstica de primer orden. source la preserva como una lista enlazada de causas donde cada nodo cuenta únicamente su capítulo y delega el resto hacia abajo. La consecuencia es honda: el manejo idiomático de errores en Rust no consiste en aplastar la información —convertirlo todo en un String y perder la procedencia— sino en estratificarla, de modo que en la cima tengas un mensaje corto para el usuario y, por debajo, la cadena entera para el forense. Cuando en las próximas lecciones thiserror genere este impl por ti y anyhow capture la cadena sola, no estarán inventando nada: estarán industrializando este contrato de tres piezas. Domínalo a mano una vez y ambas crates dejarán de ser magia para volverse mera comodidad.

📝
Lo esencial del trait Error

Error es un trait con dos supertraits obligatorios —Display (mensaje para el usuario) y Debug (volcado para el programador)— y un método source que devuelve la causa subyacente, None por defecto. Implementarlo a mano son tres pasos: derivar Debug, escribir Display describiendo solo tu nivel, y redefinir source si envuelves otro error. La cadena de causas se recorre en bucle llamando a source hasta None. La cota 'static obliga a que el error sea dueño de sus datos.

⚔️ Firma el contrato a mano
  1. Define struct ErrorParseo que envuelva un std::num::ParseIntError junto al texto de entrada. Deriva Debug, implementa Display (solo tu nivel) e implementa Error con source.
  2. Escribe la función informar del bucle y pruébala con una instancia de tu error; comprueba que imprime dos líneas: la tuya y la de la causa.
  3. Provoca a propósito el error de novato: mete el mensaje de la causa dentro del Display del padre y observa la duplicación al recorrer la cadena. Luego corrígelo.
  4. Intenta implementar Error para un tipo que no implemente Display. Lee el error del compilador e identifica qué supertrait falta.
  5. Explica con tus palabras por qué el retorno de source es Option<&(dyn Error + 'static)> y no Option<TuTipoConcreto>. Qué gana el ecosistema con ese borrado de tipo.