Manejo de errores en Go: errors.Is, errors.As y wrapping

1. Descripción general

En Go, los errores son valores normales. Una función devuelve el error a quien la llama, y quien la llama decide cómo manejarlo. Cuando un error atraviesa varias capas como repository, service, handler, generalmente quieres añadir contexto mientras aún puedes identificar el error original.

Este artículo te muestra cómo hacer wrap de errores con fmt.Errorf usando %w, verificar errores con errors.Is, obtener errores personalizados con errors.As y combinar múltiples errores con errors.Join. Al final encontrarás tests y errores comunes. Todos los ejemplos usan solo la librería estándar y funcionan con Go 1.20 o superior. Solo errors.AsType requiere Go 1.26.

2. Sentinel error y limitaciones de la comparación ==

En Go, error es una interfaz muy pequeña:

type error interface {
	Error() string
}

La forma más simple de definir un error “nombrado” es crear un sentinel error. Es una variable a nivel de package que quien llama puede comparar directamente:

package main

import (
	"errors"
	"fmt"
)

var ErrOrderNotFound = errors.New("order not found")

func findOrder(id int) error {
	if id != 42 {
		return ErrOrderNotFound
	}
	return nil
}

func main() {
	err := findOrder(7)
	if err == ErrOrderNotFound {
		fmt.Println("Orden no encontrada")
	}
}
Orden no encontrada

Aquí está por qué: errors.New crea un nuevo puntero. Por lo tanto, err == ErrOrderNotFound solo es verdadero si err es exactamente esa variable. Este enfoque funciona bien hasta que comienzas a añadir contexto a los errores.

3. Wrapping de errores con fmt.Errorf y %w

3.1. Cómo %w difiere de %v

Supongamos que la capa de servicio quiere añadir el ID del pedido al mensaje de error:

func loadOrder(id int) error {
	if err := findOrder(id); err != nil {
		return fmt.Errorf("loadOrder id=%d: %w", id, err)
	}
	return nil
}

func main() {
	err := loadOrder(7)
	fmt.Println(err)
	fmt.Println(err == ErrOrderNotFound)
	fmt.Println(errors.Is(err, ErrOrderNotFound))
	fmt.Println(errors.Unwrap(err) == ErrOrderNotFound)
}
loadOrder id=7: order not found
false
true
true

Explicación línea por línea:

  • fmt.Errorf(... %w ...) crea un error nuevo. Este error mantiene una referencia al err original a través del método Unwrap() error.
  • err == ErrOrderNotFound devuelve false porque err es ahora un valor diferente, llamado error envuelto.
  • errors.Is recorre la cadena de wrapping y encuentra ErrOrderNotFound dentro.
  • errors.Unwrap desenvuelve exactamente una capa y devuelve el error original.

Si cambias %w por %v, el mensaje impreso sigue siendo idéntico. Pero errors.Is devolverá false, porque %v solo copia la cadena de texto sin mantener el error original.

3.2. Cadena de wrapping en múltiples capas

Cada capa puede envolver una capa adicional. errors.Is aún puede encontrar el error original sin importar cuán profundo esté:

func handleGetOrder(id int) error {
	if err := loadOrder(id); err != nil {
		return fmt.Errorf("GET /orders/%d: %w", id, err)
	}
	return nil
}

// fmt.Println(handleGetOrder(7))
// fmt.Println(errors.Is(handleGetOrder(7), ErrOrderNotFound))
GET /orders/7: loadOrder id=7: order not found
true

El mensaje de error ahora se lee como un stack trace resumido: la capa externa está a la izquierda, la causa raíz está a la derecha.

4. errors.Is: verificar “¿es este error X?”

errors.Is(err, target) devuelve true si cualquier error en la cadena cumple una de estas dos condiciones:

  1. Es igual a target cuando se compara con ==, o
  2. Tiene un método Is(error) bool que devuelve true para target.

La segunda condición permite que un tipo de error declare que es ‘equivalente’ a un sentinel. Por ejemplo, podrías tener un error general ErrNotFound y un tipo de error más específico para clientes:

var ErrNotFound = errors.New("not found")

type CustomerNotFoundError struct {
	CustomerID string
}

func (e CustomerNotFoundError) Error() string {
	return "customer " + e.CustomerID + " not found"
}

func (e CustomerNotFoundError) Is(target error) bool {
	return target == ErrNotFound
}

func main() {
	err := fmt.Errorf("checkout: %w", CustomerNotFoundError{CustomerID: "C-001"})
	fmt.Println(errors.Is(err, ErrNotFound))
}
true

El handler HTTP solo necesita verificar errors.Is(err, ErrNotFound) para devolver un 404. No necesita saber si el error viene de una orden, un cliente o un producto.

Nota: No llames Unwrap dentro del método Is, porque errors.Is ya recorre la cadena automáticamente. El método Is solo debe responder sobre la capa de error actual.

5. errors.As: obtener un error de tipo específico

Un sentinel solo te dice lo que es el error. Cuando necesitas datos adicionales como códigos de error o IDs, usas un tipo de error personalizado y lo obtienes con errors.As.

5.1. Tipo de error personalizado con Unwrap

var ErrInsufficientFunds = errors.New("insufficient funds")

type PaymentError struct {
	OrderID int
	Code    string
	Err     error
}

func (e *PaymentError) Error() string {
	return fmt.Sprintf("payment failed for order %d (%s): %v", e.OrderID, e.Code, e.Err)
}

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

type PaymentService struct{ balance int }

func (s *PaymentService) Charge(orderID, amount int) error {
	if amount > s.balance {
		return &PaymentError{OrderID: orderID, Code: "PAY-402", Err: ErrInsufficientFunds}
	}
	s.balance -= amount
	return nil
}

func checkout(s *PaymentService, orderID, amount int) error {
	if err := s.Charge(orderID, amount); err != nil {
		return fmt.Errorf("checkout: %w", err)
	}
	return nil
}

El código llamador lo maneja así:

func main() {
	svc := &PaymentService{balance: 100}
	err := checkout(svc, 42, 500)

	var pe *PaymentError
	if errors.As(err, &pe) {
		fmt.Println("code:", pe.Code, "order:", pe.OrderID)
	}
	fmt.Println(errors.Is(err, ErrInsufficientFunds))
	fmt.Println(err)
}
code: PAY-402 order: 42
true
checkout: payment failed for order 42 (PAY-402): insufficient funds

Puntos clave:

  • errors.As recorre la cadena de wrapping. Cuando encuentra el primer error que se puede asignar al tipo *PaymentError, lo asigna a pe y devuelve true.
  • Debido a que PaymentError tiene Unwrap(), errors.Is(err, ErrInsufficientFunds) aún puede penetrar el campo Err.
  • El segundo parámetro debe ser un puntero a la variable de destino. Aquí es &pe, que tiene tipo **PaymentError.

5.2. Trampa del receptor de puntero

El método Error() se declara en *PaymentError, por lo que solo *PaymentError implementa error. Escribir lo siguiente sería un error:

var pe PaymentError
errors.As(err, &pe) // panic: errors: *target must be interface or implement error

go vet atrapa este error en tiempo de construcción. Ejecuta go vet ./... en tu CI. Regla fácil de recordar: el tipo de la variable de destino debe coincidir exactamente con el tipo que devolviste. Si devuelves &PaymentError{...}, la variable de destino debe ser *PaymentError.

5.3. errors.AsType (Go 1.26+)

A partir de Go 1.26, la librería estándar proporciona una versión genérica más concisa. No necesitas declarar la variable con anticipación:

if pe, ok := errors.AsType[*PaymentError](err); ok {
	fmt.Println("code:", pe.Code)
}

Este enfoque es type-safe en tiempo de compilación, por lo que no puede causar pánico como en la sección 5.2. Si tu proyecto ya está en Go 1.26, deberías usarlo.

6. Combinar múltiples errores con errors.Join

A partir de Go 1.20, un error puede envolver múltiples errores secundarios. Un caso típico es la validación de datos, cuando quieres reportar todos los errores de una vez en lugar de parar en el primero:

var (
	ErrEmptyName    = errors.New("name is empty")
	ErrInvalidEmail = errors.New("email is invalid")
)

type Customer struct {
	Name  string
	Email string
}

func validateCustomer(c Customer) error {
	var errs []error
	if c.Name == "" {
		errs = append(errs, ErrEmptyName)
	}
	if !strings.Contains(c.Email, "@") {
		errs = append(errs, ErrInvalidEmail)
	}
	return errors.Join(errs...)
}

func main() {
	err := validateCustomer(Customer{Name: "", Email: "abc"})
	fmt.Println(err)
	fmt.Println(errors.Is(err, ErrEmptyName), errors.Is(err, ErrInvalidEmail))
	fmt.Println(validateCustomer(Customer{Name: "An", Email: "an@shop.vn"}) == nil)
}
name is empty
email is invalid
true true
true

Recuerda añadir "strings" a las importaciones. El resultado muestra tres cosas:

  • errors.Join une los mensajes con saltos de línea.
  • errors.Is y errors.As pueden verificar cada error secundario.
  • errors.Join devuelve nil si todos los parámetros son nil o la lista está vacía. Por lo tanto, no necesitas escribir if len(errs) == 0.

fmt.Errorf también acepta múltiples %w en la misma cadena de formato, por ejemplo fmt.Errorf("sync order: %w; %w", errDB, errCache). El error resultante envolverá ambos errores.

7. Escribir tests para el comportamiento de errores

Al hacer tests, debes verificar la identidad del error usando errors.Is o errors.As. No compares cadenas de mensaje. A continuación hay un test table-driven para checkout, colocado en el archivo checkout_test.go:

package main

import (
	"errors"
	"testing"
)

func TestCheckout(t *testing.T) {
	tests := []struct {
		name     string
		balance  int
		amount   int
		wantErr  error
		wantCode string
	}{
		{name: "fondos suficientes", balance: 1000, amount: 500},
		{name: "fondos insuficientes", balance: 100, amount: 500,
			wantErr: ErrInsufficientFunds, wantCode: "PAY-402"},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			err := checkout(&PaymentService{balance: tc.balance}, 42, tc.amount)

			if tc.wantErr == nil {
				if err != nil {
					t.Fatalf("no se esperaba un error, se recibió: %v", err)
				}
				return
			}
			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("errors.Is(%v, %v) = false", err, tc.wantErr)
			}
			var pe *PaymentError
			if !errors.As(err, &pe) {
				t.Fatalf("el error no contiene *PaymentError: %v", err)
			}
			if pe.Code != tc.wantCode {
				t.Errorf("code = %q, se esperaba %q", pe.Code, tc.wantCode)
			}
		})
	}
}

Ejecuta el test:

go test -v -run TestCheckout ./...
=== RUN   TestCheckout
=== RUN   TestCheckout/fondos_suficientes
=== RUN   TestCheckout/fondos_insuficientes
--- PASS: TestCheckout (0.00s)
    --- PASS: TestCheckout/fondos_suficientes (0.00s)
    --- PASS: TestCheckout/fondos_insuficientes (0.00s)
PASS

Este test sigue funcionando correctamente si alguien después modifica el mensaje de error o añade una capa de wrapping. Esa es la razón por la que debes verificar la identidad en lugar de la cadena.

8. Errores comunes y cuándo NO debes hacer wrap

8.1. Comparar cadenas con err.Error()

if strings.Contains(err.Error(), "not found") { ... } // frágil

Basta una modificación en el mensaje, o un error diferente que casualmente contenga “not found”, para que la lógica se rompa. Usa un sentinel combinado con errors.Is.

8.2. Nil tipado

func (s *PaymentService) Refund(orderID int) error {
	var pe *PaymentError // nil
	// ... sin errores ...
	return pe // ¡NO es nil!
}

La interfaz error contiene el tipo *PaymentError con el valor nil. Debido a que la interfaz tiene un tipo, err != nil sigue devolviendo true. Cuando no hay error, siempre escribe return nil explícitamente.

8.3. El wrapping expone detalles internos

Cuando haces wrap de sql.ErrNoRows con %w, tu package se compromete implícitamente a que quien llama puede verificar errors.Is(err, sql.ErrNoRows). Ese error se convierte en parte de tu API pública. Después, si cambias a MongoDB, el código del llamador se romperá.

En el límite del package, tienes dos opciones mejores:

if errors.Is(err, sql.ErrNoRows) {
	return fmt.Errorf("find order %d: %w", id, ErrOrderNotFound) // traducir al error del dominio
}
return fmt.Errorf("find order %d: %v", id, err) // solo mantener el mensaje, ocultar el error original

Nota: %w es una decisión de API, no solo de formato. Solo haz wrap cuando realmente quieras que quien llama pueda verificar el error original.

8.4. Logging y retorno simultáneos

if err != nil {
	log.Printf("charge failed: %v", err)
	return fmt.Errorf("charge: %w", err)
}

Si haces esto en cada capa, un error aparecerá múltiples veces en el log. La regla general es o maneja (log) o devuelve, no hagas ambas. El logging debería dejarse para la capa más externa, como un handler HTTP o la función main.

8.5. Contexto repetido, información faltante

Mensajes como failed to load order: failed to query: failed to ... son largos pero no dicen nada más. Escribe el nombre de la operación y datos importantes como loadOrder id=7: .... Omite “failed” porque si es un error, ya es obvio que falló.

8.6. Cuándo NO debes usar errors.As

Si solo necesitas saber qué tipo de error es, errors.Is con un sentinel es suficiente y más simple. Solo crea un tipo de error personalizado cuando quien llama realmente necesita datos adjuntos, como código de error, campo incorrecto o tiempo de reintento.

9. Conclusión

Has pasado por el conjunto de herramientas de manejo de errores de Go:

  • fmt.Errorf con %w añade contexto mientras mantiene el error original. %v solo mantiene el texto.
  • errors.Is verifica si un error coincide con un sentinel, atravesando la cadena de wrapping. Un tipo de error puede definir un método Is para declarar su equivalencia.
  • errors.As (o errors.AsType desde Go 1.26) obtiene un error de tipo específico junto con sus datos. El tipo de la variable de destino debe coincidir exactamente con el tipo que devolviste.
  • errors.Join y múltiples %w permiten que un error envuelva múltiples errores secundarios.

Finalmente, considera que hacer wrap es parte del diseño de la API. Hazlo con propósito, traduce errores de infraestructura a errores de dominio en los límites del package, y escribe tests basados en la identidad del error en lugar de en la cadena de mensaje.

golangerror handlingerrorsbest practices