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 alerroriginal a través del métodoUnwrap() error.err == ErrOrderNotFounddevuelvefalseporqueerres ahora un valor diferente, llamado error envuelto.errors.Isrecorre la cadena de wrapping y encuentraErrOrderNotFounddentro.errors.Unwrapdesenvuelve 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:
- Es igual a
targetcuando se compara con==, o - Tiene un método
Is(error) boolque devuelvetrueparatarget.
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
Unwrapdentro del métodoIs, porqueerrors.Isya recorre la cadena automáticamente. El métodoIssolo 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.Asrecorre la cadena de wrapping. Cuando encuentra el primer error que se puede asignar al tipo*PaymentError, lo asigna apey devuelvetrue.- Debido a que
PaymentErrortieneUnwrap(),errors.Is(err, ErrInsufficientFunds)aún puede penetrar el campoErr. - 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.Joinune los mensajes con saltos de línea.errors.Isyerrors.Aspueden verificar cada error secundario.errors.Joindevuelvenilsi todos los parámetros sonnilo la lista está vacía. Por lo tanto, no necesitas escribirif 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:
%wes 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.Errorfcon%wañade contexto mientras mantiene el error original.%vsolo mantiene el texto.errors.Isverifica si un error coincide con un sentinel, atravesando la cadena de wrapping. Un tipo de error puede definir un métodoIspara declarar su equivalencia.errors.As(oerrors.AsTypedesde 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.Joiny múltiples%wpermiten 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.