errors.Join y wrapping avanzado de errores

Avanzado
Go
Go
Actualizado: 19/04/2026

El manejo de errores en Go evolucionó con la incorporación de errors.Is, errors.As y errors.Join. Esas tres funciones, combinadas con fmt.Errorf("...: %w", err), cubren la práctica totalidad de escenarios reales sin necesidad de librerías externas. Esta lección profundiza en los patrones que diferencian una API correcta de una API difícil de mantener.

Errores centinela y comparación con errors.Is

Un error centinela es una variable exportada del paquete cuyo valor identifica de forma única una condición de fallo. La convención es llamarlos ErrXxx y declararlos con errors.New a nivel de paquete. El consumidor compara mediante errors.Is en lugar de usar ==, porque ese operador solo funcionaría si el error no viene envuelto:

package repo

import "errors"

var (
    ErrNoEncontrado   = errors.New("recurso no encontrado")
    ErrYaExiste       = errors.New("el recurso ya existe")
    ErrRestriccionFK  = errors.New("violación de clave foránea")
)

En el consumidor, errors.Is recorre toda la cadena de envoltura hasta localizar el centinela. Esto permite envolver con contexto sin romper la comparación:

usuario, err := repo.BuscarPorID(ctx, 42)
if err != nil {
    if errors.Is(err, repo.ErrNoEncontrado) {
        http.NotFound(w, r)
        return
    }
    http.Error(w, "error interno", http.StatusInternalServerError)
    return
}

Dentro del repositorio podemos enriquecer el mensaje conservando la identidad del centinela:

func (r *Repo) BuscarPorID(ctx context.Context, id int64) (*Usuario, error) {
    row := r.db.QueryRowContext(ctx, "SELECT id, email FROM usuarios WHERE id=$1", id)
    var u Usuario
    if err := row.Scan(&u.ID, &u.Email); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return nil, fmt.Errorf("usuario %d: %w", id, ErrNoEncontrado)
        }
        return nil, fmt.Errorf("scan usuario %d: %w", id, err)
    }
    return &u, nil
}

Los errores centinela tienen sentido cuando la condición es estable y forma parte del contrato público. Si la información relevante es dinámica (campo, motivo, código HTTP), conviene pasar a un error tipado.

Errores tipados y extracción con errors.As

Cuando necesitas devolver datos estructurados junto al mensaje, crea un tipo que implemente Error() string. Expón los campos como públicos para que el consumidor los extraiga con errors.As:

type ErrValidacion struct {
    Campo   string
    Valor   string
    Motivo  string
}

func (e *ErrValidacion) Error() string {
    return fmt.Sprintf("validación: campo %q con valor %q: %s", e.Campo, e.Valor, e.Motivo)
}

El consumidor declara una variable del tipo esperado y errors.As intenta asignar la primera coincidencia encontrada en la cadena:

var verr *ErrValidacion
if errors.As(err, &verr) {
    w.WriteHeader(http.StatusBadRequest)
    json.NewEncoder(w).Encode(map[string]string{
        "campo":  verr.Campo,
        "motivo": verr.Motivo,
    })
    return
}

Esta combinación permite a una API HTTP traducir automáticamente errores de validación en respuestas 400 con el campo y el motivo en el cuerpo, sin acoplar la capa de negocio al paquete net/http.

Usa punteros al tipo (*ErrValidacion) tanto al devolverlo como al pasarlo a errors.As. Si lo declaras por valor pierdes el método del puntero y el compilador obliga a reimplementar Error para el tipo valor.

Soporte de Unwrap y cadenas de envoltura

Los tipos que quieren participar en cadenas de envoltura implementan Unwrap() error. Ese método devuelve el error subyacente y permite a errors.Is y errors.As continuar la búsqueda:

type ErrRepositorio struct {
    Operacion string
    Entidad   string
    Err       error
}

func (e *ErrRepositorio) Error() string {
    return fmt.Sprintf("repo: %s %s: %v", e.Operacion, e.Entidad, e.Err)
}

func (e *ErrRepositorio) Unwrap() error { return e.Err }

Con esta implementación, podemos devolver &ErrRepositorio{Operacion: "insert", Entidad: "usuario", Err: ErrYaExiste} y los consumidores podrán seguir usando errors.Is(err, ErrYaExiste) sin preocuparse por la envoltura.

errors.Join para múltiples errores

Muchos escenarios reales no encajan en un único error. errors.Join produce un error compuesto que guarda una lista de errores subyacentes y muestra uno por línea al ser impreso. Esta función habilita patrones de validación agregada y de rollbacks compuestos sin inventar tipos propios.

import "errors"

func ValidarUsuario(u Usuario) error {
    var errs []error
    if u.Email == "" {
        errs = append(errs, &ErrValidacion{Campo: "email", Motivo: "obligatorio"})
    }
    if len(u.Password) < 10 {
        errs = append(errs, &ErrValidacion{Campo: "password", Motivo: "longitud mínima 10"})
    }
    if !strings.Contains(u.Email, "@") {
        errs = append(errs, &ErrValidacion{Campo: "email", Motivo: "formato inválido"})
    }
    return errors.Join(errs...)
}

Si la lista está vacía, errors.Join devuelve nil. El valor resultante implementa automáticamente un Unwrap() []error que permite a errors.Is y errors.As recorrer todos los componentes:

err := ValidarUsuario(u)
if err != nil {
    var verr *ErrValidacion
    for e := err; errors.As(e, &verr); {
        log.Printf("campo inválido: %s (%s)", verr.Campo, verr.Motivo)
        // avanzar al siguiente error validación en la cadena compuesta
        e = unwrapMultiple(e, verr)
    }
}

En Go moderno, puedes iterar directamente sobre los errores agrupados usando errors.Unwrap(err) y comprobando si devuelve []error. Para casos habituales, basta con errors.Is(err, ErrX) o errors.As(err, &verrObj) y la biblioteca se encarga de recorrer todos los componentes.

Rollbacks y cierres con errores combinados

Otro uso clásico de errors.Join es acumular errores de cierre en un flujo donde pueden fallar varias operaciones de limpieza. Un caso recurrente es cerrar un fichero tras un error de escritura: conviene reportar ambos:

func EscribirReporte(ruta string, datos []byte) (err error) {
    f, err := os.Create(ruta)
    if err != nil {
        return fmt.Errorf("crear %s: %w", ruta, err)
    }
    defer func() {
        // Si cerrar falla, acompañamos al error principal
        if cerr := f.Close(); cerr != nil {
            err = errors.Join(err, fmt.Errorf("cerrar %s: %w", ruta, cerr))
        }
    }()

    if _, err = f.Write(datos); err != nil {
        return fmt.Errorf("escribir %s: %w", ruta, err)
    }
    return nil
}

Gracias al patrón de named return (err error), el defer puede mutar el valor que devuelve la función. El resultado final informa tanto del error de escritura como del fallo de cierre sin perder información.

flowchart TD
    A[Operación 1 falla] --> C[errors.Join]
    B[Operación 2 falla] --> C
    C --> D[Error compuesto]
    D -->|errors.Is ErrX| E[Coincide en cualquiera]
    D -->|errors.As &amp;tipo| F[Extrae primero que case]

Error centinela vs error tipado vs errors.Join

La elección entre las tres técnicas depende de la información que quieres exponer:

  • 1. Error centinela (var ErrX = errors.New(...)): úsalo cuando la condición es binaria y forma parte del contrato estable del paquete. El consumidor compara con errors.Is.

  • 2. Error tipado (struct + Error()): úsalo cuando la respuesta al fallo requiere datos concretos (campo inválido, código HTTP, reintento recomendado). El consumidor extrae con errors.As.

  • 3. Error compuesto (errors.Join): úsalo cuando varias operaciones pueden fallar simultáneamente (validación multi-campo, cleanup tras error). El consumidor usa errors.Is o errors.As igualmente.

Un API bien diseñado combina los tres según la semántica. El repositorio devuelve un centinela (ErrNoEncontrado) envuelto con contexto. La capa de validación devuelve errors.Join de *ErrValidacion. La capa HTTP inspecciona con errors.Is y errors.As para generar la respuesta adecuada.

Evitar el antipatrón de logging duplicado

Un error recurrente consiste en registrar el mismo error en varias capas. Cuando cada función llama a log.Printf antes de devolver err, el mismo fallo aparece tres o cuatro veces en los logs con distinta redacción. El patrón idiomático en Go reserva el logging a un único punto alto (middleware o main) y hace que el resto de capas se limiten a envolver con contexto:

// Mal: cada capa registra
func (s *Servicio) Crear(ctx context.Context, u Usuario) error {
    if err := s.repo.Insertar(ctx, u); err != nil {
        log.Printf("error insertando usuario: %v", err) // duplicado
        return err
    }
    return nil
}

// Bien: envolver con contexto y propagar
func (s *Servicio) Crear(ctx context.Context, u Usuario) error {
    if err := s.repo.Insertar(ctx, u); err != nil {
        return fmt.Errorf("servicio.Crear usuario %q: %w", u.Email, err)
    }
    return nil
}

El middleware HTTP o el main reciben el error final, lo inspeccionan con errors.Is y errors.As y generan un solo registro estructurado con toda la cadena. Esta separación mantiene el volumen de logs manejable y facilita correlacionar entradas con peticiones.

Buenas prácticas al envolver

Unas reglas prácticas compactas para el día a día:

  • 1. Envolver con fmt.Errorf("acción %q: %w", valor, err), incluyendo el identificador útil (ID, ruta, campo) y el verbo de la operación.

  • 2. Reservar %w para la cadena de envoltura. Solo se admite uno por Errorf. Si necesitas incluir el mensaje textual de otro error sin unirlo a la cadena, usa %v.

  • 3. No envolver dos veces el mismo fallo. Si el consumidor ya recibirá un mensaje con contexto, evita duplicar la información.

  • 4. Evita errors.New con mensajes dinámicos (errors.New(fmt.Sprintf(...))). Para errores centinela usa literal; para errores con contexto, usa fmt.Errorf.

  • 5. Publica los centinelas y los tipos que forman parte del contrato en la documentación del paquete, idealmente en el mismo fichero donde se declaran.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Go es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Go

Explora más contenido relacionado con Go y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Combinar varios errores con errors.Join, recorrer cadenas de envoltura con errors.Is y errors.As, diseñar errores centinela y tipados, y adoptar patrones de salida para agregadores de validación y rollbacks.