DEV Community

Cover image for Tipos de errores, Wrapping e Inspección en Go
Juan Carlos Garcia Esquivel
Juan Carlos Garcia Esquivel

Posted on

Tipos de errores, Wrapping e Inspección en Go

Tabla de Contenidos

Objetivo

Aprender a diseñar tipos de errores personalizados con metadatos de negocio en Go y a propagarlos correctamente a través del flujo de la aplicación sin perder su tipo ni su información de origen.

Restricciones de los Errores Basados en Texto

En programas sencillos, el uso de errors.New es perfecto. Sin embargo, en aplicaciones empresariales los errores necesitan transportar datos estructurados.

Por ejemplo, si una validación de entrada falla, el frontend no solo quiere saber que "hubo un error", necesita saber qué campo exacto falló (ej. email) y qué regla se violó (ej. formato inválido). Si solo devolvemos una cadena de texto, la capa superior de nuestra aplicación tendría que parsear el texto del error con expresiones regulares para extraer los metadatos. Esto es extremadamente ineficiente, propenso a errores de formato y acopla la lógica interna a los mensajes de texto visibles al usuario.

Tipos de Errores Comunes en Go

Antes de analizar cómo propagar y envolver errores, es fundamental comprender los dos patrones principales que se utilizan en Go:

  1. Sentinel Errors: Son variables globales predefinidas que representan un estado de error estático y específico. Se definen a nivel de paquete y se comparan directamente por valor.

    • Ejemplos estándar: io.EOF, sql.ErrNoRows.
    • Creación: Generalmente declarados con errors.New (como var ErrNotFound = errors.New("not found")).
  2. Custom Structs (Errores estructurados personalizados): Son estructuras que implementan la interfaz error y contienen campos adicionales para transportar metadatos dinámicos del fallo en tiempo de ejecución (como códigos de estado o parámetros de entrada).

    • Creación: Un struct personalizado que implementa el método Error() (ej. type ValidationError struct { Field string }).

Semáforo en rojo vs. Multa de tránsito
Para entender la diferencia:

  • Sentinel Error es como un semáforo en rojo. Es único, estático y global. No importa quién seas o cuándo pases, la luz roja siempre significa "Alto". En tu código, comparas tu error directamente contra esta señal fija: errors.Is(err, sql.ErrNoRows).
  • Custom Struct es como una multa de tránsito. Contiene datos específicos redactados para tu caso (matrícula, velocidad, hora, costo). No puedes compararla directamente con una plantilla fija; necesitas extraer su información usando errors.As.

El término "centinela" proviene de los guardias militares apostados en una caseta. Su único trabajo es dar una señal fija (como gritar "¡Alerta!" o "¡Fin!") cuando ocurre un suceso concreto.

Errores Personalizados en Go

Para entender esto, piensa en el diagnóstico de fallos en un automóvil. Si la luz de "Fallo de Motor" se enciende, el conductor solo tiene un texto general: "Algo anda mal". Esto equivale a un error básico creado con errors.New.

Sin embargo, el mecánico conecta un escáner OBD-II al coche para extraer el código exacto (como P0300 - fallos de encendido del cilindro). Este código viene con metadatos específicos: el cilindro afectado, las revoluciones del motor al momento del fallo y la temperatura. En Go, un Custom Struct es ese reporte estructurado del escáner que hereda la capacidad de comportarse como un simple error gracias a la interfaz error, pero permitiéndonos acceder a su información detallada.

Conceptos Clave

  • Estructura Personalizada (Custom Struct): Un struct propio donde defines campos para almacenar metadatos (ej. códigos HTTP, códigos de base de datos o listas de validaciones).
  • Type Assertion / Type Switch: Mecanismos clásicos para preguntar si una interfaz error contiene un tipo concreto bajo el capó.
  • Error Wrapping (Envoltorio): La capacidad de agregar contexto a un error existente envolviéndolo con el verbo %w en fmt.Errorf.
  • errors.Is: Función de Go 1.13+ que determina si un error específico o cualquiera de sus errores envueltos coincide con un error centinela.
  • errors.As: Función de Go 1.13+ que permite buscar un tipo de error específico en una cadena de errores envueltos y extraerlo en una variable de destino.

Implementar e Inspeccionar Errores

A continuación, implementaremos un error de validación personalizado y lo examinaremos a través del mecanismo moderno de inspección.

1. Definición del Error Estructurado

Creamos un struct llamado ValidationError e implementamos la interfaz error.

package main

import (
    "errors"
    "fmt"
)

// ValidationError estructura los detalles de un error de validación.
type ValidationError struct {
    Field   string
    Message string
}

// Error implementa la interfaz integrada error.
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error on field '%s': %s", e.Field, e.Message)
}
Enter fullscreen mode Exit fullscreen mode

2. Retorno y Wrapping de Errores

El siguiente código muestra cómo una capa puede validar los datos de un usuario y cómo una capa superior puede envolver el error para añadir contexto (por ejemplo, saber que el fallo ocurrió durante el registro):

func ValidateEmail(email string) error {
    if email == "" {
        return &ValidationError{Field: "email", Message: "cannot be empty"}
    }
    return nil
}

func RegisterUser(email string) error {
    err := ValidateEmail(email)
    if err != nil {
        // Envolvemos el ValidationError usando %w para conservar su identidad
        return fmt.Errorf("failed to register user: %w", err)
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

3. Inspección Avanzada con errors.As

Para recuperar los metadatos específicos del error envuelto, utilizamos errors.As:

func main() {
    err := RegisterUser("")
    if err != nil {
        // Declaramos una variable del tipo concreto del error que buscamos
        var valErr *ValidationError

        // errors.As busca ValidationError en toda la cadena de errores envueltos
        if errors.As(err, &valErr) {
            fmt.Printf("Inspection successful! Failed field: %s, Reason: %s\n", 
                valErr.Field, valErr.Message)
        } else {
            fmt.Printf("Occurred another type of error: %s\n", err)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

¿Qué pasa cuando imprimes el error final?

Para entender cómo se construye el mensaje de error que termina viendo el usuario, podemos descomponer la suma de textos paso a paso desde el origen:

  1. El error de origen (ValidationError):
    Internamente genera su string al llamar a su método Error():
    "validation error on field 'email': cannot be empty"

  2. El error contenedor (fmt.Errorf con %w):
    En RegisterUser, tomas el error anterior y lo inyectas en un nuevo mensaje de contexto:
    "failed to register user: " + [Error de origen]

  3. El resultado final:
    Al imprimir en la función main usando fmt.Println(err):

   failed to register user: validation error on field 'email': cannot be empty
Enter fullscreen mode Exit fullscreen mode

¿Cómo funciona la Cadena de Envolturas (Wrapping Chain) y el método Unwrap?

Conceptualmente, el mecanismo de envoltura en Go funciona exactamente como una lista enlazada simple (singly linked list) de una sola dirección.

En lugar de tener una propiedad física .Next, Go utiliza el método Unwrap() como el enlace para saltar al siguiente error de la cadena. El error raíz original representa el último nodo, el cual devuelve nil al ser desenvuelto.

Así es como se ve la jerarquía de memoria estructurada como nodos de una lista enlazada:

3-resources/notes/mermaid-d4b387b9.png

Cuando ejecutas errors.As(err, &valErr), Go realiza los siguientes pasos de forma automática:

  1. Comprueba si el Error Externo es de tipo *ValidationError. Como no lo es (es un error de formato estándar creado por fmt.Errorf), no puede extraerlo directamente.
  2. Al fallar, Go llama al método Unwrap() del Error Externo. Esto le devuelve el error interno: el puntero a ValidationError.
  3. Go evalúa este error interno. Como coincide con el tipo que declaraste (*ValidationError), la búsqueda tiene éxito, copia los datos en tu variable valErr y retorna true.
  4. Si llamara a Unwrap() una vez más sobre ValidationError, este devolvería nil porque es el origen del fallo y no envuelve a nadie más.

4. Inspección de Errores Centinela Envueltos con errors.Is

A diferencia de errors.As (que busca coincidencias por tipo para extraer metadatos de estructuras), errors.Is busca coincidencias por valor contra un error centinela predefinido.

Imaginemos un flujo multicapa en nuestra aplicación donde un error de la base de datos se envuelve secuencialmente mientras sube por nuestra arquitectura:

package main

import (
    "errors"
    "fmt"
)

// Definimos el error centinela global en la capa de datos
var ErrRecordNotFound = errors.New("database record not found")

// Capa de Infraestructura / Repositorio
func fetchRow() error {
    return ErrRecordNotFound
}

// Capa de Servicio de Negocio
func processUser() error {
    err := fetchRow()
    if err != nil {
        // Envolvemos el error usando %w
        return fmt.Errorf("error in user service: %w", err)
    }
    return nil
}

// Capa del Controlador HTTP / Handler
func apiController() error {
    err := processUser()
    if err != nil {
        // Volvemos a envolver agregando más contexto
        return fmt.Errorf("controller failed to register: %w", err)
    }
    return nil
}

func main() {
    err := apiController()
    if err != nil {
        // Evaluamos si el error original en el fondo de la cadena es ErrRecordNotFound
        if errors.Is(err, ErrRecordNotFound) {
            fmt.Println("Match detected! Redirecting to 404 page...")
        } else {
            fmt.Println("Generic server error:", err)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

¿Cómo funciona la inspección recursiva bajo el capó?

Cuando ejecutas errors.Is(err, target) o errors.As(err, &target), el runtime de Go realiza una búsqueda secuencial a través de la lista enlazada de errores llamando a las siguientes reglas:

  1. Comparación Directa: Go comprueba si el error actual coincide con el objetivo. Si coincide, devuelve true.
  2. Método Unwrap: Si no hay coincidencia, Go verifica si el error actual implementa el método Unwrap() error.
  3. Paso de Lista: Si Unwrap() devuelve un error interno, Go se desplaza a ese nodo y repite la inspección.
  4. Condición de Parada: La búsqueda se detiene al llegar a un nodo que devuelve nil. Si no hubo coincidencias, retorna false.

Gráficamente, la búsqueda secuencial por la lista enlazada se ve así:

3-resources/notes/mermaid-d2daaff6.png

Casos de Uso Comunes

Tabla de Comparación de Herramientas de Inspección

Herramienta Cuándo Usarla Ejemplo de Código Soporta Wrapping ¿Es Recomendable?
Comparación Directa (==) Errores centinela simples (sin envolver) if err == sql.ErrNoRows No No (Evitar en código moderno)
errors.Is Comprobar coincidencia de un Sentinel Error errors.Is(err, sql.ErrNoRows) (Buena práctica)
Type Assertion Extraer estructuras directamente (sin envolver) valErr, ok := err.(ValidationError) No No (Evitar si puede haber wrapping)
errors.As Extraer metadatos de un Custom Struct errors.As(err, &valErr) (Buena práctica)

¿Cuándo elegir Sentinel Error frente a Custom Struct?

Para decidir qué camino tomar, analiza qué necesita hacer el consumidor del error para gestionar el fallo:

  • Elige un Sentinel Error cuando la capa superior solo necesita una confirmación binaria (saber si ocurrió esa situación específica o no) para tomar decisiones de control de flujo. No necesitas añadir información variable sobre el fallo.

    • Caso de uso real (Aplicar un cupón en un E-commerce): Cuando un usuario intenta aplicar un cupón en el checkout, el repositorio busca el código en la base de datos. Si el código no existe, el repositorio devuelve un Sentinel Error: ErrCouponNotFound. El servicio que llama al repositorio solo necesita saber de forma binaria si el cupón existe o no. Si el error es ErrCouponNotFound, el sistema responde al usuario con un mensaje amigable indicando que el cupón no es válido. No se requiere información dinámica (como el ID del cupón o la hora del servidor) dentro de la estructura del error para tomar esta decisión.
    // Definición en el repositorio
    var ErrCouponNotFound = errors.New("coupon not found in database")
    
    // Verificación en el controlador/handler HTTP usando errors.Is
    if errors.Is(err, repository.ErrCouponNotFound) {
        http.Error(w, "El cupon no es valido", http.StatusNotFound)
        return
    }
    
  • Elige un Custom Struct cuando la capa superior necesita datos estructurados concretos para poder recuperarse del error o formular una respuesta específica para el cliente.

    • Caso de uso real (Procesar un pago con Stripe/Gateway): Cuando una pasarela de pago rechaza una tarjeta, no basta con decir "falló". La pasarela devuelve información dinámica: la razón del rechazo (ej. fondos insuficientes, tarjeta expirada, CVV incorrecto) y si el error es temporal (es decir, si vale la pena reintentar el cobro). El sistema de checkout necesita acceder a estos metadatos para tomar una decisión: si el error indica que es un fallo temporal (IsRetryable == true), el sistema puede programar un reintento automático. Si el error es definitivo (ej. DeclineCode == "insufficient_funds"), el sistema aborta la compra inmediatamente y le muestra al usuario la causa específica para que intente con otra tarjeta.
    // Definición del error estructurado
    type PaymentGatewayError struct {
        DeclineCode string
        IsRetryable bool
    }
    
    func (e *PaymentGatewayError) Error() string {
        return fmt.Sprintf("payment failed: code=%s, retryable=%t", e.DeclineCode, e.IsRetryable)
    }
    
    // Extracción y toma de decisiones en el controlador usando errors.As
    var payErr *PaymentGatewayError
    if errors.As(err, &payErr) {
        if payErr.IsRetryable {
            // Reintentar transacción o sugerir reintento al usuario
            http.Error(w, "Error temporal de red en el banco. Reintente.", http.StatusServiceUnavailable)
            return
        }
        // Mostrar la razón específica del rechazo del banco
        http.Error(w, fmt.Sprintf("Pago rechazado: %s", payErr.DeclineCode), http.StatusPaymentRequired)
        return
    }
    

Para no morir en el intento y consejos que no pediste

  • El peligro de comparar directamente con == en Go moderno: Si tus dependencias externas o funciones envuelven los errores usando %w (práctica estándar hoy en día), cualquier comparación directa err == ErrCentinela fallará. Usa siempre errors.Is(err, ErrCentinela).
  • Cuidado con los punteros nil en interfaces de error: En Go, una interfaz es nula si y solo si su tipo y su valor son ambos nulos (nil). Si devuelves una variable de tipo puntero a tu struct personalizado (ej. *ValidationError) que vale nil, la interfaz devuelta no será nula, ya que contendrá un tipo asignado (*ValidationError) y un valor nil. Por lo tanto, cualquier validación del estilo if err != nil evaluará a true (verdadero), creyendo erróneamente que ocurrió un error.

Código incorrecto (simula un error inexistente):

  func Validate() error {
      var err *ValidationError = nil
      return err // Devuelve una interfaz con Tipo=*ValidationError y Valor=nil. (err != nil) es true.
  }
Enter fullscreen mode Exit fullscreen mode

Código correcto (solución 1: retornar nil directamente):

  func Validate(email string) error {
      if email == "" {
          return &ValidationError{Field: "email", Message: "cannot be empty"}
      }
      return nil // Retorna nil explícito. La interfaz tendrá Tipo=nil y Valor=nil.
  }
Enter fullscreen mode Exit fullscreen mode

Código correcto (solución 2: comprobar nulidad antes de retornar):

  func Validate() error {
      var err *ValidationError = nil
      // ... lógica de validación ...
      if err == nil {
          return nil // Retorna la interfaz nula limpia
      }
      return err
  }
Enter fullscreen mode Exit fullscreen mode
  • Cuidado con la fuga de abstracciones al usar %w: Si envuelves un error interno de infraestructura (como sql.ErrNoRows) usando %w en tu repositorio, y este error viaja hasta la API de tu paquete público, los clientes de tu biblioteca podrían comenzar a depender de errors.Is(err, sql.ErrNoRows). Si en el futuro decides cambiar tu base de datos de relacional a NoSQL (por ejemplo, MongoDB), romperás el código de tus clientes porque ya no estarás devolviendo un error que envuelve a sql.ErrNoRows. Como regla general, envuelve usando %w solo aquello que sea parte del contrato público de tu módulo o traduce los errores de infraestructura a errores semánticos en las fronteras.

Conclusiones del Tema

Las herramientas de Go para gestionar tipos de errores estructurados e inspeccionar cadenas de manera no intrusiva facilitan la separación de responsabilidades entre las capas lógicas de tu sistema. El dominio puede generar errores ricos en metadatos y la infraestructura de red (como HTTP o gRPC) puede deserializarlos sin acoplamientos rígidos.

Puntos clave a recordar:

  • Implementa la interfaz error en tus structs para transportar metadatos avanzados.
  • Usa fmt.Errorf("...: %w", err) para propagar errores agregando contexto sin perder su tipo original.
  • Prefiere siempre errors.Is y errors.As sobre comparaciones tradicionales para que tu código sea compatible con el anidamiento.

Top comments (0)