Tratamento de Erros em Go: errors.Is, errors.As e wrap
1. Visão geral
Em Go, erros são valores comuns. A função retorna o erro para quem a chamou, e quem chama decide como tratá-lo. Quando um erro passa por várias camadas como repository, service e handler, você geralmente quer adicionar contexto mantendo a capacidade de reconhecer o erro original.
Este artigo mostra como fazer wrap de erros usando fmt.Errorf com %w, verificar erros com errors.Is, extrair erros personalizados com errors.As e combinar múltiplos erros com errors.Join. No final, tem uma seção sobre testes e os erros mais comuns. Todos os exemplos usam apenas a biblioteca padrão e funcionam com Go 1.20 ou superior. Apenas errors.AsType requer Go 1.26.
2. Sentinel error e as limitações da comparação ==
Em Go, error é uma interface bem simples:
type error interface {
Error() string
}
A forma mais simples de definir um erro “nomeado” é criar um sentinel error. É uma variável no escopo do pacote que quem chama pode comparar diretamente:
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("Pedido não encontrado")
}
}
Pedido não encontrado
errors.New cria um novo ponteiro. Por isso err == ErrOrderNotFound é verdadeiro apenas quando err é exatamente essa variável. Funciona bem até que você comece a adicionar contexto ao erro.
3. Fazer wrap de erros com fmt.Errorf e %w
3.1. Como %w é diferente de %v
Suponha que a camada de serviço queira adicionar o ID do pedido à mensagem de erro:
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
Explicação de cada linha:
fmt.Errorf(... %w ...)cria um novo erro. Esse erro mantém uma referência aoerroriginal através do métodoUnwrap() error.err == ErrOrderNotFoundretornafalseporqueerragora é um valor diferente, chamado de erro envolvido.errors.Ispercorre a cadeia de wrap e encontraErrOrderNotFounddentro dela.errors.Unwrapremove exatamente uma camada e retorna o erro original.
Se você trocar %w por %v, a mensagem impressa fica idêntica. Mas errors.Is retornará false, porque %v apenas copia o texto em vez de manter o erro original.
3.2. Cadeia de wrap com múltiplas camadas
Cada camada pode adicionar outra camada de wrap. errors.Is ainda consegue encontrar o erro original não importa o quão profundo esteja:
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
A mensagem de erro agora lê como um stack trace reduzido: a camada mais externa fica à esquerda, a causa raiz fica à direita.
4. errors.Is: verificar “este erro é X?”
errors.Is(err, target) retorna true se qualquer erro na cadeia satisfizer uma dessas duas condições:
- É igual a
targetna comparação com==, ou - Tem um método
Is(error) boole esse método retornatrueparatarget.
A segunda condição permite que um tipo de erro declare que é “equivalente” a um sentinel. Por exemplo, você tem um erro genérico ErrNotFound e um tipo de erro mais 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
O handler HTTP apenas precisa verificar errors.Is(err, ErrNotFound) para retornar 404. Não precisa saber se o erro vem de um pedido, cliente ou produto.
Nota: Não chame
Unwrapdentro do métodoIs, porqueerrors.Isjá percorre a cadeia por conta própria. O métodoIsdeve apenas responder pela camada de erro atual.
5. errors.As: extrair um erro de tipo específico
Sentinel apenas diz o que um erro é. Quando precisa de mais dados como código de erro ou ID, você usa um tipo de erro personalizado e o extrai com errors.As.
5.1. Tipo de erro personalizado com 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
}
Quem chama trata assim:
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
Pontos importantes a entender:
errors.Aspercorre a cadeia de wrap. Quando encontra o primeiro erro que pode ser atribuído ao tipo*PaymentError, o atribui apee retornatrue.- Porque
PaymentErrortemUnwrap(),errors.Is(err, ErrInsufficientFunds)ainda consegue penetrar no campoErr. - O segundo parâmetro deve ser um ponteiro para a variável destino. Aqui é
&pe, que tem tipo**PaymentError.
5.2. Armadilha do receiver de ponteiro
O método Error() é declarado em *PaymentError, então apenas *PaymentError implementa error. Isso vai dar erro:
var pe PaymentError
errors.As(err, &pe) // panic: errors: *target must be interface or implement error
go vet detecta esse erro no build. A regra fácil de lembrar: o tipo da variável destino deve ser exatamente o tipo que você retorna. Você retorna &PaymentError{...} então a variável destino deve ser *PaymentError.
5.3. errors.AsType (Go 1.26+)
A partir de Go 1.26, a biblioteca padrão tem uma versão genérica mais concisa. Você não precisa declarar a variável antecipadamente:
if pe, ok := errors.AsType[*PaymentError](err); ok {
fmt.Println("code:", pe.Code)
}
Essa forma garante segurança de tipo no momento da compilação, então não pode causar panic como na seção 5.2. Se seu projeto já está em Go 1.26, você deveria usar isso.
6. Combinar múltiplos erros com errors.Join
A partir de Go 1.20, um erro pode envolver múltiplos erros filhos. O caso típico é validação de dados, quando você quer reportar todos os erros de uma vez em vez de parar no primeiro:
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
Lembre-se de adicionar "strings" à seção import. O resultado mostra três coisas:
errors.Joinconcatena as mensagens com quebras de linha.errors.Iseerrors.Asverificam cada erro filho.errors.Joinretornanilse todos os argumentos sãonilou a lista está vazia. Então você não precisa escreverif len(errs) == 0.
fmt.Errorf também aceita múltiplos %w na mesma string de formato, por exemplo fmt.Errorf("sync order: %w; %w", errDB, errCache). O erro resultante envolverá ambos os erros.
7. Escrever testes para comportamento de erro
Ao testar, você deve verificar a identidade do erro usando errors.Is ou errors.As. Não compare strings de mensagens. Aqui está um teste table-driven para checkout, coloque em 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: "tem saldo suficiente", balance: 1000, amount: 500},
{name: "saldo insuficiente", 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("não esperava erro, mas obtive: %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("erro não contém *PaymentError: %v", err)
}
if pe.Code != tc.wantCode {
t.Errorf("code = %q, esperava %q", pe.Code, tc.wantCode)
}
})
}
}
Execute o teste:
go test -v -run TestCheckout ./...
=== RUN TestCheckout
=== RUN TestCheckout/tem_saldo_suficiente
=== RUN TestCheckout/saldo_insuficiente
--- PASS: TestCheckout (0.00s)
--- PASS: TestCheckout/tem_saldo_suficiente (0.00s)
--- PASS: TestCheckout/saldo_insuficiente (0.00s)
PASS
Esse teste continua funcionando corretamente se depois alguém mudar a mensagem de erro ou adicionar outra camada de wrap. É por isso que você deve verificar identidade em vez de strings.
8. Erros comuns e quando NÃO fazer wrap
8.1. Comparar strings err.Error()
if strings.Contains(err.Error(), "not found") { ... } // quebra facilmente
Tudo que você precisa é mudar a mensagem uma vez, ou outro erro contém acidentalmente “not found”, e a lógica quebra. Use sentinel combinado com errors.Is.
8.2. Typed nil
func (s *PaymentService) Refund(orderID int) error {
var pe *PaymentError // nil
// ... sem erros ...
return pe // NÃO é nil!
}
A interface error agora contém o tipo *PaymentError com valor nil. Porque a interface tem um tipo, err != nil ainda retorna true. Quando não há erro, sempre escreva return nil explicitamente.
8.3. Wrap expõe detalhes internos
Quando você faz wrap de sql.ErrNoRows com %w, o seu pacote se compromete implicitamente a deixar quem chama verificar errors.Is(err, sql.ErrNoRows). Esse erro vira parte da sua API pública. Se depois você trocar para MongoDB, o código de quem chama quebra.
Na fronteira do pacote, tem duas opções melhores:
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("find order %d: %w", id, ErrOrderNotFound) // traduz para erro do domínio
}
return fmt.Errorf("find order %d: %v", id, err) // mantém só a mensagem, esconde o erro original
Nota:
%wé uma decisão sobre API, não apenas sobre formatação. Só faça wrap quando realmente quer que quem chama consiga verificar o erro original.
8.4. Logar E retornar
if err != nil {
log.Printf("charge failed: %v", err)
return fmt.Errorf("charge: %w", err)
}
Fazer isso em cada camada faz um erro aparecer várias vezes no log. A regra geral é ou trate (logue), ou retorne, não faça os dois. Logar normalmente fica para cargo da camada mais externa, como um handler HTTP ou main.
8.5. Contexto repetido, informação faltando
Mensagens como failed to load order: failed to query: failed to ... ficam longas sem adicionar nada. Inclua o nome da operação e dados importantes como loadOrder id=7: .... Pule “failed” porque já é um erro, obviamente é uma falha.
8.6. Quando NÃO usar errors.As
Se você só precisa saber qual é o tipo do erro, errors.Is com um sentinel é suficiente e mais simples. Só crie um tipo de erro personalizado quando quem chama realmente precisa dos dados que vêm com ele, como código de erro, campo inválido ou tempo de retry.
9. Conclusão
Você explorou todo o kit de ferramentas para tratamento de erros em Go:
fmt.Errorfcom%wadiciona contexto mantendo o erro original.%vapenas mantém o texto.errors.Isverifica se um erro corresponde a um sentinel, percorrendo toda a cadeia de wrap. Um tipo de erro pode definir o métodoIspara declarar sua própria equivalência.errors.As(ouerrors.AsTypea partir de Go 1.26) extrai um erro de tipo específico com seus dados. O tipo da variável destino deve corresponder exatamente ao tipo que você retorna.errors.Joine múltiplos%wpermitem que um erro envolva vários erros filhos.
Por fim, pense em fazer wrap como parte do design da API. Wrap é intencional, traduz erros de infraestrutura para erros de domínio na fronteira do pacote, e escreva testes baseados na identidade do erro em vez de strings de mensagens.